Add a /bot fix-tidy Command That Pushes clang-tidy's Missing Includes

A maintainer comments /bot fix-tidy on a PR. A read-only job runs
scripts/run_clang_tidy.sh --fix on the PR's merge commit and returns the
patch; a second job, which never runs the PR's code, accepts it only when
it adds nothing but #include lines to C++ files the PR already changes,
then commits it on the PR head and pushes with CLANG_TIDY_BOT_TOKEN.
This commit is contained in:
Hanif Koh
2026-10-12 17:29:38 +08:00
parent d2675f15d1
commit b10cd14372
+321
View File
@@ -0,0 +1,321 @@
name: PR clang-tidy Bot
# Adds the includes the clang-tidy check reports missing, on request, as a
# commit pushed to the pull request's branch.
#
# Command, posted as a comment on the PR by an account with write access:
# /bot fix-tidy
#
# The fix job runs scripts/run_clang_tidy.sh --fix, as a contributor would
# locally, on the merge commit the clang-tidy check runs on: clang-tidy's
# missing-include fixes on the lines the PR changes. Other findings, such as a
# using in a header, need a manual edit and are left for the check to report.
#
# The PR's code runs only in the fix job, which has a read-only token and no
# secrets and hands back a patch. The push job never runs that code. It accepts
# the patch only when every line it adds is an #include in a C++ file the PR
# already changes, then commits it on the PR head and pushes. The fix job still
# runs the PR's build scripts, so the command is limited to accounts with write
# access, who are expected to have read the PR first.
#
# Pushing needs the CLANG_TIDY_BOT_TOKEN repository secret: a classic personal
# access token with the public_repo scope, of an account with write access.
# GITHUB_TOKEN cannot push to a fork, and a push made with it does not start the
# checks again. A fork's branch can only be pushed to when its PR allows edits
# by maintainers.
on:
issue_comment:
types:
- created
permissions: {}
jobs:
authorize:
# Skips the job unless a PR comment mentions the command.
if: >-
github.repository == 'OrcaSlicer/OrcaSlicer'
&& github.event.issue.pull_request != null
&& contains(github.event.comment.body, '/bot fix-tidy')
permissions:
pull-requests: read # pulls.get, pulls.listFiles
issues: write # feedback comment + reactions
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
head-sha: ${{ steps.check.outputs.head-sha }}
head-repo: ${{ steps.check.outputs.head-repo }}
head-ref: ${{ steps.check.outputs.head-ref }}
files: ${{ steps.check.outputs.files }}
steps:
- name: Check the command and the PR
id: check
uses: actions/github-script@v9
env:
HAS_TOKEN: ${{ secrets.CLANG_TIDY_BOT_TOKEN != '' }}
with:
script: |
const MARKER = '<!-- pr-tidy-bot -->';
const LOCAL_FIX = 'run `scripts/run_clang_tidy.sh --fix` (`scripts\\run_clang_tidy.ps1 -Fix` on Windows) and push the result';
const { owner, repo } = context.repo;
const issue = context.payload.issue;
const comment = context.payload.comment;
// Ignores a comment whose sender is not its author.
if (context.payload.sender.login !== comment.user.login || comment.user.type !== 'User') {
core.info('Ignoring a comment not written by its sender, or by a bot.');
return;
}
const commandLine = (comment.body || '')
.split('\n')
.map((line) => line.trim())
.find((line) => /^\/bot\s+fix-tidy\b/i.test(line));
if (!commandLine) {
core.info('No /bot fix-tidy command found.');
return;
}
const commenter = comment.user.login;
const react = (content) =>
github.rest.reactions.createForIssueComment({ owner, repo, comment_id: comment.id, content });
const say = (body) =>
github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: `${MARKER}\n@${commenter} ${body}` });
async function refuse(reason) {
core.warning(`Refused /bot fix-tidy from @${commenter}: ${reason}`);
await react('-1');
await say(reason);
}
const { data: access } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: commenter });
if (!['admin', 'write'].includes(access.permission)) {
// Answers only the PR's author, so the command cannot be used to spam.
if (commenter === issue.user.login) {
return refuse(`only maintainers can run \`/bot fix-tidy\`. Ask one to, or ${LOCAL_FIX}.`);
}
core.info(`Ignoring /bot fix-tidy from @${commenter}: no write access.`);
return;
}
await react('eyes');
if (!/^\/bot\s+fix-tidy$/i.test(commandLine)) {
return refuse('`/bot fix-tidy` takes no arguments.');
}
if (process.env.HAS_TOKEN !== 'true') {
return refuse('I can\'t push without the `CLANG_TIDY_BOT_TOKEN` secret, which is not set up.');
}
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue.number });
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 fork = pr.head.repo.full_name !== `${owner}/${repo}`;
if (fork && !pr.maintainer_can_modify) {
return refuse(`I can't push to this PR's branch because it does not allow edits by maintainers. Turn that on, or ${LOCAL_FIX}.`);
}
// The fix is made on the merge commit, which a conflict leaves out of date.
if (pr.mergeable === false) {
return refuse('the PR conflicts with its base branch. Resolve that first.');
}
const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 });
core.setOutput('head-sha', pr.head.sha);
core.setOutput('head-repo', pr.head.repo.full_name);
core.setOutput('head-ref', pr.head.ref);
core.setOutput('files', JSON.stringify(files.filter((f) => f.status !== 'removed').map((f) => f.filename)));
fix:
needs: authorize
if: needs.authorize.outputs.head-sha != ''
# A newer command for the same PR supersedes this one.
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
cancel-in-progress: true
permissions:
contents: read
runs-on: ${{ vars.SELF_HOSTED && 'orca-lnx-server' || 'ubuntu-24.04' }}
timeout-minutes: 60
outputs:
status: ${{ steps.tidy.outputs.status }}
patch: ${{ steps.tidy.outputs.patch }}
steps:
# The merge commit and its first parent, as the clang-tidy check sees them.
- name: Checkout
uses: actions/checkout@v7
with:
ref: refs/pull/${{ github.event.issue.number }}/merge
fetch-depth: 2
lfs: 'false'
persist-credentials: false
- name: Check the merge commit is of the checked head
env:
HEAD_SHA: ${{ needs.authorize.outputs.head-sha }}
run: |
if [ "$(git rev-parse HEAD^2)" != "$HEAD_SHA" ]; then
echo "::error::The PR changed after the command was checked. Run /bot fix-tidy again."
exit 1
fi
# 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
if: ${{ !vars.SELF_HOSTED }}
uses: ./.github/actions/apt-install-deps
# Base64 keeps the patch on one output line. The exit status tells whether
# findings remain that --fix could not fix.
- name: Fix the changed lines
id: tidy
run: |
status=0
scripts/run_clang_tidy.sh --base HEAD^1 --yes --fix || status=$?
git -c core.quotePath=false diff --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ > "$RUNNER_TEMP/fix.patch"
echo "status=$status" >> "$GITHUB_OUTPUT"
echo "patch=$(base64 -w0 "$RUNNER_TEMP/fix.patch")" >> "$GITHUB_OUTPUT"
push:
needs: [authorize, fix]
# Reports a failed fix too, but not one a newer command cancelled.
if: always() && needs.authorize.outputs.head-sha != '' && needs.fix.result != 'cancelled' && needs.fix.result != 'skipped'
permissions:
issues: write # feedback comment + reactions
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# The PR's own files, only to apply the patch to. Nothing in them is run.
- name: Checkout the PR head
if: needs.fix.outputs.patch != ''
uses: actions/checkout@v7
with:
repository: ${{ needs.authorize.outputs.head-repo }}
ref: ${{ needs.authorize.outputs.head-sha }}
token: ${{ secrets.CLANG_TIDY_BOT_TOKEN }}
fetch-depth: 1
lfs: 'false'
sparse-checkout: |
src
tests
- name: Check, commit and push the patch
id: push
if: needs.fix.outputs.patch != ''
env:
PATCH: ${{ needs.fix.outputs.patch }}
FILES: ${{ needs.authorize.outputs.files }}
HEAD_REF: ${{ needs.authorize.outputs.head-ref }}
run: |
echo "$PATCH" | base64 -d > "$RUNNER_TEMP/fix.patch"
cat "$RUNNER_TEMP/fix.patch"
# Every line the patch adds must be an #include or blank, in a C++ file
# under src/ or tests/ that the PR already changes, and it may not remove,
# create, rename or change the mode of anything.
if ! python3 -I - "$RUNNER_TEMP/fix.patch" <<'EOF'
import json, os, re, sys
allowed = set(json.loads(os.environ["FILES"]))
cpp = re.compile(r"(src|tests)/.+\.(cpp|cc|cxx|hpp|h|hxx)")
include = re.compile(r'\+\s*#\s*include\s*(<[^<>"]+>|"[^<>"]+")\s*')
header = re.compile(r"(index [0-9a-f]+\.\.[0-9a-f]+( \d+)?|@@ -\d+(,\d+)? \+\d+(,\d+)? @@.*)")
problems = []
path = None
for line in open(sys.argv[1], encoding="utf-8").read().splitlines():
if line.startswith("diff --git "):
match = re.fullmatch(r"diff --git a/(.+) b/\1", line)
path = match.group(1) if match else None
if path is None or path not in allowed or not cpp.fullmatch(path):
problems.append(f"{line}: not a C++ file the PR changes")
# git appends a tab to the --- and +++ lines of a path that contains a space.
elif line.removesuffix("\t") in (f"--- a/{path}", f"+++ b/{path}") or header.fullmatch(line):
pass
elif line.startswith(" ") or line == "\\ No newline at end of file":
pass
elif line.startswith("+") and (include.fullmatch(line) or not line[1:].strip()):
pass
else:
problems.append(f"{path}: {line}")
for problem in problems:
print(f"::error::Refused patch line {problem}")
sys.exit(1 if problems else 0)
EOF
then
echo "result=rejected" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git apply --index "$RUNNER_TEMP/fix.patch"; then
echo "result=apply-failed" >> "$GITHUB_OUTPUT"
exit 0
fi
git -c user.name='github-actions[bot]' \
-c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
commit --quiet -m "Add Missing Includes Reported by clang-tidy"
# No force: a branch that moved since the command was checked rejects the push.
if ! git push origin "HEAD:refs/heads/$HEAD_REF"; then
echo "result=push-failed" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "result=pushed" >> "$GITHUB_OUTPUT"
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Report
if: always()
uses: actions/github-script@v9
env:
FIX_RESULT: ${{ needs.fix.result }}
STATUS: ${{ needs.fix.outputs.status }}
HAS_PATCH: ${{ needs.fix.outputs.patch != '' }}
RESULT: ${{ steps.push.outputs.result }}
SHA: ${{ steps.push.outputs.sha }}
with:
script: |
const MARKER = '<!-- pr-tidy-bot -->';
const { owner, repo } = context.repo;
const comment = context.payload.comment;
const run = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
const remaining = process.env.STATUS === '0'
? 'clang-tidy passes with it.'
: `Some findings remain that need a manual edit, listed in the [run log](${run}).`;
const { FIX_RESULT, HAS_PATCH, RESULT, SHA } = process.env;
let ok = false;
let body;
if (FIX_RESULT !== 'success') {
body = `I couldn't run clang-tidy on this PR. See the [run log](${run}).`;
} else if (HAS_PATCH !== 'true') {
ok = process.env.STATUS === '0';
body = ok
? 'clang-tidy passes on this PR. There is nothing to fix.'
: `clang-tidy has no fix for what it reports. The findings need a manual edit, listed in the [run log](${run}).`;
} else if (RESULT === 'pushed') {
ok = true;
body = `I pushed ${SHA}, which adds the missing includes. ${remaining}`;
} else if (RESULT === 'rejected') {
body = `clang-tidy's fix changed more than includes, so I didn't push it. See the [run log](${run}).`;
} else if (RESULT === 'apply-failed') {
body = `clang-tidy's fix doesn't apply to the PR's branch. See the [run log](${run}).`;
} else if (RESULT === 'push-failed') {
body = `I couldn't push to the PR's branch. If it changed since the command, run \`/bot fix-tidy\` again. See the [run log](${run}).`;
} else {
body = `Something went wrong. See the [run log](${run}).`;
}
await github.rest.reactions.createForIssueComment({ owner, repo, comment_id: comment.id, content: ok ? '+1' : '-1' });
await github.rest.issues.createComment({
owner, repo, issue_number: context.payload.issue.number,
body: `${MARKER}\n@${comment.user.login} ${body}`
});