diff --git a/.api-doc-ignore b/.api-doc-ignore new file mode 100644 index 00000000..9f7ca059 --- /dev/null +++ b/.api-doc-ignore @@ -0,0 +1,9 @@ +# Endpoints excluded from automatic documentation PRs. +# Format: "METHOD /path" — must match the OpenAPI spec exactly. +# Adding an endpoint here permanently suppresses PR creation for it. +# +# Example: +# - "GET /v1/internal/debug" +# - "POST /v1/experimental/beta-feature" + +excluded_endpoints: [] diff --git a/.github/scripts/detect-new-endpoints.mjs b/.github/scripts/detect-new-endpoints.mjs new file mode 100644 index 00000000..2652bc2a --- /dev/null +++ b/.github/scripts/detect-new-endpoints.mjs @@ -0,0 +1,359 @@ +#!/usr/bin/env node + +/** + * detect-new-endpoints.mjs + * + * Detects undocumented API endpoints by comparing the OpenAPI spec against + * existing MDX stubs, then creates one PR per new endpoint. + * + * Env vars: + * DRY_RUN=1 — log what would happen, don't create branches/PRs + * GH_TOKEN — GitHub token (provided by Actions) + */ + +import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { join, basename, dirname } from 'node:path'; +import { execSync } from 'node:child_process'; +import { parse as yamlParse } from './yaml-lite.mjs'; + +const DRY_RUN = process.env.DRY_RUN === '1'; +const ROOT = process.cwd(); +const SPEC_PATH = join(ROOT, 'api-reference', 'openapi.json'); +const DOCS_JSON_PATH = join(ROOT, 'docs.json'); +const IGNORE_PATH = join(ROOT, '.api-doc-ignore'); + +// --------------------------------------------------------------------------- +// Tag → directory + docs.json group mapping +// Built from current repo state. Fallback: slugify the tag. +// --------------------------------------------------------------------------- + +const TAG_MAP = { + 'Accounts': { dir: 'accounts', group: 'Accounts' }, + 'Alert channels': { dir: 'alert-channels', group: 'Alert Channels' }, + 'Alert notifications': { dir: 'alert-notifications', group: 'Alert Notifications' }, + 'Analytics': { dir: 'analytics', group: 'Analytics' }, + 'Badges': { dir: 'badges', group: 'Badges' }, + 'Check alerts': { dir: 'check-alerts', group: 'Check Alerts' }, + 'Check groups': { dir: 'check-groups', group: 'Check Groups' }, + 'Check results': { dir: 'check-results', group: 'Check Results' }, + 'Check sessions': { dir: 'check-sessions', group: 'Check Sessions' }, + 'Check status': { dir: 'check-status', group: 'Check Status' }, + 'Checks': { dir: 'checks', group: 'Checks and Monitors' }, + 'Client certificates': { dir: 'client-certificates', group: 'Client Certificates' }, + 'Dashboards': { dir: 'dashboards', group: 'Dashboards' }, + 'Environment variables': { dir: 'environment-variables', group: 'Environment Variables' }, + 'Error Groups': { dir: 'error-groups', group: 'Error Groups' }, + 'Heartbeats': { dir: 'heartbeats', group: 'Checks and Monitors' }, + 'Incident Updates': { dir: 'incident-updates', group: 'Dashboard Incident Updates' }, + 'Incidents': { dir: 'incidents', group: 'Dashboard Incidents' }, + 'Location': { dir: 'location', group: 'Locations' }, + 'Maintenance windows': { dir: 'maintenance-windows', group: 'Maintenance Windows' }, + 'Monitors': { dir: 'monitors', group: 'Checks and Monitors' }, + 'Private locations': { dir: 'private-locations', group: 'Private Locations' }, + 'Reporting': { dir: 'reporting', group: 'Reporting' }, + 'Runtimes': { dir: 'runtimes', group: 'Runtimes' }, + 'Snippets': { dir: 'snippets', group: 'Snippets' }, + 'Static IPs': { dir: 'static-ips', group: 'Static IPs' }, + 'Status Page Incidents': { dir: 'status-page-incidents', group: 'Status Page Incidents' }, + 'Status Page Services': { dir: 'status-page-services', group: 'Status Page Services' }, + 'Status Pages': { dir: 'status-pages', group: 'Status Pages' }, + 'Subscriptions': { dir: 'status-pages', group: 'Status Page Subscribers' }, + 'Triggers': { dir: 'triggers', group: 'Check Triggers' }, +}; + +// Monitor-type endpoints under the "Checks" or "Monitors" tag go into +// specific subgroups within "Checks and Monitors". +const MONITOR_SUBGROUP_PATTERNS = [ + { pattern: /icmp/i, dir: 'monitors', subgroup: 'ICMP Monitor' }, + { pattern: /dns/i, dir: 'monitors', subgroup: 'DNS Monitor' }, + { pattern: /tcp/i, dir: 'checks', subgroup: 'TCP Monitor' }, + { pattern: /url/i, dir: 'monitors', subgroup: 'URL Monitor' }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function slugify(text) { + return text + .toLowerCase() + .replace(/\[.*?\]\s*/g, '') // strip [beta] etc. + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function run(cmd, opts = {}) { + if (DRY_RUN && !opts.allowInDryRun) { + console.log(` [dry-run] ${cmd}`); + return ''; + } + return execSync(cmd, { encoding: 'utf-8', cwd: ROOT, ...opts }).trim(); +} + +function loadSpec() { + const raw = readFileSync(SPEC_PATH, 'utf-8'); + // OpenAPI descriptions may contain literal control chars (newlines in JSON strings) + return JSON.parse(raw.replace(/[\x00-\x1f]/g, (ch) => { + if (ch === '\n') return '\\n'; + if (ch === '\r') return '\\r'; + if (ch === '\t') return '\\t'; + return ''; + })); +} + +function loadExclusions() { + if (!existsSync(IGNORE_PATH)) return new Set(); + const raw = readFileSync(IGNORE_PATH, 'utf-8'); + const parsed = yamlParse(raw); + const list = parsed?.excluded_endpoints ?? []; + return new Set(list.map((e) => e.trim())); +} + +function scanExistingMdx() { + const documented = new Set(); + const apiRefDir = join(ROOT, 'api-reference'); + + function walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { walk(full); continue; } + if (!entry.name.endsWith('.mdx')) continue; + const content = readFileSync(full, 'utf-8'); + const match = content.match(/openapi:\s*(get|post|put|delete|patch)\s+(\/\S+)/i); + if (match) { + documented.add(`${match[1].toUpperCase()} ${match[2]}`); + } + } + } + walk(apiRefDir); + return documented; +} + +function resolveMapping(tag, summary) { + const mapping = TAG_MAP[tag]; + if (!mapping) { + // Unknown tag — create new directory and group from tag name + return { dir: slugify(tag), group: tag, subgroup: null }; + } + + // For tags that map to "Checks and Monitors", check if it's a monitor subtype + if (mapping.group === 'Checks and Monitors' && (tag === 'Checks' || tag === 'Monitors')) { + for (const mp of MONITOR_SUBGROUP_PATTERNS) { + if (mp.pattern.test(summary)) { + return { dir: mp.dir, group: 'Checks and Monitors', subgroup: mp.subgroup }; + } + } + } + + return { dir: mapping.dir, group: mapping.group, subgroup: null }; +} + +function generateFilename(summary) { + const slug = slugify(summary); + return slug || 'unnamed-endpoint'; +} + +function prExists(branchName) { + try { + const result = run( + `gh pr list --head "${branchName}" --state all --json number --jq 'length'`, + { allowInDryRun: true } + ); + return parseInt(result, 10) > 0; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// docs.json updater +// --------------------------------------------------------------------------- + +function addToDocsJson(docsJson, pagePath, groupName, subgroupName) { + // Structure: docsJson.navigation.tabs[] → { tab: "API", pages: [...] } + const tabs = docsJson.navigation?.tabs ?? []; + const apiTab = tabs.find((t) => t.tab === 'API'); + if (!apiTab) { + console.warn(' ⚠ Could not find API tab in docs.json'); + return false; + } + + // Find "API Reference" group within the API tab's pages + const apiRefGroup = (apiTab.pages ?? []).find( + (g) => typeof g === 'object' && g.group === 'API Reference' + ); + if (!apiRefGroup) { + console.warn(' ⚠ Could not find "API Reference" group in docs.json'); + return false; + } + + // Find the target group within API Reference + let targetGroup = apiRefGroup.pages.find( + (g) => typeof g === 'object' && g.group === groupName + ); + + // Create group if it doesn't exist + if (!targetGroup) { + targetGroup = { group: groupName, pages: [] }; + apiRefGroup.pages.push(targetGroup); + console.log(` + Created new group "${groupName}" in docs.json`); + } + + // If there's a subgroup, find or create it within the target group + if (subgroupName) { + let subgroup = targetGroup.pages.find( + (g) => typeof g === 'object' && g.group === subgroupName + ); + if (!subgroup) { + subgroup = { group: subgroupName, pages: [] }; + targetGroup.pages.push(subgroup); + console.log(` + Created new subgroup "${subgroupName}" in docs.json`); + } + subgroup.pages.push(pagePath); + } else { + targetGroup.pages.push(pagePath); + } + + return true; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + console.log(DRY_RUN ? '🏃 DRY RUN MODE\n' : '🚀 Running endpoint sync\n'); + + // 1. Load inputs + const spec = loadSpec(); + const exclusions = loadExclusions(); + const documented = scanExistingMdx(); + + console.log(`📋 Spec endpoints: ${Object.keys(spec.paths).length} paths`); + console.log(`📄 Documented endpoints: ${documented.size}`); + console.log(`🚫 Excluded endpoints: ${exclusions.size}`); + + // 2. Find undocumented endpoints + const undocumented = []; + for (const [path, methods] of Object.entries(spec.paths)) { + for (const [method, details] of Object.entries(methods)) { + if (!['get', 'post', 'put', 'delete', 'patch'].includes(method)) continue; + const key = `${method.toUpperCase()} ${path}`; + + if (documented.has(key)) continue; + if (exclusions.has(key)) { + console.log(` ⏭ ${key} — excluded`); + continue; + } + + const tag = details.tags?.[0] ?? ''; + const summary = details.summary ?? ''; + const { dir, group, subgroup } = resolveMapping(tag, summary); + const filename = generateFilename(summary); + + undocumented.push({ key, method: method.toUpperCase(), path, tag, summary, dir, group, subgroup, filename }); + } + } + + if (undocumented.length === 0) { + console.log('\n✅ All endpoints are documented (or excluded). Nothing to do.'); + return; + } + + console.log(`\n🆕 Found ${undocumented.length} undocumented endpoint(s):\n`); + for (const ep of undocumented) { + console.log(` ${ep.key} → ${ep.dir}/${ep.filename}.mdx [${ep.group}${ep.subgroup ? ' > ' + ep.subgroup : ''}]`); + } + + // 3. Process each endpoint + let created = 0; + let skipped = 0; + + // Store the main branch name to return to between PRs + const mainBranch = run('git rev-parse --abbrev-ref HEAD', { allowInDryRun: true }); + + for (const ep of undocumented) { + const branchName = `api-doc/${ep.dir}/${ep.filename}`; + const mdxRelPath = `api-reference/${ep.dir}/${ep.filename}.mdx`; + const docsJsonPagePath = `api-reference/${ep.dir}/${ep.filename}`; + + console.log(`\n--- Processing: ${ep.key} ---`); + + // Check for existing PR + if (prExists(branchName)) { + console.log(` ⏭ PR already exists for branch ${branchName}`); + skipped++; + continue; + } + + // Ensure we're on main and up to date + run(`git checkout ${mainBranch}`); + + // Create branch + run(`git checkout -b "${branchName}"`); + + // Create MDX stub + const mdxDir = join(ROOT, 'api-reference', ep.dir); + if (!existsSync(mdxDir)) { + mkdirSync(mdxDir, { recursive: true }); + console.log(` + Created directory: api-reference/${ep.dir}/`); + } + + const mdxContent = `---\nopenapi: ${ep.method.toLowerCase()} ${ep.path}\ntitle: ${ep.summary.replace(/\[.*?\]\s*/g, '').trim()}\n---\n`; + + if (!DRY_RUN) { + writeFileSync(join(ROOT, mdxRelPath), mdxContent); + } + console.log(` + Created ${mdxRelPath}`); + + // Update docs.json + const docsJson = JSON.parse(readFileSync(DOCS_JSON_PATH, 'utf-8')); + const added = addToDocsJson(docsJson, docsJsonPagePath, ep.group, ep.subgroup); + if (added && !DRY_RUN) { + writeFileSync(DOCS_JSON_PATH, JSON.stringify(docsJson, null, 2) + '\n'); + } + console.log(` + Updated docs.json → ${ep.group}${ep.subgroup ? ' > ' + ep.subgroup : ''}`); + + // Commit + run(`git add "${mdxRelPath}" docs.json`); + const commitMsg = `docs(api): add ${ep.summary.replace(/\[.*?\]\s*/g, '').trim()} endpoint`; + run(`git commit -m "${commitMsg.replace(/"/g, '\\"')}"`); + + // Push + run(`git push origin "${branchName}"`); + + // Create PR + const prTitle = commitMsg; + const prBody = [ + '## New API Endpoint Documentation', + '', + `**Endpoint:** \`${ep.key}\``, + `**Category:** ${ep.group}${ep.subgroup ? ' > ' + ep.subgroup : ''}`, + '', + 'Automatically detected from OpenAPI spec update. This PR adds:', + `- MDX stub: \`${mdxRelPath}\``, + '- Navigation entry in `docs.json`', + '', + '**To exclude this endpoint permanently**, add it to `.api-doc-ignore` and close this PR.', + '', + '---', + '*Auto-generated by sync-api-endpoints workflow*', + ].join('\n'); + + run(`gh pr create --title "${prTitle.replace(/"/g, '\\"')}" --body "${prBody.replace(/"/g, '\\"')}" --label "auto-generated" --label "api-docs" --head "${branchName}"`); + + console.log(` ✅ PR created for ${ep.key}`); + created++; + + // Return to main for next iteration + run(`git checkout ${mainBranch}`); + } + + console.log(`\n========================================`); + console.log(`✅ Done. Created: ${created} | Skipped: ${skipped} | Total new: ${undocumented.length}`); +} + +main().catch((err) => { + console.error('❌ Fatal error:', err); + process.exit(1); +}); diff --git a/.github/scripts/yaml-lite.mjs b/.github/scripts/yaml-lite.mjs new file mode 100644 index 00000000..3ae1fc64 --- /dev/null +++ b/.github/scripts/yaml-lite.mjs @@ -0,0 +1,56 @@ +/** + * yaml-lite.mjs + * + * Minimal YAML parser — only supports the subset used by .api-doc-ignore: + * key: [] → { key: [] } + * key: + * - "value" → { key: ["value"] } + * - value → { key: ["value"] } + * + * Does NOT support nested objects, multi-line strings, anchors, etc. + * For anything more complex, use a real YAML parser. + */ + +export function parse(text) { + const result = {}; + let currentKey = null; + + for (const rawLine of text.split('\n')) { + const line = rawLine.replace(/#.*$/, '').trimEnd(); // strip comments + if (!line.trim()) continue; + + // Key with inline empty array: "key: []" + const emptyArrayMatch = line.match(/^(\w[\w\s]*?):\s*\[\]\s*$/); + if (emptyArrayMatch) { + currentKey = emptyArrayMatch[1].trim(); + result[currentKey] = []; + continue; + } + + // Key declaration: "key:" or "key: value" + const keyMatch = line.match(/^(\w[\w\s]*?):\s*(.*)$/); + if (keyMatch && !line.startsWith(' ') && !line.startsWith('-')) { + currentKey = keyMatch[1].trim(); + const val = keyMatch[2].trim(); + if (val) { + result[currentKey] = val.replace(/^["']|["']$/g, ''); + } else { + result[currentKey] = []; + } + continue; + } + + // Array item: " - value" or ' - "value"' + const itemMatch = line.match(/^\s+-\s+(.+)$/); + if (itemMatch && currentKey) { + const val = itemMatch[1].trim().replace(/^["']|["']$/g, ''); + if (!Array.isArray(result[currentKey])) { + result[currentKey] = []; + } + result[currentKey].push(val); + continue; + } + } + + return result; +} diff --git a/.github/workflows/sync-api-endpoints.yml b/.github/workflows/sync-api-endpoints.yml new file mode 100644 index 00000000..d3cd1faa --- /dev/null +++ b/.github/workflows/sync-api-endpoints.yml @@ -0,0 +1,56 @@ +name: Sync API Endpoint Documentation + +on: + # Run after the spec update workflow completes + workflow_run: + workflows: ["Update API Specification"] + types: [completed] + + # Allow manual trigger for testing + workflow_dispatch: + +concurrency: + group: sync-api-endpoints + cancel-in-progress: false + +jobs: + detect-and-create-prs: + runs-on: ubuntu-latest + timeout-minutes: 15 + # Only run if spec update succeeded (or manual trigger) + if: >- + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' + + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Configure git + run: | + git config user.name "API Docs Bot" + git config user.email "noreply@checkly.com" + + - name: Ensure labels exist + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh label create "auto-generated" --color "0e8a16" --description "Automatically generated PR" --force + gh label create "api-docs" --color "1d76db" --description "API documentation changes" --force + + - name: Detect new endpoints and create PRs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/scripts/detect-new-endpoints.mjs