/** * Script to manage documentation preview comments on pull requests. */ const path = require("path"); const MARKER = ""; /** * Check if a URL is accessible * @param {string} url - URL to check * @returns {Promise} True if the URL is accessible (200 or 3xx), false otherwise; undefined if an error occurs */ async function isUrlAccessible(url) { try { const res = await fetch(url, { method: "GET", redirect: "manual" }); // treat 200 and 3xx as "accessible", 404 as "missing" return res.ok || (res.status >= 300 && res.status < 400); } catch (e) { console.error(`Error checking accessibility for ${url}:`, e); return undefined; } } /** * Fetch changed files from a pull request * @param {object} params - Parameters object * @param {object} params.github - GitHub API client * @param {string} params.owner - Repository owner * @param {string} params.repo - Repository name * @param {string} params.pullNumber - Pull request number * @returns {Promise>} Array of changed file objects with filename and status */ async function fetchChangedFiles({ github, owner, repo, pullNumber }) { const iterator = github.paginate.iterator(github.rest.pulls.listFiles, { owner, repo, pull_number: pullNumber, per_page: 100, }); const changedFiles = []; for await (const { data } of iterator) { changedFiles.push(...data.map(({ filename, status }) => ({ filename, status }))); } return changedFiles; } /** * Get changed documentation pages from the list of changed files * @param {Array<{filename: string, status: string}>} changedFiles - Array of changed file objects * @returns {Array<{page: string, status: string}>} Array of documentation page objects with page path and status, sorted alphabetically by page path */ function getChangedDocPages(changedFiles) { const DOCS_DIR = "docs/docs/"; const changedPages = []; for (const { filename, status } of changedFiles) { const ext = path.extname(filename); if (ext !== ".md" && ext !== ".mdx") continue; if (!filename.startsWith(DOCS_DIR)) continue; const relativePath = path.relative(DOCS_DIR, filename); const { dir, name, base } = path.parse(relativePath); let pagePath; if (base === "index.mdx") { pagePath = dir; } else { pagePath = path.join(dir, name); } // Adjust classic-ml/ to ml/ pagePath = pagePath.replace(/^classic-ml/, "ml"); // Ensure forward slashes for web paths pagePath = pagePath.split(path.sep).join("/"); changedPages.push({ page: pagePath, status }); } // Sort alphabetically by page path for easier lookup changedPages.sort((a, b) => a.page.localeCompare(b.page)); return changedPages; } /** * Create or update a PR comment with documentation preview information * @param {object} params - Parameters object * @param {object} params.github - GitHub API client * @param {string} params.owner - Repository owner * @param {string} params.repo - Repository name * @param {string} params.pullNumber - Pull request number * @param {string} params.commentBody - Comment body content */ async function upsertComment({ github, owner, repo, pullNumber, commentBody }) { // Get existing comments on the PR const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: pullNumber, per_page: 100, }); // Find existing preview docs comment const existingComment = comments.find((comment) => comment.body.includes(MARKER)); const commentBodyWithMarker = `${MARKER}\n\n${commentBody}`; if (!existingComment) { console.log("Creating comment"); await github.rest.issues.createComment({ owner, repo, issue_number: pullNumber, body: commentBodyWithMarker, }); } else { console.log("Updating comment"); await github.rest.issues.updateComment({ owner, repo, comment_id: existingComment.id, body: commentBodyWithMarker, }); } } /** * Generate the comment template for documentation preview * @param {object} params - Parameters object * @param {string} params.commitSha - Git commit SHA * @param {string} params.workflowRunLink - Link to the workflow run * @param {string} params.docsWorkflowRunUrl - Link to the docs workflow run * @param {string} params.mainMessage - Main message content * @param {Array<{link: string, status: string, isAccessible?: boolean}>} params.changedPages - Array of changed documentation page objects with link, status, and optional accessibility flag * @returns {string} Comment template */ function getCommentTemplate({ commitSha, workflowRunLink, docsWorkflowRunUrl, mainMessage, changedPages, }) { let changedPagesSection = ""; if (changedPages && changedPages.length > 0) { const pageLinks = changedPages .map(({ link, status, isAccessible }) => { let statusText = status; // Add warning or success indicator for removed and renamed files if (status === "removed" || status === "renamed") { if (isAccessible === true) { statusText = `${status}, ✅ redirect exists`; } else if (isAccessible === false) { statusText = `${status}, ❌ add a redirect`; } else { // If accessibility check failed statusText = `${status}, ⚠️ failed to check`; } } return `- ${link} (${statusText})`; }) .join("\n"); // Only collapse if there are more than 5 changed pages if (changedPages.length > 5) { changedPagesSection = `
Changed Pages (${changedPages.length}) ${pageLinks}
`; } else { changedPagesSection = ` **Changed Pages (${changedPages.length})** ${pageLinks} `; } } return ` Documentation preview for ${commitSha} ${mainMessage} ${changedPagesSection}
More info - Ignore this comment if this PR does not change the documentation. - The preview is updated when a new commit is pushed to this PR. - This comment was created by [this workflow run](${workflowRunLink}). - The documentation was built by [this workflow run](${docsWorkflowRunUrl}).
`; } /** * Main function to handle documentation preview comments * @param {object} params - Parameters object containing context and github * @param {object} params.github - GitHub API client * @param {object} params.context - GitHub context * @param {object} params.env - Environment variables */ module.exports = async ({ github, context, env }) => { const commitSha = env.COMMIT_SHA; const pullNumber = env.PULL_NUMBER; const workflowRunId = env.WORKFLOW_RUN_ID; const stage = env.STAGE; const netlifyUrl = env.NETLIFY_URL; const docsWorkflowRunUrl = env.DOCS_WORKFLOW_RUN_URL; // Validate required parameters if (!commitSha || !pullNumber || !workflowRunId || !stage || !docsWorkflowRunUrl) { throw new Error( "Missing required parameters: commit-sha, pull-number, workflow-run-id, stage, docs-workflow-run-url" ); } if (!["completed", "failed"].includes(stage)) { throw new Error("Stage must be either 'completed' or 'failed'"); } if (stage === "completed" && !netlifyUrl) { throw new Error("netlify-url is required for completed stage"); } const { owner, repo } = context.repo; const workflowRunLink = `https://github.com/${owner}/${repo}/actions/runs/${workflowRunId}`; let mainMessage; let changedPages = []; if (stage === "completed") { mainMessage = `is available at:\n\n- ${netlifyUrl}`; // Fetch changed files and get documentation pages try { const changedFiles = await fetchChangedFiles({ github, owner, repo, pullNumber }); const docPages = getChangedDocPages(changedFiles); // Convert to clickable links with status if we have changed pages if (docPages.length > 0) { changedPages = await Promise.all( docPages.map(async ({ page, status }) => { const pageUrl = `${netlifyUrl}/${page}`; const link = `[${page}](${pageUrl})`; // Check accessibility for removed or renamed pages let isAccessible; if (status === "removed" || status === "renamed") { isAccessible = await isUrlAccessible(pageUrl); } return { link, status, isAccessible, }; }) ); } } catch (error) { console.error("Error fetching changed files:", error); // Continue without changed pages list } } else if (stage === "failed") { mainMessage = "failed to build or deploy."; } const commentBody = getCommentTemplate({ commitSha, workflowRunLink, docsWorkflowRunUrl, mainMessage, changedPages, }); await upsertComment({ github, owner, repo, pullNumber, commentBody }); };