|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +/** |
| 4 | + * detect-new-endpoints.mjs |
| 5 | + * |
| 6 | + * Detects undocumented API endpoints by comparing the OpenAPI spec against |
| 7 | + * existing MDX stubs, then creates one PR per new endpoint. |
| 8 | + * |
| 9 | + * Env vars: |
| 10 | + * DRY_RUN=1 — log what would happen, don't create branches/PRs |
| 11 | + * GH_TOKEN — GitHub token (provided by Actions) |
| 12 | + */ |
| 13 | + |
| 14 | +import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; |
| 15 | +import { join, basename, dirname } from 'node:path'; |
| 16 | +import { execSync } from 'node:child_process'; |
| 17 | +import { parse as yamlParse } from './yaml-lite.mjs'; |
| 18 | + |
| 19 | +const DRY_RUN = process.env.DRY_RUN === '1'; |
| 20 | +const ROOT = process.cwd(); |
| 21 | +const SPEC_PATH = join(ROOT, 'api-reference', 'openapi.json'); |
| 22 | +const DOCS_JSON_PATH = join(ROOT, 'docs.json'); |
| 23 | +const IGNORE_PATH = join(ROOT, '.api-doc-ignore'); |
| 24 | + |
| 25 | +// --------------------------------------------------------------------------- |
| 26 | +// Tag → directory + docs.json group mapping |
| 27 | +// Built from current repo state. Fallback: slugify the tag. |
| 28 | +// --------------------------------------------------------------------------- |
| 29 | + |
| 30 | +const TAG_MAP = { |
| 31 | + 'Accounts': { dir: 'accounts', group: 'Accounts' }, |
| 32 | + 'Alert channels': { dir: 'alert-channels', group: 'Alert Channels' }, |
| 33 | + 'Alert notifications': { dir: 'alert-notifications', group: 'Alert Notifications' }, |
| 34 | + 'Analytics': { dir: 'analytics', group: 'Analytics' }, |
| 35 | + 'Badges': { dir: 'badges', group: 'Badges' }, |
| 36 | + 'Check alerts': { dir: 'check-alerts', group: 'Check Alerts' }, |
| 37 | + 'Check groups': { dir: 'check-groups', group: 'Check Groups' }, |
| 38 | + 'Check results': { dir: 'check-results', group: 'Check Results' }, |
| 39 | + 'Check sessions': { dir: 'check-sessions', group: 'Check Sessions' }, |
| 40 | + 'Check status': { dir: 'check-status', group: 'Check Status' }, |
| 41 | + 'Checks': { dir: 'checks', group: 'Checks and Monitors' }, |
| 42 | + 'Client certificates': { dir: 'client-certificates', group: 'Client Certificates' }, |
| 43 | + 'Dashboards': { dir: 'dashboards', group: 'Dashboards' }, |
| 44 | + 'Environment variables': { dir: 'environment-variables', group: 'Environment Variables' }, |
| 45 | + 'Error Groups': { dir: 'error-groups', group: 'Error Groups' }, |
| 46 | + 'Heartbeats': { dir: 'heartbeats', group: 'Checks and Monitors' }, |
| 47 | + 'Incident Updates': { dir: 'incident-updates', group: 'Dashboard Incident Updates' }, |
| 48 | + 'Incidents': { dir: 'incidents', group: 'Dashboard Incidents' }, |
| 49 | + 'Location': { dir: 'location', group: 'Locations' }, |
| 50 | + 'Maintenance windows': { dir: 'maintenance-windows', group: 'Maintenance Windows' }, |
| 51 | + 'Monitors': { dir: 'monitors', group: 'Checks and Monitors' }, |
| 52 | + 'Private locations': { dir: 'private-locations', group: 'Private Locations' }, |
| 53 | + 'Reporting': { dir: 'reporting', group: 'Reporting' }, |
| 54 | + 'Runtimes': { dir: 'runtimes', group: 'Runtimes' }, |
| 55 | + 'Snippets': { dir: 'snippets', group: 'Snippets' }, |
| 56 | + 'Static IPs': { dir: 'static-ips', group: 'Static IPs' }, |
| 57 | + 'Status Page Incidents': { dir: 'status-page-incidents', group: 'Status Page Incidents' }, |
| 58 | + 'Status Page Services': { dir: 'status-page-services', group: 'Status Page Services' }, |
| 59 | + 'Status Pages': { dir: 'status-pages', group: 'Status Pages' }, |
| 60 | + 'Subscriptions': { dir: 'status-pages', group: 'Status Page Subscribers' }, |
| 61 | + 'Triggers': { dir: 'triggers', group: 'Check Triggers' }, |
| 62 | +}; |
| 63 | + |
| 64 | +// Monitor-type endpoints under the "Checks" or "Monitors" tag go into |
| 65 | +// specific subgroups within "Checks and Monitors". |
| 66 | +const MONITOR_SUBGROUP_PATTERNS = [ |
| 67 | + { pattern: /icmp/i, dir: 'monitors', subgroup: 'ICMP Monitor' }, |
| 68 | + { pattern: /dns/i, dir: 'monitors', subgroup: 'DNS Monitor' }, |
| 69 | + { pattern: /tcp/i, dir: 'checks', subgroup: 'TCP Monitor' }, |
| 70 | + { pattern: /url/i, dir: 'monitors', subgroup: 'URL Monitor' }, |
| 71 | +]; |
| 72 | + |
| 73 | +// --------------------------------------------------------------------------- |
| 74 | +// Helpers |
| 75 | +// --------------------------------------------------------------------------- |
| 76 | + |
| 77 | +function slugify(text) { |
| 78 | + return text |
| 79 | + .toLowerCase() |
| 80 | + .replace(/\[.*?\]\s*/g, '') // strip [beta] etc. |
| 81 | + .replace(/[^a-z0-9]+/g, '-') |
| 82 | + .replace(/^-+|-+$/g, ''); |
| 83 | +} |
| 84 | + |
| 85 | +function run(cmd, opts = {}) { |
| 86 | + if (DRY_RUN && !opts.allowInDryRun) { |
| 87 | + console.log(` [dry-run] ${cmd}`); |
| 88 | + return ''; |
| 89 | + } |
| 90 | + return execSync(cmd, { encoding: 'utf-8', cwd: ROOT, ...opts }).trim(); |
| 91 | +} |
| 92 | + |
| 93 | +function loadSpec() { |
| 94 | + const raw = readFileSync(SPEC_PATH, 'utf-8'); |
| 95 | + // OpenAPI descriptions may contain literal control chars (newlines in JSON strings) |
| 96 | + return JSON.parse(raw.replace(/[\x00-\x1f]/g, (ch) => { |
| 97 | + if (ch === '\n') return '\\n'; |
| 98 | + if (ch === '\r') return '\\r'; |
| 99 | + if (ch === '\t') return '\\t'; |
| 100 | + return ''; |
| 101 | + })); |
| 102 | +} |
| 103 | + |
| 104 | +function loadExclusions() { |
| 105 | + if (!existsSync(IGNORE_PATH)) return new Set(); |
| 106 | + const raw = readFileSync(IGNORE_PATH, 'utf-8'); |
| 107 | + const parsed = yamlParse(raw); |
| 108 | + const list = parsed?.excluded_endpoints ?? []; |
| 109 | + return new Set(list.map((e) => e.trim())); |
| 110 | +} |
| 111 | + |
| 112 | +function scanExistingMdx() { |
| 113 | + const documented = new Set(); |
| 114 | + const apiRefDir = join(ROOT, 'api-reference'); |
| 115 | + |
| 116 | + function walk(dir) { |
| 117 | + for (const entry of readdirSync(dir, { withFileTypes: true })) { |
| 118 | + const full = join(dir, entry.name); |
| 119 | + if (entry.isDirectory()) { walk(full); continue; } |
| 120 | + if (!entry.name.endsWith('.mdx')) continue; |
| 121 | + const content = readFileSync(full, 'utf-8'); |
| 122 | + const match = content.match(/openapi:\s*(get|post|put|delete|patch)\s+(\/\S+)/i); |
| 123 | + if (match) { |
| 124 | + documented.add(`${match[1].toUpperCase()} ${match[2]}`); |
| 125 | + } |
| 126 | + } |
| 127 | + } |
| 128 | + walk(apiRefDir); |
| 129 | + return documented; |
| 130 | +} |
| 131 | + |
| 132 | +function resolveMapping(tag, summary) { |
| 133 | + const mapping = TAG_MAP[tag]; |
| 134 | + if (!mapping) { |
| 135 | + // Unknown tag — create new directory and group from tag name |
| 136 | + return { dir: slugify(tag), group: tag, subgroup: null }; |
| 137 | + } |
| 138 | + |
| 139 | + // For tags that map to "Checks and Monitors", check if it's a monitor subtype |
| 140 | + if (mapping.group === 'Checks and Monitors' && (tag === 'Checks' || tag === 'Monitors')) { |
| 141 | + for (const mp of MONITOR_SUBGROUP_PATTERNS) { |
| 142 | + if (mp.pattern.test(summary)) { |
| 143 | + return { dir: mp.dir, group: 'Checks and Monitors', subgroup: mp.subgroup }; |
| 144 | + } |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + return { dir: mapping.dir, group: mapping.group, subgroup: null }; |
| 149 | +} |
| 150 | + |
| 151 | +function generateFilename(summary) { |
| 152 | + const slug = slugify(summary); |
| 153 | + return slug || 'unnamed-endpoint'; |
| 154 | +} |
| 155 | + |
| 156 | +function prExists(branchName) { |
| 157 | + try { |
| 158 | + const result = run( |
| 159 | + `gh pr list --head "${branchName}" --state all --json number --jq 'length'`, |
| 160 | + { allowInDryRun: true } |
| 161 | + ); |
| 162 | + return parseInt(result, 10) > 0; |
| 163 | + } catch { |
| 164 | + return false; |
| 165 | + } |
| 166 | +} |
| 167 | + |
| 168 | +// --------------------------------------------------------------------------- |
| 169 | +// docs.json updater |
| 170 | +// --------------------------------------------------------------------------- |
| 171 | + |
| 172 | +function addToDocsJson(docsJson, pagePath, groupName, subgroupName) { |
| 173 | + // Structure: docsJson.navigation.tabs[] → { tab: "API", pages: [...] } |
| 174 | + const tabs = docsJson.navigation?.tabs ?? []; |
| 175 | + const apiTab = tabs.find((t) => t.tab === 'API'); |
| 176 | + if (!apiTab) { |
| 177 | + console.warn(' ⚠ Could not find API tab in docs.json'); |
| 178 | + return false; |
| 179 | + } |
| 180 | + |
| 181 | + // Find "API Reference" group within the API tab's pages |
| 182 | + const apiRefGroup = (apiTab.pages ?? []).find( |
| 183 | + (g) => typeof g === 'object' && g.group === 'API Reference' |
| 184 | + ); |
| 185 | + if (!apiRefGroup) { |
| 186 | + console.warn(' ⚠ Could not find "API Reference" group in docs.json'); |
| 187 | + return false; |
| 188 | + } |
| 189 | + |
| 190 | + // Find the target group within API Reference |
| 191 | + let targetGroup = apiRefGroup.pages.find( |
| 192 | + (g) => typeof g === 'object' && g.group === groupName |
| 193 | + ); |
| 194 | + |
| 195 | + // Create group if it doesn't exist |
| 196 | + if (!targetGroup) { |
| 197 | + targetGroup = { group: groupName, pages: [] }; |
| 198 | + apiRefGroup.pages.push(targetGroup); |
| 199 | + console.log(` + Created new group "${groupName}" in docs.json`); |
| 200 | + } |
| 201 | + |
| 202 | + // If there's a subgroup, find or create it within the target group |
| 203 | + if (subgroupName) { |
| 204 | + let subgroup = targetGroup.pages.find( |
| 205 | + (g) => typeof g === 'object' && g.group === subgroupName |
| 206 | + ); |
| 207 | + if (!subgroup) { |
| 208 | + subgroup = { group: subgroupName, pages: [] }; |
| 209 | + targetGroup.pages.push(subgroup); |
| 210 | + console.log(` + Created new subgroup "${subgroupName}" in docs.json`); |
| 211 | + } |
| 212 | + subgroup.pages.push(pagePath); |
| 213 | + } else { |
| 214 | + targetGroup.pages.push(pagePath); |
| 215 | + } |
| 216 | + |
| 217 | + return true; |
| 218 | +} |
| 219 | + |
| 220 | +// --------------------------------------------------------------------------- |
| 221 | +// Main |
| 222 | +// --------------------------------------------------------------------------- |
| 223 | + |
| 224 | +async function main() { |
| 225 | + console.log(DRY_RUN ? '🏃 DRY RUN MODE\n' : '🚀 Running endpoint sync\n'); |
| 226 | + |
| 227 | + // 1. Load inputs |
| 228 | + const spec = loadSpec(); |
| 229 | + const exclusions = loadExclusions(); |
| 230 | + const documented = scanExistingMdx(); |
| 231 | + |
| 232 | + console.log(`📋 Spec endpoints: ${Object.keys(spec.paths).length} paths`); |
| 233 | + console.log(`📄 Documented endpoints: ${documented.size}`); |
| 234 | + console.log(`🚫 Excluded endpoints: ${exclusions.size}`); |
| 235 | + |
| 236 | + // 2. Find undocumented endpoints |
| 237 | + const undocumented = []; |
| 238 | + for (const [path, methods] of Object.entries(spec.paths)) { |
| 239 | + for (const [method, details] of Object.entries(methods)) { |
| 240 | + if (!['get', 'post', 'put', 'delete', 'patch'].includes(method)) continue; |
| 241 | + const key = `${method.toUpperCase()} ${path}`; |
| 242 | + |
| 243 | + if (documented.has(key)) continue; |
| 244 | + if (exclusions.has(key)) { |
| 245 | + console.log(` ⏭ ${key} — excluded`); |
| 246 | + continue; |
| 247 | + } |
| 248 | + |
| 249 | + const tag = details.tags?.[0] ?? ''; |
| 250 | + const summary = details.summary ?? ''; |
| 251 | + const { dir, group, subgroup } = resolveMapping(tag, summary); |
| 252 | + const filename = generateFilename(summary); |
| 253 | + |
| 254 | + undocumented.push({ key, method: method.toUpperCase(), path, tag, summary, dir, group, subgroup, filename }); |
| 255 | + } |
| 256 | + } |
| 257 | + |
| 258 | + if (undocumented.length === 0) { |
| 259 | + console.log('\n✅ All endpoints are documented (or excluded). Nothing to do.'); |
| 260 | + return; |
| 261 | + } |
| 262 | + |
| 263 | + console.log(`\n🆕 Found ${undocumented.length} undocumented endpoint(s):\n`); |
| 264 | + for (const ep of undocumented) { |
| 265 | + console.log(` ${ep.key} → ${ep.dir}/${ep.filename}.mdx [${ep.group}${ep.subgroup ? ' > ' + ep.subgroup : ''}]`); |
| 266 | + } |
| 267 | + |
| 268 | + // 3. Process each endpoint |
| 269 | + let created = 0; |
| 270 | + let skipped = 0; |
| 271 | + |
| 272 | + // Store the main branch name to return to between PRs |
| 273 | + const mainBranch = run('git rev-parse --abbrev-ref HEAD', { allowInDryRun: true }); |
| 274 | + |
| 275 | + for (const ep of undocumented) { |
| 276 | + const branchName = `api-doc/${ep.dir}/${ep.filename}`; |
| 277 | + const mdxRelPath = `api-reference/${ep.dir}/${ep.filename}.mdx`; |
| 278 | + const docsJsonPagePath = `api-reference/${ep.dir}/${ep.filename}`; |
| 279 | + |
| 280 | + console.log(`\n--- Processing: ${ep.key} ---`); |
| 281 | + |
| 282 | + // Check for existing PR |
| 283 | + if (prExists(branchName)) { |
| 284 | + console.log(` ⏭ PR already exists for branch ${branchName}`); |
| 285 | + skipped++; |
| 286 | + continue; |
| 287 | + } |
| 288 | + |
| 289 | + // Ensure we're on main and up to date |
| 290 | + run(`git checkout ${mainBranch}`); |
| 291 | + |
| 292 | + // Create branch |
| 293 | + run(`git checkout -b "${branchName}"`); |
| 294 | + |
| 295 | + // Create MDX stub |
| 296 | + const mdxDir = join(ROOT, 'api-reference', ep.dir); |
| 297 | + if (!existsSync(mdxDir)) { |
| 298 | + mkdirSync(mdxDir, { recursive: true }); |
| 299 | + console.log(` + Created directory: api-reference/${ep.dir}/`); |
| 300 | + } |
| 301 | + |
| 302 | + const mdxContent = `---\nopenapi: ${ep.method.toLowerCase()} ${ep.path}\ntitle: ${ep.summary.replace(/\[.*?\]\s*/g, '').trim()}\n---\n`; |
| 303 | + |
| 304 | + if (!DRY_RUN) { |
| 305 | + writeFileSync(join(ROOT, mdxRelPath), mdxContent); |
| 306 | + } |
| 307 | + console.log(` + Created ${mdxRelPath}`); |
| 308 | + |
| 309 | + // Update docs.json |
| 310 | + const docsJson = JSON.parse(readFileSync(DOCS_JSON_PATH, 'utf-8')); |
| 311 | + const added = addToDocsJson(docsJson, docsJsonPagePath, ep.group, ep.subgroup); |
| 312 | + if (added && !DRY_RUN) { |
| 313 | + writeFileSync(DOCS_JSON_PATH, JSON.stringify(docsJson, null, 2) + '\n'); |
| 314 | + } |
| 315 | + console.log(` + Updated docs.json → ${ep.group}${ep.subgroup ? ' > ' + ep.subgroup : ''}`); |
| 316 | + |
| 317 | + // Commit |
| 318 | + run(`git add "${mdxRelPath}" docs.json`); |
| 319 | + const commitMsg = `docs(api): add ${ep.summary.replace(/\[.*?\]\s*/g, '').trim()} endpoint`; |
| 320 | + run(`git commit -m "${commitMsg.replace(/"/g, '\\"')}"`); |
| 321 | + |
| 322 | + // Push |
| 323 | + run(`git push origin "${branchName}"`); |
| 324 | + |
| 325 | + // Create PR |
| 326 | + const prTitle = commitMsg; |
| 327 | + const prBody = [ |
| 328 | + '## New API Endpoint Documentation', |
| 329 | + '', |
| 330 | + `**Endpoint:** \`${ep.key}\``, |
| 331 | + `**Category:** ${ep.group}${ep.subgroup ? ' > ' + ep.subgroup : ''}`, |
| 332 | + '', |
| 333 | + 'Automatically detected from OpenAPI spec update. This PR adds:', |
| 334 | + `- MDX stub: \`${mdxRelPath}\``, |
| 335 | + '- Navigation entry in `docs.json`', |
| 336 | + '', |
| 337 | + '**To exclude this endpoint permanently**, add it to `.api-doc-ignore` and close this PR.', |
| 338 | + '', |
| 339 | + '---', |
| 340 | + '*Auto-generated by sync-api-endpoints workflow*', |
| 341 | + ].join('\n'); |
| 342 | + |
| 343 | + run(`gh pr create --title "${prTitle.replace(/"/g, '\\"')}" --body "${prBody.replace(/"/g, '\\"')}" --label "auto-generated" --label "api-docs" --head "${branchName}"`); |
| 344 | + |
| 345 | + console.log(` ✅ PR created for ${ep.key}`); |
| 346 | + created++; |
| 347 | + |
| 348 | + // Return to main for next iteration |
| 349 | + run(`git checkout ${mainBranch}`); |
| 350 | + } |
| 351 | + |
| 352 | + console.log(`\n========================================`); |
| 353 | + console.log(`✅ Done. Created: ${created} | Skipped: ${skipped} | Total new: ${undocumented.length}`); |
| 354 | +} |
| 355 | + |
| 356 | +main().catch((err) => { |
| 357 | + console.error('❌ Fatal error:', err); |
| 358 | + process.exit(1); |
| 359 | +}); |
0 commit comments