diff --git a/.github/workflows/clang_tidy_fix.yml b/.github/workflows/clang_tidy_fix.yml new file mode 100644 index 0000000000..6339e17570 --- /dev/null +++ b/.github/workflows/clang_tidy_fix.yml @@ -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 diff --git a/.github/workflows/pr-tidy-bot.yml b/.github/workflows/pr-tidy-bot.yml new file mode 100644 index 0000000000..62f4a0002f --- /dev/null +++ b/.github/workflows/pr-tidy-bot.yml @@ -0,0 +1,476 @@ +name: PR clang-tidy Bot + +# Helps a pull request past the clang-tidy check. +# +# 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 +# +# 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. +# +# 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: + 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') + permissions: + actions: write # re-run the fix run + pull-requests: write # react to and answer the comment, which is the PR's + issues: write # feedback comment + reactions + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Re-run the PR's clang-tidy fix + uses: actions/github-script@v9 + with: + script: | + const MARKER = ''; + const FIX_WORKFLOW = 'clang_tidy_fix.yml'; + + 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; + // Answers only the PR's author and maintainers, so the command cannot be used to spam. + if (commenter !== issue.user.login) { + const { data: access } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: commenter }); + if (!['admin', 'write'].includes(access.permission)) { + core.info(`Ignoring /bot fix-tidy from @${commenter}: not the PR's author and no write access.`); + return; + } + } + + 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}` }); + } + + await react('eyes'); + + if (!/^\/bot\s+fix-tidy$/i.test(commandLine)) { + return refuse('`/bot fix-tidy` takes no arguments.'); + } + + 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.'); + } + + const sha = pr.head.sha.slice(0, 10); + const { data: { workflow_runs: runs } } = await github.rest.actions.listWorkflowRuns({ + owner, repo, workflow_id: FIX_WORKFLOW, event: 'pull_request', head_sha: pr.head.sha, per_page: 1 + }); + const run = runs[0]; + // A PR last pushed before clang_tidy_fix.yml existed has no run to re-run. + if (!run) { + return refuse(`there is no clang-tidy fix run for ${sha}, the PR's latest commit. If you just pushed it, ` + + 'try again in a minute. Otherwise, push a commit or merge the base branch to start one, then comment `/bot fix-tidy` again.'); + } + if (run.status !== 'completed') { + // 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.`); + } + // GitHub refuses to re-run a run created over 30 days ago. + try { + await github.rest.actions.reRunWorkflow({ owner, repo, run_id: run.id }); + } catch (error) { + core.warning(`Re-running ${run.html_url} failed: ${error.message}`); + return refuse(`GitHub won't re-run the [clang-tidy fix run](${run.html_url}) for ${sha} (${error.message}). ` + + 'Push a commit or merge the base branch to start a new one, then comment `/bot fix-tidy` again.'); + } + 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 = ''; + 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}` + }); + 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 + // review comment can be placed on. A file too large for GitHub to + // show its diff has none, and gets no suggestions. + const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 }); + const ranges = {}; + for (const file of files) { + if (file.status === 'removed' || !CPP.test(file.filename)) { + continue; + } + ranges[file.filename] = [...(file.patch || '').matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)] + .map((m) => [Number(m[1]), Number(m[1]) + Number(m[2] ?? 1) - 1]) + .filter(([first, last]) => last >= first); + } + + core.setOutput('pr', pr.number); + core.setOutput('head-sha', pr.head.sha); + 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.pr != '' + uses: actions/download-artifact@v8 + with: + name: clang-tidy-fix + 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.pr != '' + uses: actions/checkout@v7 + with: + repository: ${{ steps.find.outputs.head-repo }} + ref: ${{ steps.find.outputs.head-sha }} + path: head + fetch-depth: 1 + lfs: 'false' + persist-credentials: false + sparse-checkout: | + src + tests + + # 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.pr != '' + env: + RANGES: ${{ steps.find.outputs.ranges }} + run: | + python3 -I - "$RUNNER_TEMP/clang-tidy-fix/fix.patch" head "$RUNNER_TEMP" <<'EOF' + import json, os, re, subprocess, sys + + patch_path, head, out = sys.argv[1:] + ranges = json.loads(os.environ["RANGES"]) + + def output(**values): + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: + 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. + include = re.compile(r'\s*#\s*include\s*(<[\w./+-]+>|"[\w./+-]+")\s*') + header = re.compile(r"(index [0-9a-f]+\.\.[0-9a-f]+( \d+)?|@@ -\d+(,\d+)? \+\d+(,\d+)? @@.*)") + problems = [] + path = None + 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 + if path not in ranges: + 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[1:]) or not line[1:].strip()): + pass + else: + problems.append(f"{path}: {line}") + if problems: + for problem in problems: + print(f"::error::Refused patch line {problem}") + output(result="rejected") + sys.exit() + + if subprocess.run(["git", "-C", head, "apply", "--index", os.path.abspath(patch_path)]).returncode != 0: + output(result="apply-failed") + sys.exit() + + git = ["git", "-C", head, "-c", "core.quotePath=false", "diff", "--cached", "--no-color", "--no-ext-diff", + "--src-prefix=a/", "--dst-prefix=b/"] + with open(os.path.join(out, "head.patch"), "w", encoding="utf-8") as f: + f.write(subprocess.run(git, check=True, capture_output=True, encoding="utf-8").stdout) + + # Each run of added lines becomes one suggestion on the line it goes after + # (before, at the top of a file): that line, replaced by itself and the + # includes. It can only sit on a line the PR's diff shows. + hunks = [] + path = None + for line in subprocess.run(git + ["-U0"], check=True, capture_output=True, encoding="utf-8").stdout.splitlines(): + if line.startswith("diff --git "): + path = re.fullmatch(r"diff --git a/(.+) b/\1", line).group(1) + elif line.startswith("@@ "): + hunks.append((path, int(re.match(r"@@ -(\d+)", line).group(1)), [])) + elif hunks and line.startswith("+") and not line.startswith("+++ "): + hunks[-1][2].append(line[1:]) + + suggestions = [] + total = suggested = 0 + for path, after, added in hunks: + count = sum(bool(include.fullmatch(line)) for line in added) + total += count + original = subprocess.run(["git", "-C", head, "show", f"HEAD:{path}"], check=True, capture_output=True, + encoding="utf-8").stdout.splitlines() + line = max(after, 1) + if line > len(original) or not any(first <= line <= last for first, last in ranges[path]): + continue + body = "\n".join([original[line - 1], *added] if after else [*added, original[0]]) + # A fence longer than any run of backticks in the line it repeats. + fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", body)]) + suggestions.append({"path": path, "line": line, "side": "RIGHT", + "body": f"{fence}suggestion\n{body}\n{fence}"}) + suggested += count + with open(os.path.join(out, "suggestions.json"), "w", encoding="utf-8") as f: + json.dump(suggestions, f) + output(result="ok", includes=total, suggested=suggested) + EOF + + - name: Post the fix + 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 }} + with: + script: | + const fs = require('fs'); + const path = require('path'); + const MARKER = ''; + // GitHub's limit on a review body is 65536 characters. + const MAX_PATCH = 60000; + + const { owner, repo } = context.repo; + 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) => 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 one = INCLUDES === '1'; + const includes = one ? '1 missing include' : `${INCLUDES} missing includes`; + const inline = SUGGESTED === '0' + ? (one ? 'It goes on a line this PR does not change, so I can\'t suggest it inline.' + : 'They go on lines this PR does not change, so I can\'t suggest them inline.') + : SUGGESTED === INCLUDES + ? (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'}, 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 [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${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' }; + try { + await github.rest.pulls.createReview({ ...review, body: body(true), comments: suggestions }); + } catch (error) { + // A suggestion GitHub will not place still leaves the patch. + core.warning(`Posting the suggestions failed (${error.message}); posting the patch alone.`); + 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: write # comment on 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 = ''; + // 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 }); + }