/** * Detect Preview Pages * Determines which pages should be included in PR preview based on changed files. * * Outputs (for GitHub Actions): * - pages-to-deploy: JSON array of URL paths to deploy * - has-layout-changes: 'true' if layout/asset/data changes detected * - has-api-doc-changes: 'true' if api-docs/ or openapi/ changes detected * - needs-author-input: 'true' if author must select pages * - change-summary: Human-readable summary of changes */ import { execSync } from 'child_process'; import { appendFileSync, existsSync, readFileSync } from 'fs'; import { load } from 'js-yaml'; import { extractDocsUrls } from './parse-pr-urls.js'; import { getChangedContentFiles, mapContentToPublic, } from '../../scripts/lib/content-utils.js'; const GITHUB_OUTPUT = process.env.GITHUB_OUTPUT || '/dev/stdout'; const PR_BODY = process.env.PR_BODY || ''; const BASE_REF = process.env.BASE_REF || 'origin/master'; const MAX_PAGES = 50; // Limit to prevent storage bloat // Validate BASE_REF to prevent command injection // Allows branch names with letters, numbers, dots, underscores, hyphens, and slashes if (!/^origin\/[a-zA-Z0-9._\/-]+$/.test(BASE_REF)) { console.error(`Invalid BASE_REF: ${BASE_REF}`); process.exit(1); } /** * Get all changed files in the PR * @returns {string[]} Array of changed file paths */ function getAllChangedFiles() { try { const output = execSync(`git diff --name-only ${BASE_REF}...HEAD`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }); return output.trim().split('\n').filter(Boolean); } catch (err) { console.error(`Error detecting changes: ${err.message}`); return []; } } /** * Categorize changed files by type * @param {string[]} files - All changed files * @returns {Object} Categorized files */ function categorizeChanges(files) { return { content: files.filter((f) => f.startsWith('content/') && f.endsWith('.md')), layouts: files.filter((f) => f.startsWith('layouts/')), assets: files.filter((f) => f.startsWith('assets/')), data: files.filter((f) => f.startsWith('data/')), apiDocs: files.filter( (f) => f.startsWith('api-docs/') || f.startsWith('openapi/') ), }; } /** * Check if PR only contains deletions (no additions/modifications) * @returns {boolean} */ function isOnlyDeletions() { try { const output = execSync( `git diff --diff-filter=d --name-only ${BASE_REF}...HEAD`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } ); // If there are no non-deletion changes, this returns empty return output.trim() === ''; } catch { return false; } } /** * Detect API pages affected by changed api-docs files. * Maps changed api-docs files to their corresponding content URL paths by reading * each product version's .config.yml and extracting API keys. * @param {string[]} apiDocFiles - Changed files in api-docs/ * @returns {string[]} Array of URL paths for affected API pages */ function detectApiPages(apiDocFiles) { const pages = new Set(); const processedVersions = new Set(); // Matches the {product}/{version} path segment in api-docs/{product}/{version}/... // e.g., api-docs/influxdb3/core/.config.yml -> captures 'influxdb3/core' const PRODUCT_VERSION_PATTERN = /^api-docs\/([^/]+\/[^/]+)\//; for (const file of apiDocFiles) { const match = file.match(PRODUCT_VERSION_PATTERN); if (!match) continue; const productVersionDir = match[1]; // e.g., 'influxdb3/core' or 'influxdb/v2' // Only process each product version once if (processedVersions.has(productVersionDir)) continue; processedVersions.add(productVersionDir); const configPath = `api-docs/${productVersionDir}/.config.yml`; if (!existsSync(configPath)) continue; try { const configContent = readFileSync(configPath, 'utf-8'); const config = load(configContent); if (!config || !config.apis) continue; for (const apiKey of Object.keys(config.apis)) { // Extract apiName: e.g., 'v3@3' -> 'v3', 'v1-compatibility@2' -> 'v1-compatibility' const apiName = apiKey.split('@')[0]; const urlPath = `/${productVersionDir}/api/${apiName}/`; pages.add(urlPath); } } catch (err) { console.log( ` āš ļø Could not read or parse ${configPath}: ${err.message}` ); } } return Array.from(pages); } /** * Write output for GitHub Actions */ function setOutput(name, value) { const output = typeof value === 'string' ? value : JSON.stringify(value); appendFileSync(GITHUB_OUTPUT, `${name}=${output}\n`); console.log(`::set-output name=${name}::${output}`); } // Main execution function main() { console.log('šŸ” Detecting changes for PR preview...\n'); // Check for deletions-only PR if (isOnlyDeletions()) { console.log('šŸ“­ PR contains only deletions - skipping preview'); setOutput('pages-to-deploy', '[]'); setOutput('has-layout-changes', 'false'); setOutput('has-api-doc-changes', 'false'); setOutput('needs-author-input', 'false'); setOutput('change-summary', 'No pages to preview (content removed)'); setOutput('skip-reason', 'deletions-only'); return; } const allChangedFiles = getAllChangedFiles(); const changes = categorizeChanges(allChangedFiles); console.log(`šŸ“ Changed files breakdown:`); console.log(` Content: ${changes.content.length}`); console.log(` Layouts: ${changes.layouts.length}`); console.log(` Assets: ${changes.assets.length}`); console.log(` Data: ${changes.data.length}`); console.log(` API Docs: ${changes.apiDocs.length}\n`); const hasLayoutChanges = changes.layouts.length > 0 || changes.assets.length > 0 || changes.data.length > 0 || changes.apiDocs.length > 0; let pagesToDeploy = []; // Strategy 1: Content-only changes - use existing change detection if (changes.content.length > 0) { console.log('šŸ“ Processing content changes...'); const expandedContent = getChangedContentFiles(BASE_REF, { verbose: true }); const htmlPaths = mapContentToPublic(expandedContent, 'public'); // Convert HTML paths to URL paths pagesToDeploy = Array.from(htmlPaths).map((htmlPath) => { return '/' + htmlPath.replace('public/', '').replace('/index.html', '/'); }); console.log(` Found ${pagesToDeploy.length} affected pages\n`); } // Strategy 2: API doc changes - auto-detect affected API pages if (changes.apiDocs.length > 0) { console.log( 'šŸ“‹ API doc changes detected, auto-detecting affected pages...' ); const apiPages = detectApiPages(changes.apiDocs); if (apiPages.length > 0) { console.log( ` Found ${apiPages.length} affected API page(s): ${apiPages.join(', ')}` ); pagesToDeploy = [...new Set([...pagesToDeploy, ...apiPages])]; } } // Strategy 3: Layout/asset changes - parse URLs from PR body if (hasLayoutChanges) { console.log( 'šŸŽØ Layout/asset changes detected, checking PR description for URLs...' ); // Auto-detect home page when the root template changes if (changes.layouts.includes('layouts/index.html')) { pagesToDeploy = [...new Set([...pagesToDeploy, '/'])]; console.log( ' šŸ  Home page template (layouts/index.html) changed - auto-adding / to preview pages' ); } const prUrls = extractDocsUrls(PR_BODY); if (prUrls.length > 0) { console.log(` Found ${prUrls.length} URLs in PR description`); // Merge with content pages (deduplicate) pagesToDeploy = [...new Set([...pagesToDeploy, ...prUrls])]; } else if (pagesToDeploy.length === 0) { // No content changes, no auto-detected pages, and no URLs specified - need author input console.log( ' āš ļø No URLs found in PR description - author input needed' ); setOutput('pages-to-deploy', '[]'); setOutput('has-layout-changes', 'true'); setOutput('has-api-doc-changes', String(changes.apiDocs.length > 0)); setOutput('needs-author-input', 'true'); setOutput( 'change-summary', 'Layout/asset changes detected - please specify pages to preview' ); return; } } // Apply page limit if (pagesToDeploy.length > MAX_PAGES) { console.log( `āš ļø Limiting preview to ${MAX_PAGES} pages (found ${pagesToDeploy.length})` ); pagesToDeploy = pagesToDeploy.slice(0, MAX_PAGES); } // Generate summary const summary = pagesToDeploy.length > 0 ? `${pagesToDeploy.length} page(s) will be previewed` : 'No pages to preview'; console.log(`\nāœ… ${summary}`); setOutput('pages-to-deploy', JSON.stringify(pagesToDeploy)); setOutput('has-layout-changes', String(hasLayoutChanges)); setOutput('has-api-doc-changes', String(changes.apiDocs.length > 0)); setOutput('needs-author-input', 'false'); setOutput('change-summary', summary); } main();