diff --git a/.github/workflows/content-changes-table-comment.yml b/.github/workflows/content-changes-table-comment.yml index fa7389b033..9a07e80691 100644 --- a/.github/workflows/content-changes-table-comment.yml +++ b/.github/workflows/content-changes-table-comment.yml @@ -27,6 +27,7 @@ on: - synchronize paths: - 'content/**' + - 'data/reusables/**' permissions: contents: read @@ -62,7 +63,7 @@ jobs: APP_URL: ${{ env.APP_URL }} BASE_SHA: ${{ github.event.pull_request.base.sha || inputs.BASE_SHA }} HEAD_SHA: ${{ github.event.pull_request.head.sha || inputs.HEAD_SHA }} - run: src/workflows/content-changes-table-comment.js + run: npm run content-changes-table-comment - name: Find content directory changes comment uses: peter-evans/find-comment@d5fe37641ad8451bdd80312415672ba26c86575e diff --git a/package.json b/package.json index 778b3ee3f6..977fc0f3bc 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "check-content-type": "node src/workflows/check-content-type.js", "check-github-github-links": "node src/links/scripts/check-github-github-links.js", "close-dangling-prs": "tsx src/workflows/close-dangling-prs.ts", + "content-changes-table-comment": "tsx src/workflows/content-changes-table-comment.ts", "copy-fixture-data": "node src/tests/scripts/copy-fixture-data.js", "count-translation-corruptions": "tsx src/languages/scripts/count-translation-corruptions.ts", "debug": "cross-env NODE_ENV=development ENABLED_LANGUAGES=en nodemon --inspect src/frame/server.ts", diff --git a/src/workflows/content-changes-table-comment-cli.js b/src/workflows/content-changes-table-comment-cli.ts old mode 100755 new mode 100644 similarity index 64% rename from src/workflows/content-changes-table-comment-cli.js rename to src/workflows/content-changes-table-comment-cli.ts index 68d57db148..2c71c5339d --- a/src/workflows/content-changes-table-comment-cli.js +++ b/src/workflows/content-changes-table-comment-cli.ts @@ -3,7 +3,7 @@ // [start-readme] // // For testing the GitHub Action that executes -// src/workflows/content-changes-table-comment.js but doing it +// src/workflows/content-changes-table-comment.ts but doing it // locally. // This is more convenient and faster than relying on seeing that the // Action produces in a PR. Especially since @@ -18,21 +18,18 @@ // // export GITHUB_TOKEN=github_pat_11AAAG..... // export APP_URL=https://docs.github.com -// ./src/workflows/content-changes-table-comment-cli.js github docs-internal main 4a0b0f2 +// tsx src/workflows/content-changes-table-comment-cli.ts github docs-internal main 4a0b0f2 // // [end-readme] import { program } from 'commander' -import main from '#src/workflows/content-changes-table-comment.js' +import main from '@/workflows/content-changes-table-comment' program .description('Produce a nice table based on the branch diff') - .option('-v, --verbose', 'Verbose outputs') - .option('--debug', "Loud about everything it's doing") - .arguments('owner repo bash_sha head_sha', 'bla bla') + .arguments('owner repo bash_sha head_sha') .parse(process.argv) -const opts = program.opts() const args = program.args - -console.log(await main(...args, { ...opts })) +const [owner, repo, baseSHA, headSHA] = args +console.log(await main(owner, repo, baseSHA, headSHA)) diff --git a/src/workflows/content-changes-table-comment.js b/src/workflows/content-changes-table-comment.js deleted file mode 100755 index dea6e5429f..0000000000 --- a/src/workflows/content-changes-table-comment.js +++ /dev/null @@ -1,194 +0,0 @@ -#!/usr/bin/env node - -/** - * Hi there! 👋 - * To test this code locally, outside of Actions, you need to run - * the script src/workflows/content-changes-table-comment-cli.js - * - * See the instructions in the doc string comment at the - * top of src/workflows/content-changes-table-comment-cli.js - */ - -import * as github from '@actions/github' -import core from '@actions/core' - -import { getContents } from './git-utils.js' -import parse from '#src/frame/lib/read-frontmatter.js' -import getApplicableVersions from '#src/versions/lib/get-applicable-versions.js' -import nonEnterpriseDefaultVersion from '#src/versions/lib/non-enterprise-default-version.js' -import { allVersionShortnames } from '#src/versions/lib/all-versions.js' -import { waitUntilUrlIsHealthy } from './wait-until-url-is-healthy.js' - -const { GITHUB_TOKEN, APP_URL } = process.env -const context = github.context - -if (!GITHUB_TOKEN) { - throw new Error(`GITHUB_TOKEN environment variable not set`) -} - -if (!APP_URL) { - throw new Error(`APP_URL environment variable not set`) -} - -// the max size of the comment (in bytes) -// the action we use to post the comment caps out at about 144kb -// see docs-engineering#1849 for more info -const MAX_COMMENT_SIZE = 125000 - -const PROD_URL = 'https://docs.github.com' - -// When this file is invoked directly from action as opposed to being imported -if (import.meta.url.endsWith(process.argv[1])) { - const owner = context.repo.owner - const repo = context.payload.repository.name - const baseSHA = process.env.BASE_SHA || context.payload.pull_request.base.sha - const headSHA = process.env.HEAD_SHA || context.payload.pull_request.head.sha - - const isHealthy = await waitUntilUrlIsHealthy(new URL('/healthz', APP_URL).toString()) - if (!isHealthy) { - core.setFailed(`Timeout waiting for preview environment: ${APP_URL}`) - } else { - const markdownTable = await main(owner, repo, baseSHA, headSHA) - core.setOutput('changesTable', markdownTable) - } -} - -async function main(owner, repo, baseSHA, headSHA) { - const octokit = github.getOctokit(GITHUB_TOKEN) - // get the list of file changes from the PR - const response = await octokit.rest.repos.compareCommitsWithBasehead({ - owner, - repo, - basehead: `${baseSHA}...${headSHA}`, - }) - - const { files } = response.data - const markdownTableHead = [ - '| **Source** | **Preview** | **Production** | **What Changed** |', - '|:----------- |:----------- |:----------- |:----------- |', - ] - let markdownTable = '' - - const pathPrefix = 'content/' - const articleFiles = files.filter( - ({ filename }) => filename.startsWith(pathPrefix) && filename.toLowerCase() !== 'readme.md', - ) - - const lines = await Promise.all( - articleFiles.map(async (file) => { - const sourceUrl = file.blob_url - const fileName = file.filename.slice(pathPrefix.length) - const fileUrl = fileName.replace('/index.md', '').replace(/\.md$/, '') - - // get the file contents and decode them - // this script is called from the main branch, so we need the API call to get the contents from the branch, instead - const fileContents = await getContents( - owner, - repo, - // Can't get its content if it no longer exists. - // Meaning, you'd get a 404 on the `getContents()` utility function. - // So, to be able to get necessary meta data about what it *was*, - // if it was removed, fall back to the 'base'. - file.status === 'removed' ? baseSHA : headSHA, - file.filename, - ) - - // parse the frontmatter - const { data } = parse(fileContents) - - let contentCell = '' - let previewCell = '' - let prodCell = '' - - if (file.status === 'added') contentCell = 'New file: ' - else if (file.status === 'removed') contentCell = 'Removed: ' - contentCell += `[\`${fileName}\`](${sourceUrl})` - - try { - // the try/catch is needed because getApplicableVersions() returns either [] or throws an error when it can't parse the versions frontmatter - // try/catch can be removed if docs-engineering#1821 is resolved - // i.e. for feature based versioning, like ghec: 'issue-6337' - const fileVersions = getApplicableVersions(data.versions) - - for (const plan in allVersionShortnames) { - // plan is the shortName (i.e., fpt) - // allVersionShortNames[plan] is the planName (i.e., free-pro-team) - - // walk by the plan names since we generate links differently for most plans - const versions = fileVersions.filter((fileVersion) => - fileVersion.includes(allVersionShortnames[plan]), - ) - - if (versions.length === 1) { - // for fpt and ghec - - if (versions.toString() === nonEnterpriseDefaultVersion) { - // omit version from fpt url - - previewCell += `[${plan}](${APP_URL}/${fileUrl})
` - prodCell += `[${plan}](${PROD_URL}/${fileUrl})
` - } else { - // for non-versioned releases (ghec) use full url - - previewCell += `[${plan}](${APP_URL}/${versions}/${fileUrl})
` - prodCell += `[${plan}](${PROD_URL}/${versions}/${fileUrl})
` - } - } else if (versions.length) { - // for ghes releases, link each version - - previewCell += `${plan}@ ` - prodCell += `${plan}@ ` - - versions.forEach((version) => { - previewCell += `[${version.split('@')[1]}](${APP_URL}/${version}/${fileUrl}) ` - prodCell += `[${version.split('@')[1]}](${PROD_URL}/${version}/${fileUrl}) ` - }) - previewCell += '
' - prodCell += '
' - } - } - } catch (e) { - console.error( - `Version information for ${file.filename} couldn't be determined from its frontmatter.`, - ) - } - let note = '' - if (file.status === 'removed') { - note = 'removed' - // If the file was removed, the `previewCell` no longer makes sense - // since it was based on looking at the base sha. - previewCell = 'n/a' - } - - return `| ${contentCell} | ${previewCell} | ${prodCell} | ${note} |` - }), - ) - - // this section limits the size of the comment - const cappedLines = [] - let underMax = true - - lines.reduce((previous, current, index, array) => { - if (underMax) { - if (previous + current.length > MAX_COMMENT_SIZE) { - underMax = false - cappedLines.push('**Note** There are more changes in this PR than we can show.') - return previous - } - - cappedLines.push(array[index]) - return previous + current.length - } - return previous - }, markdownTable.length) - - if (cappedLines.length) { - cappedLines.unshift(...markdownTableHead) - } - - markdownTable += cappedLines.join('\n') - - return markdownTable -} - -export default main diff --git a/src/workflows/content-changes-table-comment.ts b/src/workflows/content-changes-table-comment.ts new file mode 100755 index 0000000000..249cb69be7 --- /dev/null +++ b/src/workflows/content-changes-table-comment.ts @@ -0,0 +1,313 @@ +#!/usr/bin/env node + +/** + * Hi there! 👋 + * To test this code locally, outside of Actions, you need to run + * the script src/workflows/content-changes-table-comment-cli.ts + * + * See the instructions in the doc string comment at the + * top of src/workflows/content-changes-table-comment-cli.ts + */ + +import fs from 'node:fs' +import path from 'node:path' + +import * as github from '@actions/github' +import core from '@actions/core' + +import walk from 'walk-sync' +import { Octokit } from '@octokit/rest' +import { retry } from '@octokit/plugin-retry' + +import { getContents } from './git-utils.js' +import getApplicableVersions from '@/versions/lib/get-applicable-versions.js' +import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-version.js' +import { allVersionShortnames } from '@/versions/lib/all-versions.js' +import { waitUntilUrlIsHealthy } from './wait-until-url-is-healthy.js' +import readFrontmatter from '@/frame/lib/read-frontmatter.js' +import { getLiquidTokens } from '@/content-linter/lib/helpers/liquid-utils.js' + +const { GITHUB_TOKEN, APP_URL } = process.env +const context = github.context + +// the max size of the comment (in bytes) +// the action we use to post the comment caps out at about 144kb +// see docs-engineering#1849 for more info +const MAX_COMMENT_SIZE = 125000 + +const PROD_URL = 'https://docs.github.com' + +// When this file is invoked directly from action as opposed to being imported +if (import.meta.url.endsWith(process.argv[1])) { + const owner = context.repo.owner + const repo = context.payload.repository!.name + const baseSHA = process.env.BASE_SHA || context.payload.pull_request!.base.sha + const headSHA = process.env.HEAD_SHA || context.payload.pull_request!.head.sha + + const isHealthy = await waitUntilUrlIsHealthy(new URL('/healthz', APP_URL).toString()) + if (!isHealthy) { + core.setFailed(`Timeout waiting for preview environment: ${APP_URL}`) + } else { + const markdownTable = await main(owner, repo, baseSHA, headSHA) + core.setOutput('changesTable', markdownTable) + } +} + +async function main(owner: string, repo: string, baseSHA: string, headSHA: string) { + if (!GITHUB_TOKEN) { + throw new Error(`GITHUB_TOKEN environment variable not set`) + } + if (!APP_URL) { + throw new Error(`APP_URL environment variable not set`) + } + const RetryingOctokit = Octokit.plugin(retry) + const octokit = new RetryingOctokit({ + auth: `token ${GITHUB_TOKEN}`, + }) + // get the list of file changes from the PR + const response = await octokit.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${baseSHA}...${headSHA}`, + }) + + const { files } = response.data + + const pathPrefix = 'content/' + const reusablesPrefix = 'data/reusables/' + const articleFiles = (files || []).filter( + ({ filename }) => filename.startsWith(pathPrefix) && filename.toLowerCase() !== 'readme.md', + ) + const reusablesFiles = (files || []).filter( + ({ filename }) => + filename.startsWith(reusablesPrefix) && filename.toLowerCase() !== 'readme.md', + ) + + const filesUsingReusables: File[] = [] + if (reusablesFiles.length) { + const contentFilePaths = new Set() + const allContentFiles = getAllContentFiles() + for (const reusablesFile of reusablesFiles) { + const relativePath = path + .relative('data/reusables', reusablesFile.filename) + .replace(/\.md$/, '') + const needle = `reusables.${relativePath.split('/').join('.')}` + for (const [contentFilePath, contentFileContents] of allContentFiles) { + if (inLiquid(contentFilePath, contentFileContents, needle)) { + contentFilePaths.add(contentFilePath) + } + } + } + const articleFilesAlready = articleFiles.map((file) => file.filename) + for (const contentFilePath of contentFilePaths) { + if (articleFilesAlready.includes(contentFilePath)) continue + filesUsingReusables.push({ + filename: contentFilePath, + blob_url: makeBlobUrl(owner, repo, headSHA, contentFilePath), + status: 'changed', + }) + } + } + + const lines = await Promise.all( + articleFiles.map(async (file) => { + const sourceUrl = file.blob_url + const fileName = file.filename.slice(pathPrefix.length) + const fileUrl = fileName.replace('/index.md', '').replace(/\.md$/, '') + + // get the file contents and decode them + // this script is called from the main branch, so we need the API call to get the contents from the branch, instead + const fileContents = await getContents( + owner, + repo, + // Can't get its content if it no longer exists. + // Meaning, you'd get a 404 on the `getContents()` utility function. + // So, to be able to get necessary meta data about what it *was*, + // if it was removed, fall back to the 'base'. + file.status === 'removed' ? baseSHA : headSHA, + file.filename, + ) + + const { data } = readFrontmatter(fileContents) + if (!data) { + console.warn(`Unable to extract frontmatter from ${file.filename}`) + return + } + + return makeRow({ file, fileName, sourceUrl, fileUrl, data }) + }), + ) + + lines.push( + ...(await Promise.all( + filesUsingReusables.map((file) => { + const { data } = readFrontmatter(fs.readFileSync(file.filename, 'utf-8')) + const fileName = file.filename.slice(pathPrefix.length) + const fileUrl = fileName.replace('/index.md', '').replace(/\.md$/, '') + return makeRow({ + file, + fileName, + sourceUrl: file.blob_url, + fileUrl, + data, + fromReusable: true, + }) + }), + )), + ) + + const filteredLines = lines.filter(Boolean) as string[] + if (!filteredLines.length) { + console.warn( + "No found files to generate a comment from. This PR doesn't contain any content changes.", + ) + return '' + } + + const headings = ['Source', 'Preview', 'Production', 'What Changed'] + const markdownTableHead = [ + `| ${headings.map((heading) => `**${heading}**`).join(' | ')} |`, + `| ${headings.map(() => ':---').join(' | ')} |`, + ] + let markdownTable = markdownTableHead.join('\n') + '\n' + for (const filteredLine of filteredLines) { + if ((markdownTable + filteredLine).length > MAX_COMMENT_SIZE) { + markdownTable += '\n**Note** There are more changes in this PR than we can show.' + break + } + markdownTable += filteredLine + '\n' + } + + return markdownTable +} + +type Token = { + name?: string + args?: string +} + +const parsedLiquidTokensCache = new Map() + +function inLiquid(filePath: string, fileContents: string, needle: string) { + if (!parsedLiquidTokensCache.has(filePath)) { + parsedLiquidTokensCache.set(filePath, getLiquidTokens(fileContents)) + } + const tokens = parsedLiquidTokensCache.get(filePath) as Token[] + for (const token of tokens) { + if (token.name === 'data') { + const { args } = token + if (args === needle) { + return true + } + } + } + return false +} + +function makeBlobUrl(owner: string, repo: string, sha: string, filePath: string) { + return `https://github.com/${owner}/${repo}/blob/${sha}/${encodeURIComponent(filePath)}` +} + +type File = { + filename: string + status: 'added' | 'removed' | 'modified' | 'renamed' | 'copied' | 'changed' | 'unchanged' + blob_url: string +} + +function makeRow({ + file, + fileUrl, + fileName, + sourceUrl, + data, + fromReusable, +}: { + file: File + fileUrl: string + fileName: string + sourceUrl: string + data: any + fromReusable?: boolean +}) { + let contentCell = '' + let previewCell = '' + let prodCell = '' + + if (file.status === 'added') contentCell = 'New file: ' + else if (file.status === 'removed') contentCell = 'Removed: ' + contentCell += `[\`${fileName}\`](${sourceUrl})` + + try { + // the try/catch is needed because getApplicableVersions() returns either [] or throws an error when it can't parse the versions frontmatter + // try/catch can be removed if docs-engineering#1821 is resolved + // i.e. for feature based versioning, like ghec: 'issue-6337' + const fileVersions: string[] = getApplicableVersions(data.versions) + + for (const plan in allVersionShortnames) { + // plan is the shortName (i.e., fpt) + // allVersionShortNames[plan] is the planName (i.e., free-pro-team) + + // walk by the plan names since we generate links differently for most plans + const versions = fileVersions.filter((fileVersion) => + fileVersion.includes(allVersionShortnames[plan]), + ) + + if (versions.length === 1) { + // for fpt and ghec + + if (versions.toString() === nonEnterpriseDefaultVersion) { + // omit version from fpt url + + previewCell += `[${plan}](${APP_URL}/${fileUrl})
` + prodCell += `[${plan}](${PROD_URL}/${fileUrl})
` + } else { + // for non-versioned releases (ghec) use full url + + previewCell += `[${plan}](${APP_URL}/${versions}/${fileUrl})
` + prodCell += `[${plan}](${PROD_URL}/${versions}/${fileUrl})
` + } + } else if (versions.length) { + // for ghes releases, link each version + + previewCell += `${plan}@ ` + prodCell += `${plan}@ ` + + versions.forEach((version) => { + previewCell += `[${version.split('@')[1]}](${APP_URL}/${version}/${fileUrl}) ` + prodCell += `[${version.split('@')[1]}](${PROD_URL}/${version}/${fileUrl}) ` + }) + previewCell += '
' + prodCell += '
' + } + } + } catch (e) { + console.error( + `Version information for ${file.filename} couldn't be determined from its frontmatter.`, + ) + } + let note = '' + if (file.status === 'removed') { + note = 'removed' + // If the file was removed, the `previewCell` no longer makes sense + // since it was based on looking at the base sha. + previewCell = 'n/a' + } else if (fromReusable) { + note += 'from reusable' + } + + return `| ${contentCell} | ${previewCell} | ${prodCell} | ${note} |` +} + +function getAllContentFiles(): Map { + const options = { + globs: ['**/*.md'], + includeBasePath: true, + } + const contentFiles = new Map() + for (const file of walk('content', options)) { + contentFiles.set(file, fs.readFileSync(file, 'utf-8')) + } + return contentFiles +} + +export default main