Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions .github/workflows/spellcheck-allow.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
name: Spell check allow list

# Adds words to cspell-allow-list.txt when someone with write access comments
# `/spell <words>` (or `/spell allow <words>`) 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)."
12 changes: 8 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` to run it locally
Add product names and identifiers to `cspell-allow-list.txt` with
`node dev/allow-spelling-words.mjs <words>`, which keeps it in alphabetical
order (the check reports out-of-order entries), or comment `/spell <words>`
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 <file>` 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,
Expand Down Expand Up @@ -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 <words>` in a PR
comment does that for you).

```sh
gh pr checks <pr>
Expand Down
126 changes: 126 additions & 0 deletions dev/allow-spelling-words.mjs
Original file line number Diff line number Diff line change
@@ -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 <words…>
*
* 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 <words>` or `/spell allow <words>` 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();
27 changes: 25 additions & 2 deletions dev/post-spelling-review.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <list of words>\`, 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,
Expand Down Expand Up @@ -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');
}
Expand Down
Loading