| 1 | /** |
| 2 | * Reference material generated from the running code: the REST API (from the |
| 3 | * router itself, plus the notes in ./api-meta.ts) and the MCP tools (from the |
| 4 | * MCP server's tool list). Nothing here is written by hand per endpoint or per |
| 5 | * tool, so the docs can't fall behind the code. |
| 6 | */ |
| 7 | import { app } from "../app.js"; |
| 8 | import { mcpTools, PROTOCOL_VERSIONS } from "../mcp/server.js"; |
| 9 | import { ACCOUNT_SCOPES, API_DOCS, type Access, type Field, type OpDoc, type Scope } from "./api-meta.js"; |
| 10 | |
| 11 | export const API_BASE = "/api/v3"; |
| 12 | |
| 13 | export interface ApiOperation { |
| 14 | method: string; |
| 15 | /** OpenAPI-style path relative to /api/v3, like /repos/{owner}/{repo}. */ |
| 16 | path: string; |
| 17 | params: string[]; |
| 18 | tag: string; |
| 19 | doc: OpDoc; |
| 20 | /** False when the summary was derived from the method and path. */ |
| 21 | documented: boolean; |
| 22 | /** The least token scope that may call it, and whether the token must cover every repository. */ |
| 23 | scope: Scope; |
| 24 | accountWide: boolean; |
| 25 | /** No token may call it: only a browser session. */ |
| 26 | sessionOnly: boolean; |
| 27 | } |
| 28 | |
| 29 | const METHOD_ORDER = ["GET", "POST", "PUT", "PATCH", "DELETE"]; |
| 30 | |
| 31 | /** Hono path → OpenAPI path: `:name{regex}` and `:name` → `{name}`, a trailing `*` → `{path}`. */ |
| 32 | export function openApiPath(honoPath: string): string { |
| 33 | return honoPath.replace(/:(\w+)(\{[^}]*\})?/g, "{$1}").replace(/\/\*$/, "/{path}"); |
| 34 | } |
| 35 | |
| 36 | /** Groups, in the order the reference lists them. The first matching rule wins. */ |
| 37 | const TAGS: Array<[string, RegExp]> = [ |
| 38 | ["Meta", /^\/(meta|rate_limit)?$/], |
| 39 | ["Users", /^\/users?(\/\{login\})?$|^\/user\/(orgs|avatar)$/], |
| 40 | ["Organizations", /^\/orgs\/\{org\}(\/(members|invitations(\/\{id\})?|avatar))?$|^\/invitations(\/\{id\})?$/], |
| 41 | ["Search", /^\/search\//], |
| 42 | ["Repositories", /^\/(user|users\/\{login\}|orgs\/\{org\})\/repos$|^\/repos\/\{owner\}\/\{repo\}(\/(collaborators|invitations|assignees|languages|topics|dispatches)(\/|$)|$)/], |
| 43 | ["Contents", /\/(contents|raw|readme|tarball|zipball|diffinery\/commits)(\/|$)/], |
| 44 | ["Releases", /\/releases(\/|$)/], |
| 45 | ["Git database", /\/git\/(blobs|trees|commits|tags|refs|ref|matching-refs)(\/|$)/], |
| 46 | ["Branches and tags", /\/(branches|tags|merges)(\/|$)(?!.*protection)/], |
| 47 | ["Commit statuses", /\/statuses\/|\/commits\/\{ref\}\/status(es)?$/], |
| 48 | ["Checks", /\/check-(runs|suites)/], |
| 49 | ["Commits", /\/(commits|compare)(\/|$)/], |
| 50 | ["Branch protection", /\/protection$/], |
| 51 | ["Issues", /\/(issues|labels|milestones)(\/|$)/], |
| 52 | ["Pull requests", /\/pulls(\/|$)(?!.*merge-queue)/], |
| 53 | ["Merge queue", /\/merge-queue/], |
| 54 | ["Deployments", /\/deployments(\/|$)/], |
| 55 | ["Environments", /\/environments(\/|$)/], |
| 56 | ["Actions secrets and variables", /\/actions\/(secrets|variables)/], |
| 57 | ["Actions artifacts and caches", /\/actions\/(artifacts|caches?)(\/|$)|\/actions\/runs\/\{runId\}\/artifacts$/], |
| 58 | ["Actions", /\/actions\//], |
| 59 | ["Webhooks", /\/hooks(\/|$)/], |
| 60 | ["Packages", /\/packages(\/|$)/], |
| 61 | ["Imports", /^\/diffinery\/(imports|user\/linked-accounts|admin\/linked-accounts)(\/|$)/], |
| 62 | ["Status page", /^\/diffinery\/(admin\/)?status(\/|$)/], |
| 63 | ["Notifications and email", /^\/notifications$|\/subscription$|\/diffinery\/(notification-settings|emails|admin)/], |
| 64 | ["Tokens and agents", /\/diffinery\/(tokens|agents)/], |
| 65 | ["Sandboxes", /\/diffinery\/sandboxes/], |
| 66 | ]; |
| 67 | export const TAG_ORDER = [...TAGS.map(([t]) => t), "Other"]; |
| 68 | |
| 69 | function tagFor(path: string): string { |
| 70 | for (const [tag, re] of TAGS) if (re.test(path)) return tag; |
| 71 | return "Other"; |
| 72 | } |
| 73 | |
| 74 | const words = (s: string) => s.replace(/[-_]/g, " "); |
| 75 | const singular = (s: string) => s.replace(/ies$/, "y").replace(/(ss)$/, "$1").replace(/s$/, ""); |
| 76 | |
| 77 | /** A readable summary for an endpoint nobody has written notes for yet. */ |
| 78 | export function derivedSummary(method: string, path: string): string { |
| 79 | const segs = path.split("/").filter(Boolean); |
| 80 | const last = segs[segs.length - 1] ?? ""; |
| 81 | const endsWithParam = last.startsWith("{"); |
| 82 | const noun = words([...segs].reverse().find((s) => !s.startsWith("{")) ?? "resource"); |
| 83 | const one = `a ${singular(noun)}`; |
| 84 | switch (method) { |
| 85 | case "GET": |
| 86 | return endsWithParam ? `Get ${one}` : `List ${noun}`; |
| 87 | case "POST": |
| 88 | return endsWithParam ? `Run ${words(last.slice(1, -1))} on ${one}` : `Create ${one}`; |
| 89 | case "PUT": |
| 90 | return endsWithParam ? `Set ${one}` : `Set ${noun}`; |
| 91 | case "PATCH": |
| 92 | return `Update ${endsWithParam ? one : noun}`; |
| 93 | case "DELETE": |
| 94 | return `Delete ${endsWithParam ? one : noun}`; |
| 95 | default: |
| 96 | return `${method} ${path}`; |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | let opsCache: ApiOperation[] | null = null; |
| 101 | |
| 102 | /** Every REST endpoint the Worker serves under /api/v3. */ |
| 103 | export function apiOperations(): ApiOperation[] { |
| 104 | if (opsCache) return opsCache; |
| 105 | const seen = new Set<string>(); |
| 106 | const ops: ApiOperation[] = []; |
| 107 | for (const r of app.routes) { |
| 108 | if (!METHOD_ORDER.includes(r.method) || !(r.path === API_BASE || r.path.startsWith(`${API_BASE}/`))) continue; |
| 109 | const path = openApiPath(r.path.slice(API_BASE.length)) || "/"; |
| 110 | const key = `${r.method} ${path}`; |
| 111 | if (seen.has(key)) continue; |
| 112 | seen.add(key); |
| 113 | const doc = API_DOCS[key]; |
| 114 | ops.push({ |
| 115 | method: r.method, |
| 116 | path, |
| 117 | params: [...path.matchAll(/\{(\w+)\}/g)].map((m) => m[1]!), |
| 118 | tag: tagFor(path), |
| 119 | doc: doc ?? { summary: derivedSummary(r.method, path) }, |
| 120 | documented: !!doc, |
| 121 | // A token's scope also caps its repository role (write → maintain), so admin endpoints need an admin token. |
| 122 | scope: ACCOUNT_SCOPES[key]?.scope ?? (doc?.access === "admin" ? "admin" : r.method === "GET" ? "read" : "write"), |
| 123 | accountWide: !!ACCOUNT_SCOPES[key] && !ACCOUNT_SCOPES[key]!.anyToken, |
| 124 | sessionOnly: !!ACCOUNT_SCOPES[key]?.sessionOnly, |
| 125 | }); |
| 126 | } |
| 127 | ops.sort((a, b) => TAG_ORDER.indexOf(a.tag) - TAG_ORDER.indexOf(b.tag) || a.path.localeCompare(b.path) || METHOD_ORDER.indexOf(a.method) - METHOD_ORDER.indexOf(b.method)); |
| 128 | // Only cache once the web UI is mounted too (index.ts mounts it after app.ts loads). |
| 129 | if (ops.length) opsCache = ops; |
| 130 | return ops; |
| 131 | } |
| 132 | |
| 133 | export const ACCESS_TEXT: Record<Access, string> = { |
| 134 | public: "No authentication needed.", |
| 135 | account: "Any active account (a token, or a browser session).", |
| 136 | read: "Read access to the repository. Public repositories allow anyone to read.", |
| 137 | triage: "Triage access to the repository.", |
| 138 | write: "Write access to the repository.", |
| 139 | maintain: "Maintain access to the repository.", |
| 140 | admin: "Admin access to the repository (or to the package).", |
| 141 | "org-admin": "An owner or admin of the organization (or a site admin).", |
| 142 | "org-owner": "An owner of the organization (or a site admin).", |
| 143 | "site-admin": "A site administrator.", |
| 144 | }; |
| 145 | |
| 146 | /** What a personal access token needs; browser sessions always have full scope. */ |
| 147 | export function scopeText(op: Pick<ApiOperation, "scope" | "accountWide" | "sessionOnly" | "doc">): string { |
| 148 | if (op.doc.access === "public") return ""; |
| 149 | if (op.sessionOnly) return "No token can call it: it needs a browser session."; |
| 150 | return `Tokens need the \`${op.scope}\` scope${op.accountWide ? " and access to all repositories" : ""}.`; |
| 151 | } |
| 152 | |
| 153 | const PARAM_TEXT: Record<string, string> = { |
| 154 | owner: "Account (user or organization) that owns the repository or package.", |
| 155 | repo: "Repository name.", |
| 156 | org: "Organization login.", |
| 157 | login: "Account login.", |
| 158 | number: "Issue or pull request number.", |
| 159 | branch: "Branch name (for protection rules: a branch name or pattern).", |
| 160 | ref: "Branch, tag or commit SHA.", |
| 161 | path: "Path inside the repository, or a ref name after `refs/`.", |
| 162 | spec: "Two refs as `base...head`.", |
| 163 | id: "Identifier.", |
| 164 | name: "Name.", |
| 165 | type: "Package type: `container`.", |
| 166 | workflow: "Workflow file path (URL-encoded) or file name.", |
| 167 | runId: "Workflow run id, or the run number (`github.run_id`).", |
| 168 | format: "Archive format: `zip`.", |
| 169 | action: "Action to take.", |
| 170 | }; |
| 171 | |
| 172 | const EXAMPLE: Record<string, string> = { owner: "acme", repo: "website", org: "acme", login: "diffy", number: "42", branch: "main", ref: "main", path: "README.md", spec: "main...feature", type: "container", name: "website", workflow: "ci.yml", action: "exec", runId: "42", format: "zip" }; |
| 173 | |
| 174 | function curl(origin: string, op: ApiOperation): string { |
| 175 | const url = `${origin}${API_BASE}${op.path === "/" ? "" : op.path.replace(/\{(\w+)\}/g, (_, p: string) => EXAMPLE[p] ?? (p === "id" ? "1" : p))}`; |
| 176 | const lines = [`curl${op.method === "GET" ? "" : ` -X ${op.method}`} "${url}"`]; |
| 177 | if (op.doc.access !== "public") lines.push(` -H "Authorization: Bearer $DIFFINERY_TOKEN"`); |
| 178 | const required = Object.entries(op.doc.body ?? {}).filter(([, f]) => f[2]); |
| 179 | if (required.length) { |
| 180 | const sample = Object.fromEntries(required.map(([k, f]) => [k, f[0] === "string[]" ? ["..."] : f[0] === "array" ? [] : f[0] === "object" ? {} : f[0] === "integer" ? 1 : f[0] === "boolean" ? true : "..."])); |
| 181 | lines.push(` -H "Content-Type: application/json"`, ` -d '${JSON.stringify(sample)}'`); |
| 182 | } |
| 183 | return lines.join(" \\\n"); |
| 184 | } |
| 185 | |
| 186 | const cell = (s: string) => s.replace(/\|/g, "\\|").replace(/\n/g, " "); |
| 187 | |
| 188 | function fieldTable(title: string, fields: Record<string, Field>): string[] { |
| 189 | return [`${title}:`, "", "| Name | Type | Required | Description |", "| --- | --- | --- | --- |", ...Object.entries(fields).map(([k, [t, d, req]]) => `| \`${k}\` | ${t} | ${req ? "yes" : "no"} | ${cell(d)} |`), ""]; |
| 190 | } |
| 191 | |
| 192 | /** The /docs/api page: every endpoint, grouped, with parameters and an example. */ |
| 193 | export function apiReferenceMarkdown(origin: string): string { |
| 194 | const ops = apiOperations(); |
| 195 | const byTag = new Map<string, ApiOperation[]>(); |
| 196 | for (const op of ops) byTag.set(op.tag, [...(byTag.get(op.tag) ?? []), op]); |
| 197 | const used = new Set<string>(); |
| 198 | const heading = (op: ApiOperation) => { |
| 199 | let h = op.doc.summary; |
| 200 | if (used.has(h.toLowerCase())) h = `${h} (${op.method} ${op.path.replace(/[{}]/g, "")})`; |
| 201 | used.add(h.toLowerCase()); |
| 202 | return h; |
| 203 | }; |
| 204 | const out: string[] = [ |
| 205 | `Every endpoint of the Diffinery REST API, generated from the server's router, so this list always matches what \`${origin}${API_BASE}\` serves. The same information is available as an [OpenAPI 3.1 document](/docs/api/openapi.json). For authentication, errors, pagination and GitHub compatibility, read the [REST API guide](/docs/reference/rest-api).`, |
| 206 | "", |
| 207 | "## Basics", |
| 208 | "", |
| 209 | `- Base URL: \`${origin}${API_BASE}\``, |
| 210 | "- Authenticate with a personal access token: `Authorization: Bearer dfy_...` (`token dfy_...` and HTTP Basic with the token as the password work too). Create one under *Settings → Access tokens*.", |
| 211 | "- Send and receive JSON. Errors are JSON objects with a `message` and a `documentation_url`, unknown paths included (404).", |
| 212 | "- Reads of public repositories need no token. Every write needs a signed-in caller (401 otherwise) and, from a token, the `write` scope (403 otherwise). A token's scope also caps its role on a repository: `read` reads, `write` acts up to maintain, `admin` acts as admin. Each endpoint below says which scope it needs.", |
| 213 | "- A private repository you can't see is a 404, whatever the method, so a read token's write to it doesn't reveal that it exists.", |
| 214 | "- An archived repository refuses every write with 403 `repository is archived (read-only)`, except unarchiving it (`PATCH` with `archived: false`), deleting it and watching it.", |
| 215 | "- A workflow job's `GITHUB_TOKEN` follows the job's `permissions:` instead of token scopes: each endpoint needs its permission (`contents`, `issues`, `pull-requests`, `actions` and so on), and settings, secrets, variables, webhooks and branch protection are out of its reach. See [Workflow permissions](/docs/actions/workflow-permissions).", |
| 216 | "- Paths, field names and status codes follow GitHub's REST API wherever Diffinery has the same feature, so most GitHub clients work with `baseUrl` set to the URL above.", |
| 217 | `- ${ops.length} endpoints in ${byTag.size} groups.`, |
| 218 | "", |
| 219 | "## Endpoints by group", |
| 220 | "", |
| 221 | ...[...byTag.entries()].map(([tag, list]) => `- [${tag}](#${slug(tag)}) (${list.length})`), |
| 222 | "", |
| 223 | ]; |
| 224 | for (const [tag, list] of byTag) { |
| 225 | out.push(`## ${tag}`, "", "| Method | Path | Summary |", "| --- | --- | --- |"); |
| 226 | const heads = list.map((op) => heading(op)); |
| 227 | list.forEach((op, i) => out.push(`| ${op.method} | \`${op.path}\` | [${cell(op.doc.summary)}](#${slug(heads[i]!)}) |`)); |
| 228 | out.push(""); |
| 229 | list.forEach((op, i) => { |
| 230 | out.push(`### ${heads[i]}`, "", "```http", `${op.method} ${API_BASE}${op.path === "/" ? "" : op.path}`, "```", ""); |
| 231 | if (op.doc.description) out.push(op.doc.description, ""); |
| 232 | out.push(`**Access:** ${[ACCESS_TEXT[op.doc.access ?? "read"], scopeText(op)].filter(Boolean).join(" ")}${op.doc.status ? ` **Success:** \`${op.doc.status}\`.` : ""}`, ""); |
| 233 | if (op.params.length) out.push(...fieldTable("Path parameters", Object.fromEntries(op.params.map((p) => [p, ["string", PARAM_TEXT[p] ?? p, true] as Field])))); |
| 234 | if (op.doc.query) out.push(...fieldTable("Query parameters", op.doc.query)); |
| 235 | if (op.doc.body) out.push(...fieldTable("Body (JSON)", op.doc.body)); |
| 236 | out.push("```sh", curl(origin, op), "```", ""); |
| 237 | }); |
| 238 | } |
| 239 | out.push( |
| 240 | "## Other HTTP endpoints", |
| 241 | "", |
| 242 | "These aren't part of the JSON API, but clients and tools talk to them directly.", |
| 243 | "", |
| 244 | "| Endpoint | What it is |", |
| 245 | "| --- | --- |", |
| 246 | ...OTHER_ENDPOINTS.map(([path, what]) => `| \`${path}\` | ${what} |`), |
| 247 | "", |
| 248 | ); |
| 249 | return out.join("\n"); |
| 250 | } |
| 251 | |
| 252 | /** Non-API endpoints worth knowing. A unit test checks that each one is still routed. */ |
| 253 | export const OTHER_ENDPOINTS: Array<[string, string]> = [ |
| 254 | ["GET /{owner}/{repo}.git/info/refs", "Git smart HTTP: ref advertisement (`git clone`, `git fetch`, `git push`). Authenticate with a token as the password. See [Git](/docs/repositories/git)."], |
| 255 | ["POST /{owner}/{repo}.git/git-upload-pack", "Git smart HTTP: fetch and clone."], |
| 256 | ["POST /{owner}/{repo}.git/git-receive-pack", "Git smart HTTP: push."], |
| 257 | ["POST /{owner}/{repo}.git/info/lfs/objects/batch", "Git LFS batch API. See [Git LFS](/docs/repositories/git-lfs)."], |
| 258 | ["GET, PUT /{owner}/{repo}.git/info/lfs/objects/{oid}", "Git LFS object download and upload."], |
| 259 | ["GET /{owner}/{repo}/actions/runs/{run_id}/artifacts/{name}", "Download a workflow artifact by name or numeric id, for browsers (the run page links here). Serves a zip, the file itself for an artifact uploaded as a single unzipped file, or a `.tar.gz` for an artifact from an older runner. Expired artifacts aren't served."], |
| 260 | ["POST /mcp", "The MCP server (Streamable HTTP, JSON-RPC). See [MCP](/docs/agents/mcp) and the [MCP tool reference](/docs/reference/mcp-tools)."], |
| 261 | ["/v2/...", "The OCI container registry (Docker Registry HTTP API v2). See [Container registry](/docs/packages/container-registry)."], |
| 262 | ["GET /.well-known/openid-configuration", "OIDC discovery for workflow ID tokens. See [OIDC](/docs/actions/oidc)."], |
| 263 | ["GET /.well-known/jwks", "Public keys that sign workflow ID tokens."], |
| 264 | ["GET /.well-known/security.txt", "Where to report a vulnerability (RFC 9116): this instance's report form first, then any `SECURITY_CONTACT` entries. See [Reporting a vulnerability](/docs/security/disclosure)."], |
| 265 | ["GET, POST /security/report", "The private security report form: signed in, or anonymously after Cloudflare Turnstile. Only site administrators can read reports. See [Reporting a vulnerability](/docs/security/disclosure)."], |
| 266 | ["POST /-/email/events", "Signed bounce and complaint intake for the email suppression list; 404 until `MAIL_EVENTS_SECRET` is set. See [Site administration](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list)."], |
| 267 | ["GET, POST /-/unsubscribe/{token}", "One-click unsubscribe from a notification email (RFC 8058): a POST with `List-Unsubscribe=One-Click` turns that kind of email off. See [Notifications](/docs/accounts/notifications)."], |
| 268 | ["GET /status", "The status page: each component's status and the incidents site administrators posted, for anyone without signing in. As JSON at `GET /api/v3/diffinery/status`. See [Status page](/docs/self-hosting/status-page)."], |
| 269 | ["GET /healthz", "Health check: `{\"ok\": true}`. Point an external uptime monitor at it: the status page can't report the whole Worker being down."], |
| 270 | ["GET /llms.txt", "Index of these docs for AI agents. See [Docs for AI agents](/docs/agents/docs-for-agents)."], |
| 271 | ["GET /llms-full.txt", "All of these docs in one Markdown file."], |
| 272 | ["GET /docs/{page}.md", "Any docs page as Markdown."], |
| 273 | ["GET /docs/api/openapi.json", "This reference as an OpenAPI 3.1 document."], |
| 274 | ]; |
| 275 | |
| 276 | // Same rules as slugify() in src/web/markdown.ts. |
| 277 | function slug(text: string): string { |
| 278 | return text |
| 279 | .toLowerCase() |
| 280 | .replace(/[^\p{L}\p{N}\s_-]/gu, "") |
| 281 | .trim() |
| 282 | .replace(/\s+/g, "-") |
| 283 | .slice(0, 80); |
| 284 | } |
| 285 | |
| 286 | // ---------------------------------------------------------------- OpenAPI |
| 287 | |
| 288 | function schemaFor(type: string): Record<string, unknown> { |
| 289 | if (type.endsWith("[]")) return { type: "array", items: schemaFor(type.slice(0, -2)) }; |
| 290 | if (["string", "integer", "boolean", "object", "array", "number"].includes(type)) return { type }; |
| 291 | return {}; |
| 292 | } |
| 293 | |
| 294 | /** The REST API as an OpenAPI 3.1 document. */ |
| 295 | export function openApiDocument(origin: string, version: string): Record<string, unknown> { |
| 296 | const paths: Record<string, Record<string, unknown>> = {}; |
| 297 | for (const op of apiOperations()) { |
| 298 | const params = [ |
| 299 | ...op.params.map((p) => ({ name: p, in: "path", required: true, description: PARAM_TEXT[p] ?? p, schema: { type: "string" } })), |
| 300 | ...Object.entries(op.doc.query ?? {}).map(([name, [t, d, req]]) => ({ name, in: "query", required: !!req, description: d, schema: schemaFor(t) })), |
| 301 | ]; |
| 302 | const body = op.doc.body |
| 303 | ? { |
| 304 | required: Object.values(op.doc.body).some((f) => f[2]), |
| 305 | content: { |
| 306 | "application/json": { |
| 307 | schema: { |
| 308 | type: "object", |
| 309 | properties: Object.fromEntries(Object.entries(op.doc.body).map(([k, [t, d]]) => [k, { ...schemaFor(t), description: d }])), |
| 310 | required: Object.entries(op.doc.body) |
| 311 | .filter(([, f]) => f[2]) |
| 312 | .map(([k]) => k), |
| 313 | }, |
| 314 | }, |
| 315 | }, |
| 316 | } |
| 317 | : undefined; |
| 318 | const ok = String(op.doc.status ?? 200); |
| 319 | const access = op.doc.access ?? "read"; |
| 320 | (paths[op.path] ??= {})[op.method.toLowerCase()] = { |
| 321 | operationId: operationId(op), |
| 322 | summary: op.doc.summary, |
| 323 | description: [op.doc.description, `Access: ${[ACCESS_TEXT[access], scopeText(op)].filter(Boolean).join(" ")}`].filter(Boolean).join("\n\n"), |
| 324 | tags: [op.tag], |
| 325 | ...(params.length ? { parameters: params } : {}), |
| 326 | ...(body ? { requestBody: body } : {}), |
| 327 | ...(access === "public" ? { security: [] } : {}), |
| 328 | "x-diffinery-access": access, |
| 329 | "x-diffinery-token-scope": op.sessionOnly ? "none" : op.scope, |
| 330 | responses: { |
| 331 | [ok]: { description: ok === "204" ? "No content" : "Success", ...(ok === "204" || ok === "205" ? {} : { content: { "application/json": { schema: {} } } }) }, |
| 332 | default: { description: "Error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, |
| 333 | }, |
| 334 | }; |
| 335 | } |
| 336 | return { |
| 337 | openapi: "3.1.0", |
| 338 | info: { |
| 339 | title: "Diffinery REST API", |
| 340 | version, |
| 341 | description: `The Diffinery REST API, generated from the server's router. Paths and payloads follow GitHub's REST API v3 where Diffinery has the same feature. Guide: ${origin}/docs/reference/rest-api`, |
| 342 | }, |
| 343 | externalDocs: { description: "Diffinery docs", url: `${origin}/docs` }, |
| 344 | servers: [{ url: `${origin}${API_BASE}` }], |
| 345 | security: [{ token: [] }], |
| 346 | tags: TAG_ORDER.filter((t) => apiOperations().some((o) => o.tag === t)).map((name) => ({ name })), |
| 347 | paths, |
| 348 | components: { |
| 349 | securitySchemes: { |
| 350 | token: { type: "http", scheme: "bearer", description: "A Diffinery personal access token (`dfy_...`) from Settings → Access tokens. `Authorization: token dfy_...` and HTTP Basic (token as the password) also work." }, |
| 351 | }, |
| 352 | schemas: { |
| 353 | Error: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, |
| 354 | }, |
| 355 | }, |
| 356 | }; |
| 357 | } |
| 358 | |
| 359 | function operationId(op: ApiOperation): string { |
| 360 | const parts = op.path |
| 361 | .split("/") |
| 362 | .filter(Boolean) |
| 363 | .map((s) => (s.startsWith("{") ? `by-${s.slice(1, -1)}` : s)); |
| 364 | return [op.method.toLowerCase(), ...parts] |
| 365 | .join("-") |
| 366 | .replace(/[^a-zA-Z0-9]+(.)/g, (_, ch: string) => ch.toUpperCase()) |
| 367 | .replace(/[^a-zA-Z0-9]/g, ""); |
| 368 | } |
| 369 | |
| 370 | // ---------------------------------------------------------------- MCP tools |
| 371 | |
| 372 | const MCP_GROUPS: Array<[string, RegExp]> = [ |
| 373 | ["Identity and repositories", /^(whoami|list_repos)$/], |
| 374 | ["Code", /^(list_tree|get_file|search_code|commit_files)$/], |
| 375 | ["Branches and commits", /branch|commit|compare/], |
| 376 | ["Pull requests", /pull_request|review|merge/], |
| 377 | ["Issues", /issue|comment|label/], |
| 378 | ["Actions", /workflow|run|job|artifact/], |
| 379 | ["Releases", /release/], |
| 380 | ["Imports", /import/], |
| 381 | ["Sandboxes", /sandbox/], |
| 382 | ["Docs", /docs/], |
| 383 | ]; |
| 384 | |
| 385 | function mcpGroup(name: string): string { |
| 386 | for (const [g, re] of MCP_GROUPS) if (re.test(name)) return g; |
| 387 | return "Other"; |
| 388 | } |
| 389 | |
| 390 | type JsonSchemaProp = { type?: string; description?: string; enum?: string[]; items?: unknown }; |
| 391 | |
| 392 | /** The /docs/reference/mcp-tools page. */ |
| 393 | export function mcpToolsMarkdown(origin: string): string { |
| 394 | const tools = mcpTools(); |
| 395 | const groups = new Map<string, typeof tools>(); |
| 396 | for (const g of [...MCP_GROUPS.map(([n]) => n), "Other"]) groups.set(g, []); |
| 397 | for (const t of tools) groups.get(mcpGroup(t.name))!.push(t); |
| 398 | for (const [g, list] of groups) if (!list.length) groups.delete(g); |
| 399 | const out: string[] = [ |
| 400 | `Every tool the Diffinery MCP server offers, generated from the server's own tool list, so it always matches what \`tools/list\` returns. For setup in Claude Code, Cursor and other clients, read [MCP](/docs/agents/mcp).`, |
| 401 | "", |
| 402 | "## Connection", |
| 403 | "", |
| 404 | `- Endpoint: \`${origin}/mcp\` (Streamable HTTP; responses are JSON, not event streams).`, |
| 405 | "- Authentication: `Authorization: Bearer dfy_...`, a token for an agent or a person. Every call needs an active account.", |
| 406 | `- Protocol versions: ${PROTOCOL_VERSIONS.map((v) => `\`${v}\``).join(", ")}. The server answers with the version the client asks for when it supports it, else the newest.`, |
| 407 | "- Every tool runs through the same permission checks, branch protection and agent guardrails as git and the REST API.", |
| 408 | "- Errors come back as a tool result with `isError: true` and the message as text. Successful results carry the JSON both as text and as `structuredContent`.", |
| 409 | "", |
| 410 | "## Tools", |
| 411 | "", |
| 412 | "| Tool | Read-only | Summary |", |
| 413 | "| --- | --- | --- |", |
| 414 | ...tools.map((t) => `| [\`${t.name}\`](#${slug(t.name)}) | ${t.annotations.readOnlyHint ? "yes" : "no"} | ${cell(firstSentence(t.description))} |`), |
| 415 | "", |
| 416 | ]; |
| 417 | for (const [g, list] of groups) { |
| 418 | out.push(`## ${g}`, ""); |
| 419 | for (const t of list) { |
| 420 | out.push(`### ${t.name}`, "", t.description, "", `Read-only: ${t.annotations.readOnlyHint ? "yes" : "no"}.`, ""); |
| 421 | const props = Object.entries(t.inputSchema.properties as Record<string, JsonSchemaProp>); |
| 422 | if (props.length) { |
| 423 | out.push("| Argument | Type | Required | Description |", "| --- | --- | --- | --- |"); |
| 424 | for (const [k, p] of props) { |
| 425 | const type = p.type === "array" && p.items && typeof p.items === "object" ? `array of ${(p.items as { type?: string }).type ?? "object"}` : (p.type ?? "any"); |
| 426 | out.push(`| \`${k}\` | ${type}${p.enum ? ` (${p.enum.map((e) => `\`${e}\``).join(", ")})` : ""} | ${t.inputSchema.required.includes(k) ? "yes" : "no"} | ${cell(p.description ?? "")} |`); |
| 427 | } |
| 428 | out.push(""); |
| 429 | } else out.push("No arguments.", ""); |
| 430 | const args = Object.fromEntries(t.inputSchema.required.map((k) => [k, EXAMPLE[k] ?? (k.includes("number") ? 42 : "...")])); |
| 431 | out.push("```json", JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: t.name, arguments: args } }), "```", ""); |
| 432 | } |
| 433 | } |
| 434 | return out.join("\n"); |
| 435 | } |
| 436 | |
| 437 | function firstSentence(s: string): string { |
| 438 | const m = /^(.+?\.)(\s|$)/.exec(s); |
| 439 | return m ? m[1]! : s; |
| 440 | } |