diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index e0dab4ba3..c5e4d7503 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -97,24 +97,13 @@ jobs: fi - name: Suggest fixes as review comments - # One suggested change per added line with a fix. Suggestions already on - # the PR (same file, line, and text) are not posted again. - if: steps.check.outputs.broken == 'true' && github.event.pull_request.head.repo.full_name == github.repository + # One suggested change per finding with a fix, kept in sync with the + # findings; see dev/sync-review-comments.sh + if: github.event.pull_request.head.repo.full_name == github.repository env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments" --paginate \ - --jq '.[] | {path, line, body}' | jq -s . > "$RUNNER_TEMP/posted.json" - jq --slurpfile posted "$RUNNER_TEMP/posted.json" \ - '.comments |= map(select(. as $comment | $posted[0] | index({path: $comment.path, line: $comment.line, body: $comment.body}) | not))' \ - "$RUNNER_TEMP/review.json" > "$RUNNER_TEMP/review-new.json" - - if [ "$(jq '.comments | length' "$RUNNER_TEMP/review-new.json")" -gt 0 ]; then - gh api --method POST "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews" \ - --input "$RUNNER_TEMP/review-new.json" > /dev/null \ - || echo "::warning::Could not post the suggested fixes; they are in the report above" - fi + run: dev/sync-review-comments.sh '' + existing_comment=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + --paginate --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n 1) + + # Comment only when there is something to report, or an earlier report to resolve + if [ "$BROKEN" = true ]; then + { echo "$marker"; cat "$RUNNER_TEMP/report.md"; } > "$RUNNER_TEMP/comment.md" + elif [ -n "$existing_comment" ]; then + printf '%s\n### āœ… The redirects an earlier revision of this PR broke are fixed\n' \ + "$marker" > "$RUNNER_TEMP/comment.md" + else + exit 0 + fi + + if [ -n "$existing_comment" ]; then + gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing_comment" \ + --field body=@"$RUNNER_TEMP/comment.md" + else + gh pr comment "$PR_NUMBER" --body-file "$RUNNER_TEMP/comment.md" + fi + + - name: Suggest fixes as review comments + # One suggested change per fixable entry this PR added, kept in sync + # with the findings; see dev/sync-review-comments.sh + if: github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: dev/sync-review-comments.sh '`, + `Link: \`${url}\``, + `Problem: ${error}`, + `Fix: \`${fix}\``, + '````suggestion', + source.split(url).join(fix), + '````' + ]; + return { path: file, line, side: 'RIGHT', body: body.join('\n') }; }); - return { - event: 'COMMENT', - body: 'Suggested fixes for the links this PR adds; details in the check-links comment.', - comments - }; + return { event: 'COMMENT', body: '', comments }; } const FORMATTERS = { diff --git a/dev/check-redirects.mjs b/dev/check-redirects.mjs new file mode 100644 index 000000000..f13831db8 --- /dev/null +++ b/dev/check-redirects.mjs @@ -0,0 +1,496 @@ +#!/usr/bin/env node + +/** + * Checks redirects in src/data/redirects.ts. + * + * Redirects exist so external traffic to an old URL still reaches a page, so + * each one must be correct. Checks, for every entry: + * - the source does not shadow an existing page (the middleware would redirect + * visitors away from a page that exists) + * - the source has no #fragment: browsers never send fragments, so such an + * entry can never match + * - the source has no earlier entry: the middleware uses the first match only + * - neither path starts with /docs: the middleware strips that prefix from + * requests and adds it to destinations + * - the destination is a page, not another redirect + * - the destination page exists under docs/ (or is a file under public/) + * - when the destination has a #fragment, the heading exists on that page + * + * External (http) destinations are not checked. + * + * Usage: node dev/check-redirects.mjs [options] + * --root Repository to check (default: this repository) + * --format Output as text (default), json, or markdown + * --baseline Only report findings absent from this JSON file + * (produced by --format json on another revision) + * --link-base Markdown output links each line to + * /src/data/redirects.ts, e.g. + * https://github.com/sourcegraph/docs/blob/ + * --diff `git diff` output; with --review, only entries this + * diff added get a suggested change + * --review Write a pull request review with one suggested + * change per fixable finding to this JSON file + * + * Exits 1 when any finding is reported. + */ + +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import {fileURLToPath} from 'url'; +import {extractHeadings, listFiles, routeFor} from './check-links.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const args = process.argv.slice(2); +const ROOT_DIR = path.resolve(flagValue('--root') ?? path.dirname(__dirname)); +const FORMAT = flagValue('--format') ?? 'text'; +const BASELINE_FILE = flagValue('--baseline'); +const LINK_BASE = flagValue('--link-base')?.replace(/\/$/, ''); +const DIFF_FILE = flagValue('--diff'); +const REVIEW_FILE = flagValue('--review'); + +const REDIRECTS_PATH = 'src/data/redirects.ts'; +const REDIRECTS_FILE = path.join(ROOT_DIR, REDIRECTS_PATH); +const CONSTANTS_FILE = path.join(ROOT_DIR, 'src/data/constants.ts'); +const DOCS_DIR = path.join(ROOT_DIR, 'docs'); +const PUBLIC_DIR = path.join(ROOT_DIR, 'public'); +// Report sections, most urgent first: a shadowed page is unreachable today. +// `fix` teaches the author what a correct entry looks like, where it applies. +const PROBLEM = { + shadowsPage: { + heading: 'Source overshadows a docs page that exists', + fix: + "Redirects take precedence over pages, so visitors to that page's URL are redirected away from it. " + + 'Update or remove the redirect or the page to remove the conflict.' + }, + fragmentSource: { + heading: 'Source has a #fragment, so this redirect can never match', + fix: + 'Use the page path alone as the source. #fragments are processed in the browser, so browsers ' + + 'never send them to web servers.\n\n' + + 'If the redirect destination has a #fragment, it takes precedence, otherwise if the customer ' + + "clicked a link which has a #fragment, it'll be kept and tried on the destination page." + }, + docsPrefix: { + heading: 'Source or destination starts with /docs', + fix: + 'Write paths without the /docs prefix. The site removes /docs from the requested URL before ' + + 'matching sources, and adds it back in front of the destination, so a /docs/... source never ' + + 'matches and a /docs/... destination lands on /docs/docs/....' + }, + duplicateSource: { + heading: + 'Source already has an earlier entry, so this one is never used', + fix: 'Only the first entry for a source matches. Update that entry instead of adding another.' + }, + chained: { + heading: 'Destination is another redirect', + fix: + "Chained redirects cost the customer's browser a round trip, slow down their page load time, " + + 'and frustrate them. They also make the redirects file impossible to maintain, and make it too ' + + "easy to create redirect loops. Change the rule's destination to the final destination." + }, + missingPage: { + heading: 'Destination page does not exist', + fix: + 'Set the redirect destination to the page that replaced it, or remove the rule if there is no replacement page; ' + + "visitors then get our fancy 404 page, with links they can click to find where they're trying " + + 'to go, and the search bar.' + }, + missingHeading: { + heading: 'Destination heading does not exist', + fix: "Use the heading's correct anchor, or drop the #fragment to land the customer at the top of the page." + } +}; + +function flagValue(name) { + const index = args.indexOf(name); + return index === -1 ? undefined : args[index + 1]; +} + +// Load redirects.ts without a TypeScript toolchain. The file is plain data +// plus one import, so strip the module syntax and evaluate it. +// Returns [{ source, destination, line }]. +function loadRedirects() { + const source = fs.readFileSync(REDIRECTS_FILE, 'utf-8'); + const constants = fs.readFileSync(CONSTANTS_FILE, 'utf-8'); + const rssUrl = + constants.match( + /TECHNICAL_CHANGELOG_RSS_URL\s*=\s*['"]([^'"]+)['"]/ + )?.[1] ?? ''; + + const script = source + .replace(/^import .*$/gm, '') + .replace(/^export const /gm, 'const ') + .replace(/module\.exports\s*=\s*\{[\s\S]*?\};?/g, ''); + + const sandbox = {TECHNICAL_CHANGELOG_RSS_URL: rssUrl}; + vm.runInNewContext(`${script}\nresult = updatedRedirectsData;`, sandbox); + + // Line numbers of each entry, for the report and suggested changes: the + // `source:` line, plus the `{` and `}` lines around it. Entries are written + // one `source:` per line; if that assumption fails, omit line numbers. + const lines = source.split('\n'); + const arrayEnd = lines.findIndex(line => /^\];?\s*$/.test(line)); + const positions = []; + lines.slice(0, arrayEnd).forEach((line, index) => { + if (!/^\s*source:/.test(line)) return; + let start = index; + while (start > 0 && !/^\s*\{\s*$/.test(lines[start])) start--; + let end = index; + while (end < arrayEnd && !/^\s*\},?\s*$/.test(lines[end])) end++; + positions.push({line: index + 1, startLine: start + 1, endLine: end + 1}); + }); + const havePositions = positions.length === sandbox.result.length; + + return sandbox.result.map((redirect, index) => ({ + source: redirect.source, + destination: redirect.destination, + ...(havePositions ? positions[index] : {}) + })); +} + +// Site route -> Set of anchors on that page. When foo.mdx and foo/index.mdx +// both exist the first (sorted) file owns the route, as in check-links.mjs. +function buildHeadingsByRoute() { + const headingsByRoute = new Map(); + for (const file of listFiles(DOCS_DIR, ['.mdx'])) { + const route = routeFor(file); + if (headingsByRoute.has(route)) continue; + headingsByRoute.set( + route, + extractHeadings(fs.readFileSync(path.join(DOCS_DIR, file), 'utf-8')) + ); + } + return headingsByRoute; +} + +// '/foo/bar/?x=1#baz' -> { pathname: '/foo/bar', fragment: 'baz' } +function splitUrl(url) { + const [pathAndQuery, fragment = ''] = url.split('#'); + const pathname = pathAndQuery.split('?')[0].replace(/\/+$/, '') || '/'; + return {pathname, fragment: decodeURIComponent(fragment)}; +} + +function isPublicFile(pathname) { + const fullPath = path.join(PUBLIC_DIR, pathname); + return fullPath.startsWith(PUBLIC_DIR) && fs.existsSync(fullPath); +} + +function isExternal(url) { + return /^https?:\/\//.test(url); +} + +// Every incorrect redirect: [{ source, destination, line, problem, detail?, fix? }]. +// `fix` is the entry that should replace this one, or {remove: true}, where the +// fix is mechanical; a shadowed page or missing destination needs a human. +function findBrokenRedirects(redirects, headingsByRoute) { + const findings = []; + const firstBySource = new Map(); + for (const redirect of redirects) { + if (!firstBySource.has(redirect.source)) + firstBySource.set(redirect.source, redirect); + } + const report = (redirect, problem, {detail, fix} = {}) => + findings.push({...redirect, problem: problem.heading, detail, fix}); + const withoutFragment = url => url.split('#')[0]; + const withoutDocsPrefix = url => url.replace(/^\/docs(?=\/)/, ''); + const isRedirect = pathname => + firstBySource.has(pathname) && !headingsByRoute.has(pathname); + + for (const redirect of redirects) { + const source = splitUrl(redirect.source); + if (source.fragment) { + // Another entry may already cover the source without its fragment + const fix = firstBySource.has(withoutFragment(redirect.source)) + ? {remove: true} + : {source: withoutFragment(redirect.source), destination: redirect.destination}; + report(redirect, PROBLEM.fragmentSource, {fix}); + continue; + } + if (firstBySource.get(redirect.source) !== redirect) { + report(redirect, PROBLEM.duplicateSource, {fix: {remove: true}}); + continue; + } + if (headingsByRoute.has(source.pathname)) { + report(redirect, PROBLEM.shadowsPage); + } + if ( + source.pathname.startsWith('/docs/') || + redirect.destination.startsWith('/docs/') + ) { + report(redirect, PROBLEM.docsPrefix, { + fix: { + source: withoutDocsPrefix(redirect.source), + destination: withoutDocsPrefix(redirect.destination) + } + }); + continue; + } + if (isExternal(redirect.destination)) continue; + + const destination = splitUrl(redirect.destination); + if (isRedirect(destination.pathname)) { + // The fix names the final destination; only a loop needs `detail` + const final = finalDestination(destination.pathname); + report( + redirect, + PROBLEM.chained, + final + ? {fix: {source: redirect.source, destination: final}} + : {detail: 'none, redirect loop'} + ); + continue; + } + const headings = headingsByRoute.get(destination.pathname); + if (!headings) { + if (!isPublicFile(destination.pathname)) { + report(redirect, PROBLEM.missingPage); + } + continue; + } + if (destination.fragment && !headings.has(destination.fragment)) { + report(redirect, PROBLEM.missingHeading, { + fix: {source: redirect.source, destination: destination.pathname} + }); + } + } + + // Where a visitor to `pathname` finally lands; undefined for a redirect loop + function finalDestination(pathname) { + const visited = new Set(); + while (isRedirect(pathname) && !visited.has(pathname)) { + visited.add(pathname); + pathname = firstBySource.get(pathname).destination; + if (isExternal(pathname)) return pathname; + pathname = splitUrl(pathname).pathname; + } + return visited.has(pathname) ? undefined : pathname; + } + return findings; +} + +// Line numbers are left out so an entry that only moved is not a new finding +function findingKey(finding) { + return `${finding.source}\u0000${finding.destination}\u0000${finding.problem}`; +} + +function withoutBaseline(findings, baselineFile) { + const baseline = new Set( + JSON.parse(fs.readFileSync(baselineFile, 'utf-8')).map(findingKey) + ); + return findings.filter(finding => !baseline.has(findingKey(finding))); +} + +// 'remove this entry', or which of source and destination to change to what +function describeFix({source, destination, fix}) { + if (fix.remove) return 'remove this entry'; + const changes = [ + ...(fix.source !== source ? [`the source to \`${fix.source}\``] : []), + ...(fix.destination !== destination + ? [`the destination to \`${fix.destination}\``] + : []) + ]; + return `change ${changes.join(' and ')}`; +} + +function formatText(findings) { + const scope = BASELINE_FILE ? 'broken by this change' : 'broken'; + if (findings.length === 0) { + return `āœ… No redirects ${scope}\n`; + } + const lines = [`āŒ ${findings.length} redirect(s) ${scope}:`, '']; + for (const finding of findings) { + const {source, destination, line, problem, detail, fix} = finding; + lines.push( + ` ${REDIRECTS_PATH}${line ? `:${line}` : ''}`, + ` ${source} -> ${destination}`, + ` ${problem}${detail ? ` (final destination: ${detail})` : ''}`, + ...(fix ? [` Fix: ${describeFix(finding).replaceAll('`', '')}`] : []), + '' + ); + } + return lines.join('\n'); +} + +function linkTo(text, url) { + return url ? `[${text}](${url})` : text; +} + +// Map of problem heading -> its findings in line order, sections in PROBLEM order +function groupByProblem(findings) { + const groups = new Map( + Object.values(PROBLEM).map(({heading}) => [heading, []]) + ); + for (const finding of findings) groups.get(finding.problem).push(finding); + for (const [problem, entries] of groups) { + if (entries.length === 0) groups.delete(problem); + else entries.sort((a, b) => (a.line ?? 0) - (b.line ?? 0)); + } + return groups; +} + +// Body for a pull request comment +function formatMarkdown(findings) { + if (findings.length === 0) { + return '### āœ… This PR breaks no redirects\n'; + } + + // ?plain=1 opens GitHub's code view, where #L anchors work + const fileUrl = LINK_BASE && `${LINK_BASE}/${REDIRECTS_PATH}?plain=1`; + const lines = [ + `### āŒ This PR breaks ${findings.length} redirect(s)`, + '', + 'Redirects are used so inbound traffic from external sources (links inside old versions of ' + + 'our product, bookmarks, search results, etc.) to old doc pages still reaches a relevant page.', + '', + 'A correct entry maps the old page path, exactly as the browser requests it, ' + + 'straight to a page that exists today, with an optional #heading that exists on the destination page:', + '', + '```ts', + '{', + "\tsource: '/old/section/page',", + "\tdestination: '/new/section/page#heading-slug'", + '},', + '```', + '', + 'Each section below explains how to fix the entries listed under it.', + '', + 'Do not use redirects for broken internal links, internal links must be fixed ' + + 'properly to tame the tech debt snowball no one wants to deal with; the ' + + '"Check links" PR check comment lists the links this PR broke, if any.', + '', + linkTo(`**\`${REDIRECTS_PATH}\`**`, fileUrl) + ]; + // One section per problem with its fix. Each entry is shown as it appears + // in the redirects file, so it is easy to find there. + const fixes = new Map( + Object.values(PROBLEM).map(({heading, fix}) => [heading, fix]) + ); + for (const [problem, entries] of groupByProblem(findings)) { + lines.push('', `#### ${problem}`, '', fixes.get(problem), ''); + for (const finding of entries) { + const {source, destination, line, detail, fix} = finding; + const where = line + ? linkTo(`line ${line}`, fileUrl && `${fileUrl}#L${line}`) + : 'entry'; + lines.push( + `- ${where}`, + ' ```ts', + ` source: '${source}',`, + ` destination: '${destination}'`, + ...(detail ? [` final destination: ${detail}`] : []), + ' ```', + ...(fix ? [` Fix: ${describeFix(finding)}`] : []) + ); + } + } + lines.push( + '', + 'Reproduce locally with `node dev/check-redirects.mjs`' + ); + return lines.join('\n') + '\n'; +} + +// Line numbers `git diff` output added to redirects.ts +function addedLines(diffFile) { + const added = new Set(); + let inRedirects = false; + let lineNumber; + for (const line of fs.readFileSync(diffFile, 'utf-8').split('\n')) { + if (line.startsWith('+++ ')) { + inRedirects = line === `+++ b/${REDIRECTS_PATH}`; + } else if (line.startsWith('@@ ')) { + lineNumber = Number(line.match(/^@@ -\S+ \+(\d+)/)[1]); + } else if (inRedirects && line.startsWith('+')) { + added.add(lineNumber++); + } else if (inRedirects && line.startsWith(' ')) { + lineNumber++; + } + } + return added; +} + +// First line of a review comment, so the workflow can match the comments it +// posted earlier to the findings still present and delete the rest +const REVIEW_MARKER = '`, + `Problem: ${problem}`, + `Fix: ${describeFix(finding)}`, + '````suggestion', + ...entry, + '````' + ]; + return { + path: REDIRECTS_PATH, + ...(startLine !== endLine && {start_line: startLine, start_side: 'RIGHT'}), + line: endLine, + side: 'RIGHT', + body: body.join('\n') + }; + }); + return {event: 'COMMENT', body: '', comments}; +} + +const FORMATTERS = { + text: formatText, + json: findings => JSON.stringify(findings, null, '\t') + '\n', + markdown: formatMarkdown +}; + +function main() { + const format = FORMATTERS[FORMAT]; + if (!format) { + throw new Error( + `Unknown --format "${FORMAT}"; use text, json, or markdown` + ); + } + + let findings = findBrokenRedirects(loadRedirects(), buildHeadingsByRoute()); + if (BASELINE_FILE) { + findings = withoutBaseline(findings, BASELINE_FILE); + } + + if (REVIEW_FILE) { + const added = DIFF_FILE ? addedLines(DIFF_FILE) : new Set(); + fs.writeFileSync( + REVIEW_FILE, + JSON.stringify(reviewRequest(findings, added), null, '\t') + '\n' + ); + } + + // Not process.exit(): that can truncate stdout when it is a pipe + process.stdout.write(format(findings)); + process.exitCode = findings.length === 0 ? 0 : 1; +} + +main(); diff --git a/dev/post-spelling-review.mjs b/dev/post-spelling-review.mjs index 94eacb399..ef41020d5 100644 --- a/dev/post-spelling-review.mjs +++ b/dev/post-spelling-review.mjs @@ -258,6 +258,14 @@ async function syncInlineComments(findings) { continue; } if (wanted.has(key)) { + const body = inlineBody(wanted.get(key)); + if (body !== comment.body) { + await githubWrite( + 'PATCH', + `/repos/${REPOSITORY}/pulls/comments/${comment.id}`, + {body} + ); + } wanted.delete(key); } else { await githubWrite( diff --git a/dev/report-vercel-build.mjs b/dev/report-vercel-build.mjs new file mode 100644 index 000000000..3f9f16f10 --- /dev/null +++ b/dev/report-vercel-build.mjs @@ -0,0 +1,198 @@ +#!/usr/bin/env node + +/** + * Reports a failed Vercel build on its pull request, since Vercel only shows + * build logs to members of the Vercel team. When a later revision builds, the + * same comment is updated to say so. + * + * Usage: node dev/report-vercel-build.mjs [--dry-run] + * + * Requires DEPLOYMENT_ID, DEPLOYMENT_STATE (error or success), COMMIT_SHA, + * GH_TOKEN and GITHUB_REPOSITORY. A failed build also needs VERCEL_TOKEN, and + * VERCEL_TEAM_ID unless the token is scoped to the project. + * With --dry-run the comment is printed instead of posted. + */ + +const DRY_RUN = process.argv.includes('--dry-run'); +const MAX_LOG_LINES = 100; +const MAX_LOG_CHARS = 30_000; + +const API_URL = process.env.GITHUB_API_URL ?? 'https://api.github.com'; +const REPOSITORY = process.env.GITHUB_REPOSITORY; +const {DEPLOYMENT_ID, DEPLOYMENT_STATE, COMMIT_SHA} = process.env; + +const MARKER = ''; + +async function fetchJson(url, headers) { + const response = await fetch(url, {headers}); + if (!response.ok) { + throw new Error( + `GET ${url} failed: ${response.status} ${await response.text()}` + ); + } + return response.json(); +} + +async function github(method, route, body) { + const response = await fetch(`${API_URL}${route}`, { + method, + headers: { + authorization: `Bearer ${process.env.GH_TOKEN}`, + accept: 'application/vnd.github+json', + 'x-github-api-version': '2022-11-28', + ...(body && {'content-type': 'application/json'}) + }, + body: body && JSON.stringify(body) + }); + if (!response.ok) { + throw new Error( + `${method} ${route} failed: ${response.status} ${await response.text()}` + ); + } + return response.json(); +} + +async function githubList(route) { + const items = []; + for (let page = 1; ; page++) { + const batch = await github('GET', `${route}?per_page=100&page=${page}`); + items.push(...batch); + if (batch.length < 100) { + return items; + } + } +} + +// The dispatch payload has no PR number; look it up from the commit. A stale +// event for a commit the PR has moved past is ignored. Fork PRs are ignored +// too, so the Vercel token is only ever used for commits by people who can +// already push to this repository. +async function findPullRequest() { + const pulls = await github( + 'GET', + `/repos/${REPOSITORY}/commits/${COMMIT_SHA}/pulls` + ); + const pull = pulls.find( + pull => pull.state === 'open' && pull.head.sha === COMMIT_SHA + ); + if (pull && pull.head.repo.full_name !== REPOSITORY) { + console.log(`PR #${pull.number} is from a fork; not reporting`); + return undefined; + } + return pull; +} + +// Build log lines, oldest first. Vercel keeps them as events; only the ones +// with text are log lines. +async function fetchBuildLog() { + const url = new URL( + `https://api.vercel.com/v3/deployments/${DEPLOYMENT_ID}/events` + ); + url.searchParams.set('limit', '-1'); + url.searchParams.set('direction', 'forward'); + if (process.env.VERCEL_TEAM_ID) { + url.searchParams.set('teamId', process.env.VERCEL_TEAM_ID); + } + const events = await fetchJson(url, { + authorization: `Bearer ${process.env.VERCEL_TOKEN}` + }); + return events + .map(event => event.payload?.text ?? event.text) + .filter(text => typeof text === 'string') + .flatMap(text => text.replace(/\n$/, '').split('\n')); +} + +// The failure is at the end of the log; keep the tail within GitHub's comment +// size limit. A four-backtick fence so lines containing ``` cannot break out. +function failureBody(logLines) { + let tail = logLines.slice(-MAX_LOG_LINES); + while (tail.length > 1 && tail.join('\n').length > MAX_LOG_CHARS) { + tail = tail.slice(1); + } + const omitted = logLines.length - tail.length; + return [ + MARKER, + '### āŒ The Vercel build failed for this PR', + '', + 'Vercel only shows build logs to members of its team, so here is the end of the log.', + 'Run `npm run build` locally to reproduce.', + '', + '
', + `Build log${omitted > 0 ? ` (last ${tail.length} of ${logLines.length} lines)` : ''}`, + '', + '````', + ...tail, + '````', + '', + '
', + '' + ].join('\n'); +} + +async function main() { + for (const name of [ + 'DEPLOYMENT_ID', + 'DEPLOYMENT_STATE', + 'COMMIT_SHA', + 'GH_TOKEN', + 'GITHUB_REPOSITORY' + ]) { + if (!process.env[name]) { + throw new Error(`Missing required environment variable ${name}`); + } + } + if (!['error', 'success'].includes(DEPLOYMENT_STATE)) { + throw new Error(`Unexpected DEPLOYMENT_STATE ${DEPLOYMENT_STATE}`); + } + + const pull = await findPullRequest(); + if (!pull) { + console.log(`No open PR with head ${COMMIT_SHA}; nothing to do`); + return; + } + + const comments = await githubList( + `/repos/${REPOSITORY}/issues/${pull.number}/comments` + ); + const existing = comments.find(comment => comment.body.startsWith(MARKER)); + + // Comment only when the build failed, or an earlier failure is resolved + let body; + if (DEPLOYMENT_STATE === 'error') { + if (!process.env.VERCEL_TOKEN) { + throw new Error('VERCEL_TOKEN is required to read the build log'); + } + body = failureBody(await fetchBuildLog()); + } else if (existing) { + body = `${MARKER}\n### āœ… The Vercel build that failed on an earlier revision of this PR passes\n`; + } else { + console.log(`PR #${pull.number} has no failed build to resolve`); + return; + } + + if (DRY_RUN) { + console.log( + `[dry-run] would ${existing ? 'update' : 'create'} comment on PR #${pull.number}:\n` + ); + console.log(body); + } else if (existing) { + console.log(`Updating comment ${existing.id} on PR #${pull.number}`); + await github( + 'PATCH', + `/repos/${REPOSITORY}/issues/comments/${existing.id}`, + {body} + ); + } else { + console.log(`Commenting on PR #${pull.number}`); + await github( + 'POST', + `/repos/${REPOSITORY}/issues/${pull.number}/comments`, + {body} + ); + } +} + +main().catch(error => { + console.error(error); + process.exit(2); +}); diff --git a/dev/sync-review-comments.sh b/dev/sync-review-comments.sh new file mode 100755 index 000000000..6ecd7b806 --- /dev/null +++ b/dev/sync-review-comments.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env sh +# Make a check's suggested-change review comments on a PR match a review.json +# ({comments: [{path, line, start_line?, body}]}): post the new ones, update +# the ones whose text changed, and delete the ones whose finding is gone. +# GitHub sets line to null on comments it could not carry to the new revision, +# so those are deleted too. Comments are matched by file, line, and the +# marker comment on their first line, e.g. "". +# +# Usage: dev/sync-review-comments.sh '