diff --git a/.github/workflows/clang_tidy_fix.yml b/.github/workflows/clang_tidy_fix.yml deleted file mode 100644 index 6339e17570..0000000000 --- a/.github/workflows/clang_tidy_fix.yml +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 62f4a0002f..0000000000 --- a/.github/workflows/pr-tidy-bot.yml +++ /dev/null @@ -1,476 +0,0 @@ -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 }); - }