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 '