From bf4af51387eae3e8a333a0b4d76415047c8660ef Mon Sep 17 00:00:00 2001 From: Peter Bengtsson Date: Mon, 18 Mar 2024 07:56:49 -0400 Subject: [PATCH] Periodically validate docs-urls.json in github/github (#49220) Co-authored-by: Robert Sese <734194+rsese@users.noreply.github.com> --- .../validate-github-github-docs-urls.yml | 105 ++++++++ package.json | 1 + src/frame/lib/warm-server.d.ts | 9 + src/frame/lib/warm-server.js | 9 +- src/links/lib/validate-docs-urls.ts | 141 +++++++++++ .../clean-up-old-branches.ts | 62 +++++ .../generate-new-json.ts | 50 ++++ .../validate-github-github-docs-urls/index.ts | 65 +++++ .../post-pr-comment.ts | 235 ++++++++++++++++++ .../validate.ts | 72 ++++++ tsconfig.json | 5 +- 11 files changed, 750 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/validate-github-github-docs-urls.yml create mode 100644 src/frame/lib/warm-server.d.ts create mode 100644 src/links/lib/validate-docs-urls.ts create mode 100644 src/links/scripts/validate-github-github-docs-urls/clean-up-old-branches.ts create mode 100644 src/links/scripts/validate-github-github-docs-urls/generate-new-json.ts create mode 100644 src/links/scripts/validate-github-github-docs-urls/index.ts create mode 100644 src/links/scripts/validate-github-github-docs-urls/post-pr-comment.ts create mode 100644 src/links/scripts/validate-github-github-docs-urls/validate.ts diff --git a/.github/workflows/validate-github-github-docs-urls.yml b/.github/workflows/validate-github-github-docs-urls.yml new file mode 100644 index 0000000000..d7f8d472c3 --- /dev/null +++ b/.github/workflows/validate-github-github-docs-urls.yml @@ -0,0 +1,105 @@ +name: Validate github/github docs URLs + +# **What it does**: Checks the URLs in docs-urls.json in github/github +# **Why we have it**: To ensure the values in docs-urls.json are perfect. +# **Who does it impact**: Docs content. + +on: + workflow_dispatch: + schedule: + - cron: '20 16 * * *' # Run every day at 16:20 UTC / 8:20 PST + pull_request: + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + validate_github_github_docs_urls: + name: Validate github/github docs URLs + if: github.repository == 'github/docs-internal' + runs-on: ubuntu-20.04-xl + steps: + - name: Check out repo's default branch + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - uses: ./.github/actions/node-npm-setup + + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + token: ${{ secrets.DOCS_BOT_PAT_READPUBLICKEY }} + repository: github/github + ref: master + path: github + + - name: Run validation + run: | + # This will generate a .json file which we can use to + # do other things in other steps. + npm run validate-github-github-docs-urls -- validate \ + --output checks.json \ + github/config/docs-urls.json + + - name: Update config/docs-urls.json in github/github (possibly) + if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + env: + GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_WRITEORG_PROJECT }} + run: | + npm run validate-github-github-docs-urls -- generate-new-json checks.json github/config/docs-urls.json + + cd github + git status + git diff + changes=$(git diff --name-only | wc -l) + if [[ $changes -eq 0 ]]; then + echo "There are no changes to commit after running generate-new-json. Exiting this step" + exit 0 + fi + + current_timestamp=$(date '+%Y-%m-%d-%H%M%S') + branch_name="update-docs-urls-$current_timestamp" + git checkout -b "$branch_name" + current_daystamp=$(date '+%Y-%m-%d') + git commit -a -m "Update Docs URLs from automation ($current_daystamp)" + git push origin "$branch_name" + + # XXX TODO + # Perhaps post an issue somewhere, about that the fact that this + # branch has been created and now needs to be turned into a PR + # that some human can take responsibility for. + + - name: Clean up old branches in github/github + if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + env: + GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_WRITEORG_PROJECT }} + run: | + npm run validate-github-github-docs-urls -- clean-up-old-branches --prefix update-docs-urls + + echo "To see them all, go to:" + echo "https://github.com/github/github/branches/all?query=update-docs-urls-" + + # If a PR comes along to github/docs-internal that causes some + # URLs in docs-urls.json (in github/github) to now fail, then + # we'll want to make the PR author+reviewer aware of this. + # For example, you moved a page without setting up a redirect. + # Or you edited a heading that now breaks a URL with fragment. + # In the latter case, you might want to update the URL in docs-urls.json + # after this PR has landed, or consider using `` as a + # workaround for the time being. + - name: Generate PR comment + if: ${{ github.event_name == 'pull_request' }} + env: + GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_WRITEORG_PROJECT }} + ISSUE_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + run: npm run validate-github-github-docs-urls -- post-pr-comment checks.json + + - uses: ./.github/actions/slack-alert + if: ${{ failure() && github.event_name == 'schedule' }} + with: + slack_channel_id: ${{ secrets.DOCS_ALERTS_SLACK_CHANNEL_ID }} + slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} diff --git a/package.json b/package.json index bfeef3abe9..d4ac5492d3 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "update-data-and-image-paths": "node src/early-access/scripts/update-data-and-image-paths.js", "update-internal-links": "node src/links/scripts/update-internal-links.js", "validate-asset-images": "node src/assets/scripts/validate-asset-images.js", + "validate-github-github-docs-urls": "tsx src/links/scripts/validate-github-github-docs-urls/index.ts", "warmup-remotejson": "node src/archives/scripts/warmup-remotejson.js" }, "lint-staged": { diff --git a/src/frame/lib/warm-server.d.ts b/src/frame/lib/warm-server.d.ts new file mode 100644 index 0000000000..c6943c71cb --- /dev/null +++ b/src/frame/lib/warm-server.d.ts @@ -0,0 +1,9 @@ +type Site = { + pages: Record + redirects: Record + unversionedTree: Record + siteTree: Record + pageList: Page[] +} + +export default function warmServer(languages: string[]): Promise diff --git a/src/frame/lib/warm-server.js b/src/frame/lib/warm-server.js index 0d405d7bf9..3dca776558 100644 --- a/src/frame/lib/warm-server.js +++ b/src/frame/lib/warm-server.js @@ -15,14 +15,17 @@ const dog = { // For multiple-triggered Promise sharing let promisedWarmServer -async function warmServer() { +async function warmServer(languagesOnly = []) { const startTime = Date.now() if (process.env.NODE_ENV !== 'test') { - console.log('Priming context information...') + console.log( + 'Priming context information...', + languagesOnly && languagesOnly.length ? `${languagesOnly.join(',')} only` : '', + ) } - const unversionedTree = await dog.loadUnversionedTree() + const unversionedTree = await dog.loadUnversionedTree(languagesOnly) const siteTree = await dog.loadSiteTree(unversionedTree) const pageList = await dog.loadPages(unversionedTree) const pageMap = await dog.loadPageMap(pageList) diff --git a/src/links/lib/validate-docs-urls.ts b/src/links/lib/validate-docs-urls.ts new file mode 100644 index 0000000000..283086b50c --- /dev/null +++ b/src/links/lib/validate-docs-urls.ts @@ -0,0 +1,141 @@ +import cheerio from 'cheerio' + +import warmServer from '@/frame/lib/warm-server.js' +import { liquid } from '@/content-render/index.js' +import shortVersions from '@/versions/middleware/short-versions.js' +import contextualize from '@/frame/middleware/context/context.js' +import features from '@/versions/middleware/features.js' +import findPage from '@/frame/middleware/find-page.js' +import { createMinimalProcessor } from '@/content-render/unified/processor.js' +import getRedirect from '@/redirects/lib/get-redirect.js' + +export type DocsUrls = { + [identifier: string]: string +} + +type Page = { + permalinks: Permalink[] + relativePath: string + rawIntro: string + rawPermissions?: string + markdown: string +} +type Permalink = { + href: string + languageCode: string +} +type PageMap = { + [href: string]: Page +} +type Redirects = { + [from: string]: string +} + +export type Check = { + identifier: string + url: string + pageURL: string + found: boolean + fragment: string | undefined + fragmentFound?: boolean + fragmentCandidates?: string[] + // If the URL lead to a redirect, this is its URL (starting with /en/...) + redirectPageURL?: string + // If the URL lead to a redirect, this is what the new URL should be + // (for example /the/new/pathname#my-fragment) + redirect?: string +} + +export async function validateDocsUrl(docsUrls: DocsUrls, { checkFragments = false } = {}) { + const site = await warmServer(['en']) + const pages: PageMap = site.pages + const redirects: Redirects = site.redirects + + const checks: Check[] = [] + for (const [identifier, url] of Object.entries(docsUrls)) { + if (!url.startsWith('/')) { + throw new Error(`URL doesn't start with '/': ${url} (identifier: ${identifier})`) + } + const pathname = url.split('?')[0] + // If the url is just '/' we want to check the homepage, + // which is `/en`, not `/en/`. + const [pageURL, fragment] = `/en${pathname === '/' ? '' : pathname}`.split('#') + + const page = pages[pageURL] + const check: Check = { + identifier, + url, + pageURL, + fragment, + found: !!page, + } + let redirectedPage: Page | null = null + if (!page) { + const redirect = getRedirect(pageURL, { + userLanguage: 'en', + redirects, + pages, + }) + if (redirect) { + redirectedPage = pages[redirect] + if (!redirectedPage) { + throw new Error(`The redirected page doesn't exist: ${redirect}`) + } + check.found = true + check.redirectPageURL = redirect + check.redirect = stripLanguagePrefix(redirect) + if (fragment) { + check.redirect += `#${fragment}` + } + } + } + + if (checkFragments && fragment) { + const permalink = (redirectedPage || page).permalinks[0] + const html = await renderInnerHTML(redirectedPage || page, permalink) + const $ = cheerio.load(html) + check.fragmentFound = $(`#${fragment}`).length > 0 || $(`a[name="${fragment}"]`).length > 0 + if (!check.fragmentFound) { + const fragmentCandidates: string[] = [] + $('h2[id], h3[id]').each((_, el) => { + const id = $(el).attr('id') + if (id) { + fragmentCandidates.push(id) + } + }) + check.fragmentCandidates = fragmentCandidates + } + } + checks.push(check) + } + return checks +} + +async function renderInnerHTML(page: Page, permalink: Permalink) { + const next = () => {} + const res = {} + + const pagePath = permalink.href + const req = { + path: pagePath, + language: permalink.languageCode, + pagePath, + cookies: {}, + // The contextualize() middleware will create a new one. + // Here it just exists for the sake of TypeScript. + context: {}, + } + await contextualize(req, res, next) + await shortVersions(req, res, next) + await findPage(req, res, next) + await features(req, res, next) + + const markdown = await liquid.parseAndRender(page.markdown, req.context) + const processor = createMinimalProcessor(req.context) + const vFile = await processor.process(markdown) + return vFile.toString() +} + +function stripLanguagePrefix(url: string) { + return url.replace(/^\/en\//, '/') +} diff --git a/src/links/scripts/validate-github-github-docs-urls/clean-up-old-branches.ts b/src/links/scripts/validate-github-github-docs-urls/clean-up-old-branches.ts new file mode 100644 index 0000000000..c7e408f8a1 --- /dev/null +++ b/src/links/scripts/validate-github-github-docs-urls/clean-up-old-branches.ts @@ -0,0 +1,62 @@ +import { Octokit } from '@octokit/rest' +import { retry } from '@octokit/plugin-retry' + +const DEFAULT_MIN_DAYS = 30 + +type Options = { + prefix: string + minDays: number + repository: string +} + +export async function cleanUpOldBranches(options: Options) { + const minDays = parseInt(`${options.minDays || DEFAULT_MIN_DAYS}`, 10) + + if (!process.env.GITHUB_TOKEN) { + throw new Error('You must set the GITHUB_TOKEN environment variable.') + } + const octokit = retryingOctokit(process.env.GITHUB_TOKEN) + + const [owner, repo] = options.repository.split('/') + const { data: refs } = await octokit.request( + 'GET /repos/{owner}/{repo}/git/matching-refs/{ref}', + { + owner, + repo, + ref: `heads/${options.prefix}`, + }, + ) + + for (const ref of refs) { + const branchName = ref.ref.replace('refs/heads/', '') + const { data: branch } = await octokit.request('GET /repos/{owner}/{repo}/branches/{branch}', { + owner, + repo, + branch: branchName, + }) + const { name, commit } = branch + if (!commit.commit.author || !commit.commit.author.date) continue + const lastUpdated = new Date(commit.commit.author.date) + const ageDays = (Date.now() - lastUpdated.getTime()) / (1000 * 60 * 60 * 24) + console.log( + `Branch ${name} was last updated ${ageDays.toFixed(1)} days ago (${lastUpdated.toISOString()})`, + ) + if (ageDays > minDays) { + console.log(`Deleting branch ${name} !!`) + await octokit.request('DELETE /repos/{owner}/{repo}/git/refs/{ref}', { + owner, + repo, + ref: `heads/${name}`, + }) + } else { + console.log(`Branch ${name} is not old enough (min days: ${minDays})`) + } + } +} + +function retryingOctokit(token: string) { + const RetryingOctokit = Octokit.plugin(retry) + return new RetryingOctokit({ + auth: `token ${token}`, + }) +} diff --git a/src/links/scripts/validate-github-github-docs-urls/generate-new-json.ts b/src/links/scripts/validate-github-github-docs-urls/generate-new-json.ts new file mode 100644 index 0000000000..9c026d1ff2 --- /dev/null +++ b/src/links/scripts/validate-github-github-docs-urls/generate-new-json.ts @@ -0,0 +1,50 @@ +import fs from 'fs' + +import chalk from 'chalk' + +import { type Check } from '../../lib/validate-docs-urls' + +type Options = { + output?: string +} +type DocsUrls = { + [key: string]: string +} +export function generateNewJSON( + checksFilePath: string, + destinationFilePath: string, + options: Options, +) { + const checks: Check[] = JSON.parse(fs.readFileSync(checksFilePath, 'utf8')) + const destination: DocsUrls = JSON.parse(fs.readFileSync(destinationFilePath, 'utf8')) + + let countChanges = 0 + for (const [identifier, url] of Object.entries(destination)) { + const check = checks.find((check) => check.identifier === identifier) + if (check) { + // At the moment, the only possible correction is if the URL is + // found but required a redirect. + if (check.redirect) { + destination[identifier] = check.redirect + console.log( + `For identifier '${chalk.bold(identifier)}' change from '${chalk.bold(url)}' to '${chalk.bold(check.redirect)}'`, + ) + countChanges++ + } + } + } + + if (countChanges > 0) { + const writeTo = options.output || destinationFilePath + // It's important that this serializes exactly like the Ruby code + // that is the CLI script `script/add-docs-url` in github/github. + const serialized = JSON.stringify(destination, null, 2) + '\n' + fs.writeFileSync(writeTo, serialized, 'utf-8') + console.log(`Wrote ${countChanges} change${countChanges === 1 ? '' : 's'} to ${writeTo}`) + if (writeTo !== destinationFilePath) { + console.log(`Consider now running: diff ${destinationFilePath} ${writeTo}`) + } + } else { + console.log(chalk.yellow('No changes to write')) + } +} diff --git a/src/links/scripts/validate-github-github-docs-urls/index.ts b/src/links/scripts/validate-github-github-docs-urls/index.ts new file mode 100644 index 0000000000..af1da7f3f9 --- /dev/null +++ b/src/links/scripts/validate-github-github-docs-urls/index.ts @@ -0,0 +1,65 @@ +import { program } from 'commander' +import { postPRComment } from './post-pr-comment' +import { validate } from './validate' +import { generateNewJSON } from './generate-new-json' +import { cleanUpOldBranches } from './clean-up-old-branches' + +program + .name('validate-github-github-docs-urls') + .description('Validate config/docs-urls.json in github/github') + +program + .command('validate') + .description('Validate config/docs-urls.json in github/github') + .option('--fail-on-warning', 'Any warning will make the process exit with a non-zero code') + .option('--fail-on-error', 'Any error will make the process exit with a non-zero code') + .option('-o, --output ', 'Output file') + .argument('', 'path to the docs-urls JSON file') + .action(validate) + +program + .command('post-pr-comment') + .description('Given a JSON file of checks, post a comment to a PR about problems') + .option( + '-r, --repository ', + 'Repository where the PR is located', + process.env.REPOSITORY, + ) + .option( + '-i, --issue-number ', + 'Issue number to post the comment on', + process.env.ISSUE_NUMBER, + ) + .option('--dry-run', "Don't post any comment. Only print what it would post.") + .option('--fail-on-error', 'Any error will make the process exit with a non-zero code') + .argument('', 'JSON file that has all checks') + .action(postPRComment) + +program + .command('generate-new-json') + .description( + 'Given a JSON file of checks, and the destination JSON file, edit the second based on the first', + ) + .option('--fail-on-error', 'Any error will make the process exit with a non-zero code') + .option('-o, --output ', 'Output file') + .argument('', 'JSON file that has all checks') + .argument('', 'JSON file to edit') + .action(generateNewJSON) + +program + .command('clean-up-old-branches') + .description('Clean up branches our automation has created and pushed to upstream') + .option('--min-days ', 'Number of days since last updated', '30') + .option( + '--repository ', + 'Repository where branches to clean up are located', + 'github/github', + ) + .option( + '--prefix ', + "Prefix of the branch name to clean up, e.g. 'update-docs-urls'", + 'update-docs-urls', + ) + .action(cleanUpOldBranches) + +program.parse(process.argv) diff --git a/src/links/scripts/validate-github-github-docs-urls/post-pr-comment.ts b/src/links/scripts/validate-github-github-docs-urls/post-pr-comment.ts new file mode 100644 index 0000000000..cd8c88e74e --- /dev/null +++ b/src/links/scripts/validate-github-github-docs-urls/post-pr-comment.ts @@ -0,0 +1,235 @@ +import fs from 'fs' + +import boxen from 'boxen' +import { Octokit } from '@octokit/rest' +import { retry } from '@octokit/plugin-retry' + +import { type Check } from '../../lib/validate-docs-urls' + +type PostPRCommentOptions = { + issueNumber: number | string + repository: string + dryRun: boolean + failOnError?: boolean +} + +// This function is designed to be able to run and potentially do nothing. +export async function postPRComment(filePath: string, options: PostPRCommentOptions) { + // Check the options before we even begin + if (!options.dryRun) { + if (!options.issueNumber) { + throw new Error( + 'You must provide an issue number. Either set ISSUE_NUMBER env var or pass the --issue-number flag. Remember, a PR is an issue actually.', + ) + } + if (!options.repository) { + throw new Error( + 'You must provide a repository name. Either set REPOSITORY env var or pass the --repository flag.', + ) + } + } + + // Exit early if there's absolutely nothing to "complain" about + const checks: Check[] = JSON.parse(fs.readFileSync(filePath, 'utf8')) + + // Really bad. This could lead to a 404 from links in GitHub. + const failedChecks = checks.filter((check) => !check.found) + + // Bad. This could lead to the fragment not finding the right + // heading in the found page. + const failedFragmentChecks = checks.filter( + (check) => check.found && check.fragment && !check.fragmentFound, + ) + + const body: string[] = [] + + // Suppose, the first time the PR is created, we post a comment about + // some failing fragments for example. Then, the PR author addresses + // that and commits more to the PR. Now, perhaps there are no more failing + // checks. Then we're going to update the previously posted comment. + // But(!) suppose there were never any failing checks. Then, we don't + // want to bother posting a comment at all since it's just noise to + // say "This PR introduces no failing checks.". Especially, since this + // will be the case for the large majority of PRs in this repo. + const onlyIfAlreadyPosted = failedChecks.length === 0 && failedFragmentChecks.length === 0 + + if (onlyIfAlreadyPosted) { + body.push('No failed checks when checking `config/docs-urls.json` in `github/github`.') + } else { + body.push( + 'For every PR, we compare what that means for `config/docs-urls.json` ' + + 'in `github/github`. That file determines how links to Docs are generated ' + + 'in GitHub.\n' + + "If those links are broken, it's either because the URL pathname is wrong " + + "or it's because the fragment (a.k.a. anchor or hash) is wrong.\n" + + 'It could be a false positive because `config/docs-urls.json` could ' + + "have a link to something that only is there under a feature flag and they're " + + 'OK with the documentation not being written, yet.', + ) + body.push('') + + if (failedChecks.length > 0) { + body.push(`## Failed URLs`) + body.push( + `\n${failedChecks.length} URL${failedChecks.length === 1 ? '' : 's'} failed to be found. ` + + 'This could be intentional or it could be a mistake. Check each URL. ', + ) + body.push('') + body.push( + '**Note** that a URL could be failing because the link is present in `master` ' + + 'but the documentation has **not yet been written**.', + ) + body.push('') + for (const check of failedChecks) { + body.push(`- ❌ [${check.url}](${makeAbsoluteDocsURL(check)}) (${check.identifier})`) + } + body.push('') + } + if (failedFragmentChecks.length > 0) { + body.push(`## Failed fragments`) + body.push( + `\n${failedFragmentChecks.length} fragment${failedFragmentChecks.length === 1 ? '' : 's'} failed to be found on its page. ` + + 'This could be intentional or it could be a mistake. Check each URL. ', + ) + body.push('') + body.push(makeMarkdownTableFragments(failedFragmentChecks)) + body.push('\n') + body.push( + '[See `config/docs-urls.json` in `github/github`](https://github.com/github/github/blob/master/config/docs-urls.json)\n', + ) + body.push( + 'Perhaps you intentionally wanted to change the heading, which "broke" the fragment.\n' + + 'You can go ahead with your fragment-breaking change and once your PR lands ' + + 'go over to github/github and edit the equivalent entry in `config/docs-urls.json`.', + ) + } + } + + body.push('\n') + body.push( + `*(This comment was posted by \`validate-github-github-docs-urls\` automatically on ${new Date().toISOString()})*`, + ) + + const needle = '__post-pr-comment__' + + if (options.dryRun) { + console.log(body.join('\n')) + } else { + // We must inject this into the comment we're about to start so that it + // can be possible to find a previously posted comment. + body.push(``) + + const issueNumber = parseInt(options.issueNumber as string, 10) + await updateIssueComment(options.repository, issueNumber, body.join('\n'), { + needle, + onlyIfAlreadyPosted, + }) + } + + if (options.failOnError) { + console.warn( + boxen( + ` +A failure here doesn't actually mean the *workflow* failed unexpectedly. + +It's just that the PR failed the checks. +A red X should yield sufficient attention to the PR author and reviewer(s). + +Remember, this workflow check is not required because it's not guaranteed to be free from false positives. + `.trim(), + ), + ) + process.exit(failedChecks.length + failedFragmentChecks.length) + } +} + +function makeAbsoluteDocsURL(check: Check) { + let absURL = `https://docs.github.com${check.pageURL}` + if (check.fragment) { + absURL += `#${check.fragment}` + } + return absURL +} + +function makeMarkdownTableFragments(checks: Check[]) { + let markdown = '' + for (const check of checks) { + let table = '' + + table += '' + table += `` + + table += '' + table += `` + + table += '' + table += `` + + table += '' + table += `` + + table += '
Identifier${check.identifier}
URL${check.url} on prod
Fragment${check.fragment}
Candidates${(check.fragmentCandidates || []).map((x) => `${x}`).join(', ')}
' + markdown += table + markdown += '\n' + } + return markdown +} + +async function updateIssueComment( + repository: string, + issueNumber: number, + body: string, + { + needle = '', + onlyIfAlreadyPosted = false, + }: { needle?: string; onlyIfAlreadyPosted?: boolean } = {}, +) { + if (!process.env.GITHUB_TOKEN) { + throw new Error('When not in dry-run mode, you must set the GITHUB_TOKEN environment variable.') + } + const octokit = retryingOctokit(process.env.GITHUB_TOKEN) + + const [owner, repo] = repository.split('/') + const { data: existingComments } = await octokit.issues.listComments({ + owner, + repo, + issue_number: issueNumber, + }) + for (const comment of existingComments) { + if (comment.body && comment.body.includes(needle)) { + console.warn(`Editing comment ${comment.id}`) + await octokit.issues.updateComment({ + owner, + repo, + comment_id: comment.id, + body, + }) + return + } + } + + // It found no comment to *edit*, so it create *create* a new comment. + // But `onlyIfAlreadyPosted` is true, so it does nothing. + // This is convenient when might have, during the lifetime of a PR, + // posted a comment, then committed more changes, and then realize + // that what was posted previously is no long the case. + if (onlyIfAlreadyPosted) { + console.warn(`Deliberately not creating a new comment`) + return + } + + console.warn(`Creating new comment in ${issueNumber}`) + await octokit.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body, + }) +} + +function retryingOctokit(token: string) { + const RetryingOctokit = Octokit.plugin(retry) + return new RetryingOctokit({ + auth: `token ${token}`, + }) +} diff --git a/src/links/scripts/validate-github-github-docs-urls/validate.ts b/src/links/scripts/validate-github-github-docs-urls/validate.ts new file mode 100644 index 0000000000..ab750c5832 --- /dev/null +++ b/src/links/scripts/validate-github-github-docs-urls/validate.ts @@ -0,0 +1,72 @@ +import fs from 'fs' + +import chalk from 'chalk' +import { type DocsUrls, validateDocsUrl } from '@/links/lib/validate-docs-urls' + +type Options = { + failOnWarning?: boolean + failOnError?: boolean + output?: string +} + +export async function validate(filePath: string, options: Options) { + let exitCode = 0 + const docsUrls: DocsUrls = JSON.parse(fs.readFileSync(filePath, 'utf8')) + const label = `Checked ${Object.keys(docsUrls).length.toLocaleString()} URLs` + console.time(label) + const checks = await validateDocsUrl(docsUrls, { checkFragments: true }) + let i = 0 + for (const check of checks) { + const prefix = `${++i}.`.padEnd(3) + if (check.found) { + if (check.redirect) { + if (options.failOnWarning) exitCode++ + console.log(prefix, `🔀 ${check.url} -> ${check.redirect} (${check.identifier})`) + } else { + console.log(prefix, `✅ ${check.url} (${check.identifier})`) + } + } else { + if (options.failOnError) exitCode++ + console.log(prefix, `❌ ${check.url} (${check.identifier})`) + } + if (check.fragment) { + if (check.fragmentFound) { + console.log(` (fragment) 👍🏼 ${check.fragment}`) + } else { + console.log(` (fragment) ❌ ${check.fragment}`) + if (options.failOnError) exitCode++ + } + } + } + + console.log('') + console.timeEnd(label) + + const T = (heading: string) => `${heading}:`.padEnd(20) + console.log('') + console.log( + chalk.green(T('Perfect URLs')), + checks.filter((check) => check.found && !check.redirect).length, + ) + console.log( + chalk.green(T('Perfect fragments')), + checks.filter( + (check) => check.found && !check.redirect && check.fragment && check.fragmentFound, + ).length, + ) + console.log( + chalk.yellow(T('Redirects')), + checks.filter((check) => check.found && check.redirect).length, + ) + console.log(chalk.red(T('Failures')), checks.filter((check) => !check.found).length) + console.log( + chalk.red(T('Failing fragments')), + checks.filter((check) => check.found && check.fragment && !check.fragmentFound).length, + ) + + if (options.output) { + fs.writeFileSync(options.output, JSON.stringify(checks, null, 2)) + } + + process.exit(exitCode) +} diff --git a/tsconfig.json b/tsconfig.json index beedb05416..2ff8836510 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,10 @@ "baseUrl": ".", "noEmit": true, "allowSyntheticDefaultImports": true, - "incremental": true + "incremental": true, + "paths": { + "@/*": ["./src/*"] + } }, "exclude": [ "node_modules"