diff --git a/.github/workflows/dependabot-pr-cleaner.yaml b/.github/workflows/dependabot-pr-cleaner.yaml new file mode 100644 index 000000000..d95f0b2da --- /dev/null +++ b/.github/workflows/dependabot-pr-cleaner.yaml @@ -0,0 +1,75 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Dependabot PR cleaner +run-name: >- + Clean up description of PR ${{github.event.pull_request.number}} on + ${{github.ref_name}} by @${{github.actor}} + +on: + pull_request_target: + types: + - opened + - synchronize + - reopened + + workflow_dispatch: + inputs: + pr-number: + description: 'The PR number of the PR to clean:' + required: true + debug: + description: 'Run with debugging options' + type: boolean + default: true + +# Declare default workflow permissions as read only. +permissions: read-all + +jobs: + clean-pr-description: + if: >- + ${{github.actor == 'dependabot[bot]' && + github.repository_owner == 'quantumlib'}} + name: Clean PR description + runs-on: ubuntu-slim + timeout-minutes: 5 + permissions: + contents: read + pull-requests: write + steps: + - name: Check out a copy of the git repository + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + sparse-checkout: | + ./dev_tools/ci/dependabot_pr_description_cleaner.py + + - name: Set up Python + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5 + with: + cache: pip + + - name: Install dependencies + run: pip install html2text + + - name: Clean the PR description + env: + GH_REPO: ${{github.repository}} + GH_TOKEN: ${{secrets.GITHUB_TOKEN}} + PR_NUMBER: ${{inputs.pr-number || github.event.pull_request.number}} + SHELLOPTS: ${{inputs.debug && 'xtrace' || '' }} + run: | + gh pr view ${{env.PR_NUMBER}} --json body --jq .body > body.txt + python3 dev_tools/ci/dependabot_pr_description_cleaner.py body.txt + gh pr edit ${{env.PR_NUMBER}} --body-file body.txt diff --git a/dev_tools/ci/dependabot_pr_description_cleaner.py b/dev_tools/ci/dependabot_pr_description_cleaner.py new file mode 100644 index 000000000..d366e9e18 --- /dev/null +++ b/dev_tools/ci/dependabot_pr_description_cleaner.py @@ -0,0 +1,79 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Convert and streamline description bodies of Dependabot PRs.""" + +import os +import re +import sys + +import html2text + + +def process_text(content): + """Take PR description body and clean it. + + Cleaning steps applied: + * Remove the section "Commits" + * Convert HTML to Markdown + * Remove the section "Dependabot commands and options" + """ + + # Pattern: match the start of the details block for Commits, lazy match + # until the closing details tag, and match the following br tag if present. + content = re.sub( + r"
\s*Commits.*?
\s*()?", + "", + content, + flags=re.DOTALL | re.IGNORECASE, + ) + + h = html2text.HTML2Text() + h.body_width = 0 + markdown_content = h.handle(content) + + target_phrase = "Dependabot commands and options" + if target_phrase in markdown_content: + # Split at the last occurrence of the phrase. + parts = markdown_content.rsplit(target_phrase, 1) + pre_text = parts[0] + pre_text = pre_text.rstrip() + # Remove the marker if present at the end. + if pre_text.endswith(r"\---"): + pre_text = pre_text[:-4] + elif pre_text.endswith("---"): + pre_text = pre_text[:-3] + markdown_content = pre_text.strip() + + return markdown_content + + +def clean_body(file_path): + """Reads the file, cleans the text, and writes it back to the same file.""" + with open(file_path, "r") as f: + content = f.read() + + markdown_content = process_text(content) + + # Write back to file. + with open(file_path, "w") as f: + f.write(markdown_content) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + script_name = os.path.basename(sys.argv[0]) + print(f"Usage: python3 {script_name} FILE") + sys.exit(1) + clean_body(sys.argv[1]) diff --git a/dev_tools/ci/dependabot_pr_description_cleaner_test.py b/dev_tools/ci/dependabot_pr_description_cleaner_test.py new file mode 100644 index 000000000..edc2fdcfd --- /dev/null +++ b/dev_tools/ci/dependabot_pr_description_cleaner_test.py @@ -0,0 +1,350 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=line-too-long + +import dependabot_pr_description_cleaner as cleaner +import pytest + +# Sample 1 +SAMPLE_1 = r"""Bumps [raven-actions/actionlint](https://github.com/raven-actions/actionlint) from 2.0.1 to 2.1.0. +
+Release notes +

Sourced from raven-actions/actionlint's releases.

+
+

v2.1.0

+

🔄️ What's Changed

+ +

👥 Contributors

+

@​DariuszPorowski

+

See details of all code changes: https://github.com/raven-actions/actionlint/compare/v2.0.2...v2.1.0 since previous release.

+

v2.0.2

+

🔄️ What's Changed

+ +

👥 Contributors

+

@​DariuszPorowski, @​allejo, @​dependabot[bot], @​raven-actions-sync[bot], dependabot[bot] and raven-actions-sync[bot]

+

See details of all code changes: https://github.com/raven-actions/actionlint/compare/v2.0.1...v2.0.2 since previous release.

+
+
+
+Commits + +
+
+ + +[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=raven-actions/actionlint&package-manager=github_actions&previous-version=2.0.1&new-version=2.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + +Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually +- `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency +- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) + + +
+""" + +# Sample 2 +SAMPLE_2 = r"""Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.0 to 6.0.1. +
+Release notes +

Sourced from actions/checkout's releases.

+
+

v6.0.1

+

What's Changed

+ +

Full Changelog: https://github.com/actions/checkout/compare/v6...v6.0.1

+
+
+
+Commits + +
+
+ + +[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=6.0.0&new-version=6.0.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + +Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually +- `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency +- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) + + +
+""" + +# Sample 3 +SAMPLE_3 = r"""Updates the requirements on [cmake](https://github.com/scikit-build/cmake-python-distributions) to permit the latest version. +
+Release notes +

Sourced from cmake's releases.

+
+

4.2.1

+ +

What's Changed

+ +

New Contributors

+ +

Full Changelog: https://github.com/scikit-build/cmake-python-distributions/compare/4.2.0...4.2.1

+
+
+
+Commits + +
+
+ + +Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually +- `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency +- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) + + +
+""" + + +def test_process_text_basic_conversion(): + html_input = "

Hello World

" + expected_output = "Hello **World**" + result = cleaner.process_text(html_input) + assert result.strip() == expected_output + + +def test_remove_commits_section(): + html_input = """ +

Some text

+
+ Commits + +
+
+

More text

+ """ + # "Commits" section should be removed. + # html2text introduces newlines. + result = cleaner.process_text(html_input) + + assert "Commits" not in result + assert "Commit 1" not in result + assert "Some text" in result + assert "More text" in result + + +def test_remove_dependabot_commands(): + html_input = """ +

Content

+
+
+ Dependabot commands and options +

commands

+
+ """ + result = cleaner.process_text(html_input) + + assert "Content" in result + assert "Dependabot commands and options" not in result + + +def test_basic_example(): + # A simplified version of a real Dependabot body + html_input = """ + Bumps [package](link) from 1.0 to 2.0. +
+ Release notes +

Notes

+
+
+ Commits + +
+
+ --- +
+ Dependabot commands and options +

help

+
+ """ + result = cleaner.process_text(html_input) + + assert "Bumps [package](link) from 1.0 to 2.0." in result + assert "Release notes" in result + assert "Commits" not in result + assert "123456" not in result + assert "Dependabot commands and options" not in result + + +@pytest.mark.parametrize("input_content", [SAMPLE_1, SAMPLE_2, SAMPLE_3]) +def test_clean_real_samples(input_content): + cleaned = cleaner.process_text(input_content) + + assert cleaned is not None + assert "Dependabot commands and options" not in cleaned + assert "
" not in cleaned + assert "
" not in cleaned + assert "Release notes" in cleaned + assert "Commits" not in cleaned + + +def test_idempotency(): + cleaned = cleaner.process_text(SAMPLE_1) + cleaned_again = cleaner.process_text(cleaned) + assert "Dependabot commands and options" not in cleaned_again + + # Check that links are preserved (and not escaped). + assert ( + "[raven-actions/actionlint](https://github.com/raven-actions/actionlint)" + in cleaned_again + ) + assert "\\[raven-actions/actionlint\\]" not in cleaned_again + + +def test_markdown_preservation(): + # Ensure links are not escaped (basic check) + cleaned = cleaner.process_text(SAMPLE_1) + assert ( + "[raven-actions/actionlint](https://github.com/raven-actions/actionlint)" + in cleaned + ) + + +def test_custom_separator_removal(): + content = "Some content\n\n--- \nDependabot commands and options\nJunk at the end" + cleaned = cleaner.process_text(content) + assert "Some content" in cleaned + assert "Dependabot commands and options" not in cleaned + assert "Junk at the end" not in cleaned + assert "---" not in cleaned + + +def test_no_separator_removal(): + content = "Some content\nDependabot commands and options\nJunk at the end" + cleaned = cleaner.process_text(content) + assert "Some content" in cleaned + assert "Dependabot commands and options" not in cleaned + assert "Junk at the end" not in cleaned