Make the clang-tidy Fix in Its Own PR-Scoped Workflow on Request

clang_tidy.yml is back to main's version. clang_tidy_fix.yml runs on
every PR push but skips its job on the first attempt; /bot fix-tidy
re-runs it, so run_clang_tidy.sh --fix runs in the PR's own context
with a read-only token and the PR's cache, never main's, and the PR's
author can ask for it. When that re-run finishes, the bot posts the
patch as review suggestions as before. When the clang-tidy step fails,
the bot also comments asking the author to run the script or the
command, edits that comment on later failures, and deletes it once the
check passes.
This commit is contained in:
Hanif Koh
2026-10-12 18:25:26 +08:00
parent f8b2ed0f93
commit 0d77d13f00
3 changed files with 278 additions and 114 deletions
+1 -29
View File
@@ -3,8 +3,7 @@ name: clang-tidy
# Runs clang-tidy, with the checks in .clang-tidy, over the C++ lines a pull
# request changes (scripts/clang_tidy_diff.py). The compile database is
# configured without the precompiled header, so code that only builds because
# the PCH supplied an include fails here. A failed check also uploads the
# missing-include fixes, which /bot fix-tidy (pr-tidy-bot.yml) posts on the PR.
# the PCH supplied an include fails here.
#
# No paths filter: the job is a required check, and a workflow skipped by a
# paths filter leaves a required check pending forever. A PR with no C++
@@ -84,34 +83,7 @@ jobs:
run: cmake --build build-tidy --target git_commit_hash_header
- name: Run clang-tidy on the changed lines
id: tidy
if: steps.changes.outputs.cpp == 'true'
run: >
python3 scripts/clang_tidy_diff.py -p build-tidy --base HEAD^1
--clang-tidy "$RUNNER_TEMP/clang-tidy/bin/clang-tidy" -j "$(nproc)"
# The fixes for a failed check, for /bot fix-tidy (pr-tidy-bot.yml) to post on
# the PR. They go into the patch only, so the check still fails. The status
# says whether findings remain that --fix cannot fix.
- name: Make the fix patch
id: fix
if: failure() && steps.tidy.outcome == 'failure'
run: |
mkdir -p "$RUNNER_TEMP/clang-tidy-fix"
status=0
python3 scripts/clang_tidy_diff.py -p build-tidy --base HEAD^1 \
--clang-tidy "$RUNNER_TEMP/clang-tidy/bin/clang-tidy" -j "$(nproc)" -- --fix > /dev/null || status=$?
echo "$status" > "$RUNNER_TEMP/clang-tidy-fix/status"
git -c core.quotePath=false diff --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ \
> "$RUNNER_TEMP/clang-tidy-fix/fix.patch"
if [ -s "$RUNNER_TEMP/clang-tidy-fix/fix.patch" ]; then
echo "patch=true" >> "$GITHUB_OUTPUT"
fi
- name: Upload the fix patch
if: failure() && steps.fix.outputs.patch == 'true'
uses: actions/upload-artifact@v7
with:
name: clang-tidy-fix
path: ${{ runner.temp }}/clang-tidy-fix/
retention-days: 30
+65
View File
@@ -0,0 +1,65 @@
name: clang-tidy fix
# Makes the missing-include fix that /bot fix-tidy (pr-tidy-bot.yml) posts on a
# pull request: scripts/run_clang_tidy.sh --fix on the merge commit the
# clang-tidy check runs on, uploaded as the clang-tidy-fix artifact.
#
# Every push to a PR starts this workflow, and its job skips on that first
# attempt. The bot re-runs it on request. A re-run keeps the pull_request
# context, so the PR's code runs with a read-only token, no secrets and the
# PR's own cache, never main's, whoever asked for it.
on:
pull_request:
branches:
- main
- release/*
permissions:
contents: read
jobs:
fix:
if: github.run_attempt > 1
# A hosted runner, so nothing the PR's code leaves behind outlives the job.
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v7
with:
lfs: 'false'
# The PR merge commit plus its first parent, the base it is diffed against.
fetch-depth: 2
persist-credentials: false
# Parsing needs the dependency headers, not a build of this PR's deps/, so
# a PR that changes deps/ falls back to the newest cache main has.
- name: Restore cached deps
uses: actions/cache/restore@v6
with:
path: ${{ github.workspace }}/deps/build/OrcaSlicer_dep
key: linux-clang-cache-orcaslicer_deps-build-${{ hashFiles('deps/**') }}
restore-keys: linux-clang-cache-orcaslicer_deps-build-
fail-on-cache-miss: true
- name: Apt-Install Dependencies
uses: ./.github/actions/apt-install-deps
# The status says whether findings remain that --fix cannot fix.
- name: Fix the changed lines
run: |
mkdir -p "$RUNNER_TEMP/clang-tidy-fix"
status=0
scripts/run_clang_tidy.sh --base HEAD^1 --yes --fix || status=$?
echo "$status" > "$RUNNER_TEMP/clang-tidy-fix/status"
git -c core.quotePath=false diff --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ \
> "$RUNNER_TEMP/clang-tidy-fix/fix.patch"
- name: Upload the fix
uses: actions/upload-artifact@v7
with:
name: clang-tidy-fix
path: ${{ runner.temp }}/clang-tidy-fix/
retention-days: 7
# A later re-run of the same run replaces the earlier fix.
overwrite: true
+212 -85
View File
@@ -1,57 +1,60 @@
name: PR clang-tidy Bot
# Posts the includes the clang-tidy check reports missing, on request, as a
# review the PR's author can apply.
# Helps a pull request past the clang-tidy check.
#
# Command, posted as a comment on the PR by its author or an account with write
# access:
# When the check fails on a PR's changes, a comment asks the author to fix it,
# either with scripts/run_clang_tidy.sh --fix or by commenting:
# /bot fix-tidy
#
# The fix comes from the clang-tidy check's own run on the PR's latest commit:
# when the check fails, clang_tidy.yml runs it again with --fix and uploads the
# result as the clang-tidy-fix artifact. Findings --fix cannot fix, such as a
# using in a header, are left for the check to report.
# That command, from the PR's author or an account with write access, re-runs
# the PR's clang-tidy fix run (clang_tidy_fix.yml), which makes the fix in the
# PR's own context. When it finishes, the fix is posted as one review: each
# include whose place falls on a line the PR's diff shows is a suggestion GitHub
# can commit in one click, and the review's body holds the whole patch with a
# git apply command. Findings --fix cannot fix, such as a using in a header,
# are left for the check to report.
#
# The reply is one review. Each include whose place falls on a line the PR's
# diff shows is a suggestion GitHub can commit in one click, and the review's
# body holds the whole patch with a git apply command for the rest. Nothing is
# pushed, and no code from the PR runs here: the patch is only accepted when
# every line it adds is an #include in a C++ file the PR already changes.
# No code from the PR runs here. Nothing is pushed, and the patch is only
# posted when every line it adds is an #include in a C++ file the PR changes.
#
# workflow_run matches the workflows by their name: keep "clang-tidy" and
# "clang-tidy fix" in step with clang_tidy.yml and clang_tidy_fix.yml, and
# TIDY_STEP below with the clang-tidy step's name in clang_tidy.yml.
on:
issue_comment:
types:
- created
workflow_run:
workflows:
- clang-tidy
- clang-tidy fix
types:
- completed
permissions: {}
jobs:
suggest:
request:
# Skips the job unless a PR comment mentions the command.
if: >-
github.repository == 'OrcaSlicer/OrcaSlicer'
&& github.event_name == 'issue_comment'
&& github.event.issue.pull_request != null
&& contains(github.event.comment.body, '/bot fix-tidy')
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
cancel-in-progress: false
permissions:
actions: read # find the check's run and download its patch
contents: read # check out the PR head
pull-requests: write # the review
actions: write # re-run the fix run
pull-requests: read # pulls.get
issues: write # feedback comment + reactions
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 5
steps:
- name: Find the check's fix
id: find
- name: Re-run the PR's clang-tidy fix
uses: actions/github-script@v9
with:
script: |
const MARKER = '<!-- pr-tidy-bot -->';
const CHECK_WORKFLOW = 'clang_tidy.yml';
const ARTIFACT = 'clang-tidy-fix';
const CPP = /^(src|tests)\/.+\.(cpp|cc|cxx|hpp|h|hxx)$/;
const FIX_WORKFLOW = 'clang_tidy_fix.yml';
const { owner, repo } = context.repo;
const issue = context.payload.issue;
@@ -84,11 +87,10 @@ jobs:
const react = (content) =>
github.rest.reactions.createForIssueComment({ owner, repo, comment_id: comment.id, content });
async function reply(body, content) {
await react(content);
await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: `${MARKER}\n@${commenter} ${body}` });
async function refuse(reason) {
await react('-1');
await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: `${MARKER}\n@${commenter} ${reason}` });
}
const refuse = (reason) => reply(reason, '-1');
await react('eyes');
@@ -100,33 +102,80 @@ jobs:
if (pr.state !== 'open') {
return refuse('the PR is not open.');
}
if (!pr.head.repo) {
return refuse('the PR\'s branch is in a repository that no longer exists.');
}
const sha = pr.head.sha.slice(0, 10);
const { data: { workflow_runs: runs } } = await github.rest.actions.listWorkflowRuns({
owner, repo, workflow_id: CHECK_WORKFLOW, event: 'pull_request', head_sha: pr.head.sha, per_page: 1
owner, repo, workflow_id: FIX_WORKFLOW, event: 'pull_request', head_sha: pr.head.sha, per_page: 1
});
const run = runs[0];
if (!run) {
return refuse(`the clang-tidy check has not run on ${sha}, the PR's latest commit.`);
return refuse(`the PR's checks have not started on ${sha} yet. Comment \`/bot fix-tidy\` again once they have.`);
}
if (run.status !== 'completed') {
return refuse(`the clang-tidy check is still running on ${sha}. Comment \`/bot fix-tidy\` again once it finishes.`);
// The first attempt is the one every push starts, and it makes no fix.
return refuse(run.run_attempt > 1
? `a clang-tidy fix for ${sha} is already [running](${run.html_url}). I'll post it when it finishes.`
: `the PR's checks on ${sha} are still starting. Comment \`/bot fix-tidy\` again in a minute.`);
}
if (run.conclusion === 'success') {
return reply(`the clang-tidy check passes on ${sha}. There is nothing to fix.`, '+1');
}
const { data: { artifacts } } = await github.rest.actions.listWorkflowRunArtifacts({
owner, repo, run_id: run.id, name: ARTIFACT
await github.rest.actions.reRunWorkflow({ owner, repo, run_id: run.id });
core.info(`Re-ran ${run.html_url}`);
suggest:
# The fix run's re-runs only. Its first attempt makes no fix.
if: >-
github.repository == 'OrcaSlicer/OrcaSlicer'
&& github.event_name == 'workflow_run'
&& github.event.workflow_run.name == 'clang-tidy fix'
&& github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.run_attempt > 1
concurrency:
group: ${{ github.workflow }}-suggest-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
permissions:
actions: read # download the fix
contents: read # check out the PR head
pull-requests: write # the review
issues: write # feedback comment + reactions
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Find the PR and who asked
id: find
uses: actions/github-script@v9
with:
script: |
const MARKER = '<!-- pr-tidy-bot -->';
const CPP = /^(src|tests)\/.+\.(cpp|cc|cxx|hpp|h|hxx)$/;
const { owner, repo } = context.repo;
const run = context.payload.workflow_run;
// workflow_run lists no PR for a fork's branch, so the PR is found by its branch.
const { data: prs } = await github.rest.pulls.list({
owner, repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}`
});
if (!artifacts.some((a) => !a.expired)) {
return refuse(
run.conclusion === 'failure'
? `clang-tidy has no automatic fix for what the check reports on ${sha}. The findings, in the [check's log](${run.html_url}), need a manual edit.`
: `the clang-tidy check on ${sha} did not finish (${run.conclusion}). Re-run it, then comment \`/bot fix-tidy\` again.`
);
const pr = prs[0];
if (!pr) {
core.info('No open PR for this run.');
return;
}
// The latest /bot fix-tidy comment is the one this run answers.
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 });
const request = comments.reverse().find((c) => c.user.type === 'User' && /^\s*\/bot\s+fix-tidy\s*$/im.test(c.body || ''));
const mention = request ? `@${request.user.login} ` : '';
async function refuse(reason) {
if (request) {
await github.rest.reactions.createForIssueComment({ owner, repo, comment_id: request.id, content: '-1' });
}
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body: `${MARKER}\n${mention}${reason}` });
}
if (pr.head.sha !== run.head_sha) {
return refuse('the PR changed while I was making its clang-tidy fix. Comment `/bot fix-tidy` again.');
}
if (run.conclusion !== 'success') {
return refuse(`I couldn't make the clang-tidy fix. See the [run log](${run.html_url}).`);
}
// The right-side lines of each changed C++ file's diff: the lines a
@@ -143,24 +192,25 @@ jobs:
.filter(([first, last]) => last >= first);
}
core.setOutput('run-id', run.id);
core.setOutput('run-url', run.html_url);
core.setOutput('pr', pr.number);
core.setOutput('head-sha', pr.head.sha);
core.setOutput('head-repo', pr.head.repo.full_name);
core.setOutput('head-repo', run.head_repository.full_name);
core.setOutput('ranges', JSON.stringify(ranges));
core.setOutput('mention', mention);
core.setOutput('request-id', request ? request.id : '');
- name: Download the fix
if: steps.find.outputs.run-id != ''
if: steps.find.outputs.pr != ''
uses: actions/download-artifact@v8
with:
name: clang-tidy-fix
run-id: ${{ steps.find.outputs.run-id }}
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}/clang-tidy-fix
# The PR's own files, only to apply the patch to. Nothing in them is run.
- name: Checkout the PR head
if: steps.find.outputs.run-id != ''
if: steps.find.outputs.pr != ''
uses: actions/checkout@v7
with:
repository: ${{ steps.find.outputs.head-repo }}
@@ -173,11 +223,11 @@ jobs:
src
tests
# The check made the patch on the PR's merge commit, so it is applied to the
# head and diffed again, giving line numbers in the PR's own files.
# The fix was made on the PR's merge commit, so it is applied to the head
# and diffed again, giving line numbers in the PR's own files.
- name: Check the fix and place its suggestions
id: place
if: steps.find.outputs.run-id != ''
if: steps.find.outputs.pr != ''
env:
RANGES: ${{ steps.find.outputs.ranges }}
run: |
@@ -192,6 +242,11 @@ jobs:
for key, value in values.items():
f.write(f"{key}={value}\n")
patch = open(patch_path, encoding="utf-8").read()
if not patch.strip():
output(result="empty")
sys.exit()
# Every line the patch adds must be an #include or blank, in a C++ file the
# PR changes, and it may not remove, create, rename or change the mode of
# anything.
@@ -199,7 +254,7 @@ jobs:
header = re.compile(r"(index [0-9a-f]+\.\.[0-9a-f]+( \d+)?|@@ -\d+(,\d+)? \+\d+(,\d+)? @@.*)")
problems = []
path = None
for line in open(patch_path, encoding="utf-8").read().splitlines():
for line in patch.splitlines():
if line.startswith("diff --git "):
match = re.fullmatch(r"diff --git a/(.+) b/\1", line)
path = match.group(1) if match else None
@@ -264,14 +319,16 @@ jobs:
EOF
- name: Post the fix
if: always() && steps.find.outputs.run-id != ''
if: always() && steps.find.outputs.pr != ''
uses: actions/github-script@v9
env:
PR: ${{ steps.find.outputs.pr }}
MENTION: ${{ steps.find.outputs.mention }}
REQUEST_ID: ${{ steps.find.outputs.request-id }}
HEAD_SHA: ${{ steps.find.outputs.head-sha }}
RESULT: ${{ steps.place.outputs.result }}
INCLUDES: ${{ steps.place.outputs.includes }}
SUGGESTED: ${{ steps.place.outputs.suggested }}
HEAD_SHA: ${{ steps.find.outputs.head-sha }}
CHECK_URL: ${{ steps.find.outputs.run-url }}
with:
script: |
const fs = require('fs');
@@ -281,34 +338,40 @@ jobs:
const MAX_PATCH = 60000;
const { owner, repo } = context.repo;
const issue = context.payload.issue;
const comment = context.payload.comment;
const commenter = comment.user.login;
const { RESULT, INCLUDES, SUGGESTED, HEAD_SHA, CHECK_URL, RUNNER_TEMP } = process.env;
const { PR, MENTION, REQUEST_ID, HEAD_SHA, RESULT, INCLUDES, SUGGESTED, RUNNER_TEMP } = process.env;
const issue_number = Number(PR);
const sha = HEAD_SHA.slice(0, 10);
const fixUrl = context.payload.workflow_run.html_url;
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
const react = (content) =>
github.rest.reactions.createForIssueComment({ owner, repo, comment_id: comment.id, content });
async function refuse(reason) {
await react('-1');
await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: `${MARKER}\n@${commenter} ${reason}` });
}
if (RESULT === 'rejected') {
return refuse(`the check's fix changes more than includes, so I didn't post it. See the [run log](${runUrl}).`);
}
if (RESULT === 'apply-failed') {
return refuse(`the check's fix doesn't apply to ${sha}. Merge the base branch into the PR, then comment \`/bot fix-tidy\` again once the check has run.`);
}
if (RESULT !== 'ok') {
return refuse(`something went wrong. See the [run log](${runUrl}).`);
const react = (content) => REQUEST_ID
? github.rest.reactions.createForIssueComment({ owner, repo, comment_id: Number(REQUEST_ID), content })
: Promise.resolve();
async function reply(body, content) {
await react(content);
await github.rest.issues.createComment({ owner, repo, issue_number, body: `${MARKER}\n${MENTION}${body}` });
}
const read = (...parts) => fs.readFileSync(path.join(RUNNER_TEMP, ...parts), 'utf8');
const passes = RESULT && read('clang-tidy-fix', 'status').trim() === '0';
if (RESULT === 'empty') {
return passes
? reply(`clang-tidy passes on ${sha}. There is nothing to fix.`, '+1')
: reply(`clang-tidy has no automatic fix for what it reports on ${sha}. The findings, in the [fix run's log](${fixUrl}), need a manual edit.`, '-1');
}
if (RESULT === 'rejected') {
return reply(`the clang-tidy fix changes more than includes, so I didn't post it. See the [run log](${runUrl}).`, '-1');
}
if (RESULT === 'apply-failed') {
return reply(`the clang-tidy fix doesn't apply to ${sha}. Merge the base branch into the PR, then comment \`/bot fix-tidy\` again.`, '-1');
}
if (RESULT !== 'ok') {
return reply(`something went wrong. See the [run log](${runUrl}).`, '-1');
}
const patch = read('head.patch');
const suggestions = JSON.parse(read('suggestions.json'));
const passes = read('clang-tidy-fix', 'status').trim() === '0';
const one = INCLUDES === '1';
const includes = one ? '1 missing include' : `${INCLUDES} missing includes`;
@@ -319,17 +382,17 @@ jobs:
? (one ? 'It is suggested inline below.' : 'Each is suggested inline below.')
: `${SUGGESTED} of them are suggested inline below; the rest go on lines this PR does not change.`;
const remaining = passes
? `With ${one ? 'it' : 'them'}, the check passes.`
: `Some findings remain that need a manual edit; see the [check's log](${CHECK_URL}).`;
? `With ${one ? 'it' : 'them'}, clang-tidy passes.`
: `Some findings remain that need a manual edit; see the [fix run's log](${fixUrl}).`;
// A fence longer than any run of backticks in the patch's context lines.
const fence = '`'.repeat(Math.max(3, ...[...patch.matchAll(/`+/g)].map((m) => m[0].length + 1)));
const apply = patch.length > MAX_PATCH
? `The full patch is too large to show here. Download \`fix.patch\` from the [check's run](${CHECK_URL}) and run \`git apply fix.patch\`.`
? `The full patch is too large to show here. Download \`fix.patch\` from the [fix run](${fixUrl}) and run \`git apply fix.patch\`.`
: `To apply ${one ? 'it' : 'all of them'}, run this in your checkout of the PR branch:\n\n${fence}sh\ngit apply <<'EOF'\n${patch}EOF\n${fence}`;
const body = (withSuggestions) => `${MARKER}\n@${commenter} clang-tidy's fix for ${sha} adds ${includes}. ` +
const body = (withSuggestions) => `${MARKER}\n${MENTION}clang-tidy's fix for ${sha} adds ${includes}. ` +
`${withSuggestions ? `${inline} ` : ''}${remaining}\n\n${apply}`;
const review = { owner, repo, pull_number: issue.number, commit_id: HEAD_SHA, event: 'COMMENT' };
const review = { owner, repo, pull_number: issue_number, commit_id: HEAD_SHA, event: 'COMMENT' };
try {
await github.rest.pulls.createReview({ ...review, body: body(true), comments: suggestions });
} catch (error) {
@@ -338,3 +401,67 @@ jobs:
await github.rest.pulls.createReview({ ...review, body: body(false) });
}
await react('+1');
nudge:
if: >-
github.repository == 'OrcaSlicer/OrcaSlicer'
&& github.event_name == 'workflow_run'
&& github.event.workflow_run.name == 'clang-tidy'
&& github.event.workflow_run.event == 'pull_request'
concurrency:
group: ${{ github.workflow }}-nudge-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
permissions:
actions: read # the check's steps
pull-requests: read # find the PR
issues: write # the comment
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Ask for a fix when the check fails
uses: actions/github-script@v9
with:
script: |
const MARKER = '<!-- pr-tidy-bot-nudge -->';
// The clang-tidy step's name in clang_tidy.yml. A failure in any other
// step is the CI's, not the PR's.
const TIDY_STEP = 'Run clang-tidy on the changed lines';
const { owner, repo } = context.repo;
const run = context.payload.workflow_run;
// workflow_run lists no PR for a fork's branch, so the PR is found by its branch.
const { data: prs } = await github.rest.pulls.list({
owner, repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}`
});
const pr = prs.find((p) => p.head.sha === run.head_sha);
if (!pr) {
core.info('No open PR at this run\'s commit; a newer run will answer.');
return;
}
const { data: { jobs } } = await github.rest.actions.listJobsForWorkflowRun({ owner, repo, run_id: run.id });
const failed = jobs.some((job) => (job.steps || []).some((step) => step.name === TIDY_STEP && step.conclusion === 'failure'));
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 });
const previous = comments.find((c) => c.user.type === 'Bot' && (c.body || '').startsWith(MARKER));
if (!failed) {
// Removes the request once the check passes.
if (previous && run.conclusion === 'success') {
await github.rest.issues.deleteComment({ owner, repo, comment_id: previous.id });
}
return;
}
const body = `${MARKER}\n@${pr.user.login} the clang-tidy check found problems in this PR's changes ` +
`at ${run.head_sha.slice(0, 10)} ([log](${run.html_url})). Missing includes can be fixed either way:\n\n` +
'- run `scripts/run_clang_tidy.sh --fix` (`scripts\\run_clang_tidy.ps1 -Fix` on Windows) and push the result, or\n' +
'- comment `/bot fix-tidy`, and I\'ll suggest the fix here for you to apply.\n\n' +
'Other findings, such as a `using` in a header, need a manual edit.';
// Edits the earlier request instead of posting another, so each push does not notify again.
if (previous) {
await github.rest.issues.updateComment({ owner, repo, comment_id: previous.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
}