diff --git a/.github/workflows/spellcheck-allow.yml b/.github/workflows/spellcheck-allow.yml new file mode 100644 index 000000000..717f1e002 --- /dev/null +++ b/.github/workflows/spellcheck-allow.yml @@ -0,0 +1,135 @@ +name: Spell check allow list + +# Adds words to cspell-allow-list.txt when someone with write access comments +# `/spell ` (or `/spell allow `) on a pull request, in the +# conversation or on a review comment. The words are committed to the pull +# request's branch and the command comment gets a 👍. Words CSpell already +# accepts are left out, and the comment gets a reply instead when nothing was +# added. +# +# A push made with GITHUB_TOKEN creates the pull_request runs for the new +# revision in an approval-required state, so this workflow approves them and +# the spell check (and the other checks) report on the new revision. +# +# GITHUB_TOKEN cannot push to a fork, so on a fork's pull request the command +# replies that the words must be added by hand. Any other problem is replied +# with a link to the job log. + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +permissions: + actions: write # approves the pull_request runs the push creates + contents: write + pull-requests: write + +# Commands on the same pull request run one after another, so their pushes do +# not race each other +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }} + +jobs: + allow: + name: Add words to the allow list + # issue_comment fires for issues too; only pull requests have the + # pull_request key + if: >- + (github.event.issue.pull_request || github.event.pull_request) + && (startsWith(github.event.comment.body, '/spell ') || github.event.comment.body == '/spell') + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + steps: + - name: Check out the pull request head + uses: actions/checkout@v4 + with: + ref: refs/pull/${{ env.PR_NUMBER }}/head + + # Node 24 is pre-cached on ubuntu-latest, avoiding the download + # incurred by node-version: latest. + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + # dev/allow-spelling-words.mjs asks CSpell which words it already accepts + - name: Install CSpell + run: npm install --global cspell@10 + + - name: Add the words, push them, and approve the checks on the new revision + # The comment comes in through env, never inline in the script, + # so a crafted comment cannot inject shell commands + env: + COMMENTER: ${{ github.event.comment.user.login }} + COMMENT_BODY: ${{ github.event.comment.body }} + # Reactions on conversation and review comments have different routes + REACTIONS_ROUTE: >- + ${{ github.event_name == 'issue_comment' + && format('issues/comments/{0}/reactions', github.event.comment.id) + || format('pulls/comments/{0}/reactions', github.event.comment.id) }} + run: | + reply() { + gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$1" --silent + } + + permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$COMMENTER/permission" --jq .permission) + if [ "$permission" != admin ] && [ "$permission" != write ]; then + reply "@$COMMENTER \`/spell\` needs write access to this repository." + exit 0 + fi + + head_ref=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .head.ref) + head_repo=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .head.repo.full_name) + if [ "$head_repo" != "$GITHUB_REPOSITORY" ]; then + reply "This pull request is from a fork, which \`/spell\` cannot push to. Please add the words to \`cspell-allow-list.txt\` yourself." + exit 0 + fi + + report=$(node dev/allow-spelling-words.mjs "$COMMENT_BODY") + echo "$report" + if git diff --quiet -- cspell-allow-list.txt; then + reply "$report" + exit 0 + fi + + added=$(printf '%s\n' "$report" | sed -n 's/^Added: //p' | tr -d '`') + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit --quiet -m "Add $added to cspell-allow-list.txt" -- cspell-allow-list.txt + # The author may have pushed since the checkout + git pull --quiet --rebase origin "$head_ref" + git push --quiet origin "HEAD:refs/heads/$head_ref" + gh api --method POST "repos/$GITHUB_REPOSITORY/$REACTIONS_ROUTE" -f content=+1 --silent + + # The runs appear a few seconds after the push, each needing + # its own approval. Keep looking until a sweep after the + # first approval finds nothing more. + approved= + for _ in 1 2 3 4 5 6 7 8 9; do + sleep 10 + runs=$(gh run list --repo "$GITHUB_REPOSITORY" --commit "$(git rev-parse HEAD)" \ + --status action_required --json databaseId --jq '.[].databaseId') + found= + for run in $runs; do + case " $approved " in *" $run "*) continue ;; esac + echo "Approving run $run" + gh api --method POST "repos/$GITHUB_REPOSITORY/actions/runs/$run/approve" --silent + approved="$approved $run" + found=1 + done + [ -n "$approved" ] && [ -z "$found" ] && break + done + if [ -z "$approved" ]; then + echo "::warning::No run to approve appeared, so the checks have not run on the new revision" + fi + + - name: Report the failure on the pull request + if: failure() + run: | + gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --silent \ + -f body="The \`/spell\` command failed; see the [job log]($RUN_URL)." diff --git a/AGENTS.md b/AGENTS.md index 5ff1a5567..491a24f54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,9 +52,12 @@ - **Spell check**: `.github/workflows/spellcheck.yml` runs `dev/check-spelling.mjs` on the lines a PR adds plus its title and description, and comments the findings (advisory, never fails the PR). - Add product names and identifiers to `cspell-allow-list.txt`, in - alphabetical order; the check reports out-of-order entries. CSpell is not a - project dependency: `npx cspell@10 --no-progress ` to run it locally + Add product names and identifiers to `cspell-allow-list.txt` with + `node dev/allow-spelling-words.mjs `, which keeps it in alphabetical + order (the check reports out-of-order entries), or comment `/spell ` + on the PR: `.github/workflows/spellcheck-allow.yml` commits them to the + branch and runs the check again. CSpell is not a project dependency: + `npx cspell@10 --no-progress ` to run it locally - **Example hostnames**: placeholder hostnames in docs are `*.example.com` (`sourcegraph.example.com` for the reader's instance, `github.example.com` for a code host, @@ -115,7 +118,8 @@ PR: a summary comment per check, plus an inline review comment with a `suggestion` block on each flagged line. After pushing, read them and follow the instructions they give (fix the spelling, the link, the redirect, or the hostname); do not work around a finding, and add a word to -`cspell-allow-list.txt` only when it is correct. +`cspell-allow-list.txt` only when it is correct (`/spell ` in a PR +comment does that for you). ```sh gh pr checks diff --git a/dev/allow-spelling-words.mjs b/dev/allow-spelling-words.mjs new file mode 100644 index 000000000..ac45974ff --- /dev/null +++ b/dev/allow-spelling-words.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node + +/** + * Adds words to cspell-allow-list.txt, in alphabetical order in its "# Words" + * section. CSpell decides what the words are, as it does when checking a + * document: it splits hyphenated, snake_case, and camelCase input into parts, + * and the parts it does not already accept, from its dictionaries or the + * allow list, are what gets added, so the list only grows by what it needs. + * Reports what was added and what was already accepted. Needs CSpell on the + * PATH: npm install --global cspell@10 + * + * Usage: node dev/allow-spelling-words.mjs + * + * The words may be separated by spaces or commas and wrapped in the backticks + * the spell check report puts around them. The text may also be the body of a + * `/spell ` or `/spell allow ` pull request comment, as sent by + * .github/workflows/spellcheck-allow.yml; only its first line is read. + */ + +import {spawnSync} from 'child_process'; +import {readFileSync, writeFileSync} from 'fs'; + +const ALLOW_LIST = 'cspell-allow-list.txt'; +const WORDS_SECTION = '# Words'; + +// The order dev/check-spelling.mjs enforces, which is also how CSpell matches +const collator = new Intl.Collator('en', {sensitivity: 'base'}); + +const unique = words => + words.filter( + (word, index) => + words.findIndex(other => collator.compare(other, word) === 0) === + index + ); + +function requestedWords(text) { + return unique( + text + .split('\n')[0] + .replace(/^\s*\/spell(\s+allow)?(?=\s|$)/, '') + .split(/[\s,]+/) + .map(word => word.replace(/^[`'"]+|[`'"]+$/g, '')) + .filter(Boolean) + ); +} + +// The parts CSpell flags in each input, one input per line, under the +// repository's cspell.json. An input with no flagged part is accepted as is. +function flaggedParts(inputs) { + if (inputs.length === 0) { + return new Map(); + } + const result = spawnSync( + 'cspell', + [ + '--no-progress', + '--reporter', + '@cspell/cspell-json-reporter', + 'stdin' + ], + {encoding: 'utf8', input: inputs.join('\n') + '\n'} + ); + if (result.error) { + throw result.error; + } + const report = JSON.parse(result.stdout); + if (![0, 1].includes(result.status) || report.error.length > 0) { + throw new Error(result.stderr || JSON.stringify(report.error)); + } + const parts = new Map(inputs.map(input => [input, []])); + for (const issue of report.issues) { + parts.get(inputs[issue.row - 1]).push(issue.text); + } + return parts; +} + +// An entry may carry a trailing comment, e.g. `argjson # jq option` +const entryOf = line => line.replace(/\s*#.*/, '').trim(); + +// Inserts the words into the "# Words" section, which runs from its heading to +// the next blank or comment line, keeping it sorted. Returns the new lines. +function withWords(lines, words) { + const start = lines.indexOf(WORDS_SECTION) + 1; + if (start === 0) { + throw new Error(`${ALLOW_LIST} has no "${WORDS_SECTION}" section`); + } + const result = [...lines]; + for (const word of words) { + let index = start; + while ( + index < result.length && + !/^\s*(#|$)/.test(result[index]) && + collator.compare(entryOf(result[index]), word) < 0 + ) { + index++; + } + result.splice(index, 0, word); + } + return result; +} + +function main() { + const parts = flaggedParts(requestedWords(process.argv.slice(2).join(' '))); + const added = unique([...parts.values()].flat()); + const alreadyAccepted = [...parts] + .filter(([, flagged]) => flagged.length === 0) + .map(([input]) => input); + + if (added.length > 0) { + const lines = readFileSync(ALLOW_LIST, 'utf8').split('\n'); + writeFileSync(ALLOW_LIST, withWords(lines, added).join('\n')); + } + + const report = [ + ['Added', added], + ['Already accepted', alreadyAccepted] + ].filter(([, words]) => words.length > 0); + if (report.length === 0) { + console.log('No words given.'); + } + for (const [label, words] of report) { + console.log(`${label}: ${words.map(word => `\`${word}\``).join(', ')}`); + } +} + +main(); diff --git a/dev/post-spelling-review.mjs b/dev/post-spelling-review.mjs index 15d30044c..109f1e711 100644 --- a/dev/post-spelling-review.mjs +++ b/dev/post-spelling-review.mjs @@ -168,11 +168,32 @@ function summaryBody(findings) { lines.push( `Please correct the spelling, or add words which are correct to ${ALLOW_LIST_LINK}.`, '', - "Run `npx cspell@10 --no-progress --dot '**/*'` locally to check the full repository." + `Comment on this PR with \`/spell \`, and a bot will push a commit to your branch adding your comment's list of words to ${ALLOW_LIST_LINK} for you.` ); + const words = unknownWords(findings); + if (words.length > 0) { + lines.push( + '', + `To accept all of the words above as correct: \`/spell ${words.join(' ')}\`` + ); + } return lines.join('\n') + '\n'; } +// The words not in the dictionary, each once, as a `/spell` command takes them +function unknownWords(findings) { + const words = []; + for (const {word, message} of findings) { + if ( + !message && + !words.some(seen => seen.toLowerCase() === word.toLowerCase()) + ) { + words.push(word); + } + } + return words; +} + function crashedBody() { return [ SUMMARY_MARKER, @@ -264,7 +285,9 @@ function inlineBody(finding) { `\`${finding.word}\` is not in the dictionary.`, '', ...suggestionBlock(finding), - `Please correct the spelling, or add the word to ${ALLOW_LIST_LINK} if it is correct.` + `Please correct the spelling, or add words which are correct to ${ALLOW_LIST_LINK}.`, + '', + `Comment on this PR with \`/spell ${finding.word}\`, and a bot will push a commit to your branch adding it to ${ALLOW_LIST_LINK} for you.` ]; return [`${INLINE_MARKER} ${finding.word} -->`, ...explanation].join('\n'); }