Skip to content

Commit 48965d4

Browse files
Add automation to open PRs for undocumented API endpoints (#189)
Adds a GitHub Actions workflow that detects new endpoints in the OpenAPI spec without corresponding MDX stubs, and opens one PR per endpoint with the stub file and docs.json navigation update ready to merge. Includes an exclusion mechanism (.api-doc-ignore) to permanently suppress PRs for endpoints that should not be documented. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 56d796c commit 48965d4

4 files changed

Lines changed: 480 additions & 0 deletions

File tree

.api-doc-ignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Endpoints excluded from automatic documentation PRs.
2+
# Format: "METHOD /path" — must match the OpenAPI spec exactly.
3+
# Adding an endpoint here permanently suppresses PR creation for it.
4+
#
5+
# Example:
6+
# - "GET /v1/internal/debug"
7+
# - "POST /v1/experimental/beta-feature"
8+
9+
excluded_endpoints: []
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
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

Comments
 (0)