From 41a3d01a33872cc4ea91525335c6ff7ba5440535 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Thu, 6 Aug 2026 17:10:38 +0200 Subject: [PATCH 01/34] Add review checklists workflow and action Add a "Review Checklists" GitHub Actions workflow that posts checklist findings on pull requests, tracks reviewer acknowledgements, invalidates stale approvals on new pushes, and reports a "review-checklists" commit status that can be required for merges. - New composite action `actions/review_checklists` (post/check/ dismiss_sync/dismiss_edit) implementing the checklist logic. - New workflow `.github/workflows/review_checklists.yml` triggering on pull_request_target, pull_request_review_comment, pull_request_review, and merge_group events. - Configuration file `.github/review_checklists.yml` with a placeholder example checklist to be customized for this repository's needs. - Bazel wiring: pip.parse hub `review_checklists_dependencies` in MODULE.bazel, BUILD targets for the action's requirements and Python scripts/tests, and `actions` added to the root copyright_checker srcs. Verified: `bazel build //actions/review_checklists/...` and `bazel test //actions/review_checklists/...` (5/5 tests pass), plus `//:copyright-check` and `//:format_test` show no new findings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/BUILD | 18 + .github/review_checklists.yml | 32 + .github/workflows/review_checklists.yml | 113 +++ BUILD | 1 + MODULE.bazel | 7 + actions/review_checklists/BUILD | 19 + actions/review_checklists/action.yml | 102 +++ actions/review_checklists/requirements.txt | 849 ++++++++++++++++++ actions/review_checklists/requirements.txt.in | 18 + actions/review_checklists/scripts/BUILD | 117 +++ actions/review_checklists/scripts/__init__.py | 13 + .../scripts/check_acknowledgements.py | 200 +++++ .../scripts/check_acknowledgements_test.py | 408 +++++++++ .../scripts/dismiss_and_invalidate.py | 232 +++++ .../scripts/dismiss_and_invalidate_test.py | 363 ++++++++ actions/review_checklists/scripts/helpers.py | 515 +++++++++++ .../review_checklists/scripts/helpers_test.py | 701 +++++++++++++++ .../scripts/post_checklists.py | 129 +++ .../scripts/post_checklists_test.py | 210 +++++ 19 files changed, 4047 insertions(+) create mode 100644 .github/BUILD create mode 100644 .github/review_checklists.yml create mode 100644 .github/workflows/review_checklists.yml create mode 100644 actions/review_checklists/BUILD create mode 100644 actions/review_checklists/action.yml create mode 100644 actions/review_checklists/requirements.txt create mode 100644 actions/review_checklists/requirements.txt.in create mode 100644 actions/review_checklists/scripts/BUILD create mode 100644 actions/review_checklists/scripts/__init__.py create mode 100644 actions/review_checklists/scripts/check_acknowledgements.py create mode 100644 actions/review_checklists/scripts/check_acknowledgements_test.py create mode 100644 actions/review_checklists/scripts/dismiss_and_invalidate.py create mode 100644 actions/review_checklists/scripts/dismiss_and_invalidate_test.py create mode 100644 actions/review_checklists/scripts/helpers.py create mode 100644 actions/review_checklists/scripts/helpers_test.py create mode 100644 actions/review_checklists/scripts/post_checklists.py create mode 100644 actions/review_checklists/scripts/post_checklists_test.py diff --git a/.github/BUILD b/.github/BUILD new file mode 100644 index 000000000..c5809021d --- /dev/null +++ b/.github/BUILD @@ -0,0 +1,18 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +filegroup( + name = "review_checklists_config", + srcs = ["review_checklists.yml"], + visibility = ["//visibility:public"], +) diff --git a/.github/review_checklists.yml b/.github/review_checklists.yml new file mode 100644 index 000000000..e9305bb80 --- /dev/null +++ b/.github/review_checklists.yml @@ -0,0 +1,32 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# Review checklists configuration. +# Each checklist has: +# id: Unique identifier (used in markers and tracking) +# name: Human-readable name shown in the PR conversation +# include: List of glob patterns; a file must match at least one to be considered +# exclude: (optional) List of glob patterns; a file matching any of these is excluded +# checklist: Markdown checklist body shown to reviewers + +checklists: + - id: example-review + name: "Example checklist" + include: + - "**" + exclude: + - ".github/**" + checklist: | + - This is an example checklist item + - Avoid checkmarks in the items since this makes it easy for users to accidentally modify the checklist + - Modifying the checklist will reset all previous acknowledgments diff --git a/.github/workflows/review_checklists.yml b/.github/workflows/review_checklists.yml new file mode 100644 index 000000000..fd3d54ac8 --- /dev/null +++ b/.github/workflows/review_checklists.yml @@ -0,0 +1,113 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# Keep review-checklists up to date on pull requests and report whether all +# checklists have been acknowledged by every approving reviewer. +# +# Triggers: +# pull_request_target (opened/reopened/synchronize/edited) +# -> Post or update checklist findings on the PR. +# -> On synchronize: invalidate stale OKs for affected checklists. +# -> On edited: refresh checklist evidence/notices after PR description changes. +# -> Re-check acknowledgements. +# pull_request_review_comment (created/edited/deleted) +# -> Restore checklist findings if they were tampered with or deleted. +# -> On edit/delete: handle OK retraction. +# -> Re-check acknowledgements. +# pull_request_review (submitted/dismissed) +# -> Re-check acknowledgements (approver set may have changed). +# +# When a PR is in merge queue, the workflow also ensures: +# - a persistent PR comment notice, and +# - a standalone PR-description notice, +# explaining that post-queue changes do not alter evidence recorded in git +# history by the merge commit evidence flow. + +name: Review Checklists + +on: + pull_request_target: + types: [opened, reopened, synchronize, edited] + branches: + - main + pull_request_review_comment: + types: [created, edited, deleted] + pull_request_review: + types: [submitted, dismissed] + merge_group: + types: [checks_requested] + +permissions: + pull-requests: write + statuses: write + +jobs: + review-checklists: + name: Review Checklists + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.2 + + # Step 1: Post or update checklist findings. + # Runs on PR open/reopen/sync, on PR description edits, and on + # review comment events so tampered checklist findings and notices + # are restored. + - name: Post / update checklist findings + if: >- + github.event_name == 'pull_request_target' || + github.event_name == 'pull_request_review_comment' + uses: ./actions/review_checklists + with: + action: post + github-token: ${{ secrets.GITHUB_TOKEN }} + pr-number: ${{ github.event.pull_request.number }} + + # Step 2: Invalidate stale OKs when new commits are pushed. + - name: Invalidate stale acknowledgements + if: >- + github.event_name == 'pull_request_target' && + github.event.action == 'synchronize' + uses: ./actions/review_checklists + with: + action: dismiss_sync + github-token: ${{ secrets.GITHUB_TOKEN }} + pr-number: ${{ github.event.pull_request.number }} + before-sha: ${{ github.event.before }} + after-sha: ${{ github.event.after }} + + # Step 3: Dismiss approval if a reviewer retracted their OK. + - name: Handle OK retraction + if: >- + github.event_name == 'pull_request_review_comment' && + (github.event.action == 'edited' || github.event.action == 'deleted') + uses: ./actions/review_checklists + with: + action: dismiss_edit + github-token: ${{ secrets.GITHUB_TOKEN }} + pr-number: ${{ github.event.pull_request.number }} + + # Step 4: Always re-check acknowledgement status. + # The script sets the commit status context "review-checklists" + # to "pending" or "success". Commit statuses with the same + # context on the same SHA are deduplicated, so the latest result + # from any trigger always wins. The workflow job itself always + # succeeds — branch protection must require the commit status + # context (not the workflow check run) to gate merges. + - name: Check acknowledgements + uses: ./actions/review_checklists + with: + action: check + github-token: ${{ secrets.GITHUB_TOKEN }} + pr-number: ${{ github.event.pull_request.number || '' }} + head-sha: ${{ github.event.merge_group.head_sha || '' }} diff --git a/BUILD b/BUILD index e28bfe93d..2b804f50f 100644 --- a/BUILD +++ b/BUILD @@ -44,6 +44,7 @@ copyright_checker( name = "copyright", srcs = [ ".github", + "actions", "quality", "score", "third_party", diff --git a/MODULE.bazel b/MODULE.bazel index 68e246cf8..5db1fc336 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -413,6 +413,13 @@ pip.parse( ) use_repo(pip, "codeql_coding_standards_pip_hub") +pip.parse( + hub_name = "review_checklists_dependencies", + python_version = "3.12", + requirements_lock = "//actions/review_checklists:requirements.txt", +) +use_repo(pip, "review_checklists_dependencies") + # TRLC dependency for requirements traceability bazel_dep(name = "trlc", version = "3.0.1", dev_dependency = True) bazel_dep(name = "rules_oci", version = "2.2.7", dev_dependency = True) diff --git a/actions/review_checklists/BUILD b/actions/review_checklists/BUILD new file mode 100644 index 000000000..d6da037ab --- /dev/null +++ b/actions/review_checklists/BUILD @@ -0,0 +1,19 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +load("@rules_python//python:pip.bzl", "compile_pip_requirements") + +compile_pip_requirements( + name = "requirements", + src = "requirements.txt.in", + requirements_txt = "requirements.txt", +) diff --git a/actions/review_checklists/action.yml b/actions/review_checklists/action.yml new file mode 100644 index 000000000..13db2c93e --- /dev/null +++ b/actions/review_checklists/action.yml @@ -0,0 +1,102 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +name: "Review Checklists" +description: > + Manages review checklists on pull requests. Posts checklist comments, + tracks reviewer acknowledgements, dismisses stale approvals, and + maintains evidence in the PR description and merge-queue evidence notices. + +inputs: + action: + description: > + Which action to run. One of: + post – Post/update checklist comments and evidence + check – Verify acknowledgements; fail if incomplete + dismiss_sync – Invalidate OKs after new push + dismiss_edit – Dismiss approval after OK edit/delete + required: true + github-token: + description: "GitHub token with repo permissions" + required: true + pr-number: + description: "Pull request number" + required: false + default: "" + before-sha: + description: "SHA before the push (for synchronize events)" + required: false + default: "" + after-sha: + description: "SHA after the push (for synchronize events)" + required: false + default: "" + head-sha: + description: > + Commit SHA to set the status on (used for merge_group events). + required: false + default: "" + config-path: + description: > + Relative path to the checklist configuration file. + Defaults to '.github/review_checklists.yml'. + required: false + default: ".github/review_checklists.yml" + +runs: + using: composite + steps: + - name: Install review_checklists requirements + shell: bash + run: python3 -m pip install -r "$GITHUB_ACTION_PATH/requirements.txt" + + - name: Run post_checklists + if: inputs.action == 'post' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pr-number }} + run: python3 "$GITHUB_ACTION_PATH/scripts/post_checklists.py" --config-path "${{ inputs.config-path }}" + + - name: Run check_acknowledgements + if: inputs.action == 'check' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pr-number }} + HEAD_SHA: ${{ inputs.head-sha }} + run: python3 "$GITHUB_ACTION_PATH/scripts/check_acknowledgements.py" --config-path "${{ inputs.config-path }}" + + + - name: Run dismiss_and_invalidate (synchronize) + if: inputs.action == 'dismiss_sync' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pr-number }} + BEFORE_SHA: ${{ inputs.before-sha }} + AFTER_SHA: ${{ inputs.after-sha }} + run: python3 "$GITHUB_ACTION_PATH/scripts/dismiss_and_invalidate.py" --trigger synchronize --config-path "${{ inputs.config-path }}" + + - name: Run dismiss_and_invalidate (comment changed) + if: inputs.action == 'dismiss_edit' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pr-number }} + GITHUB_EVENT_PATH: ${{ github.event_path }} + run: python3 "$GITHUB_ACTION_PATH/scripts/dismiss_and_invalidate.py" --trigger comment_changed --config-path "${{ inputs.config-path }}" diff --git a/actions/review_checklists/requirements.txt b/actions/review_checklists/requirements.txt new file mode 100644 index 000000000..5981e7a00 --- /dev/null +++ b/actions/review_checklists/requirements.txt @@ -0,0 +1,849 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# bazel run //actions/review_checklists:requirements.update +# +anyio==4.12.1 \ + --hash=sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703 \ + --hash=sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c + # via gql +backoff==2.2.1 \ + --hash=sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba \ + --hash=sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8 + # via gql +bazel-runfiles==1.8.4 \ + --hash=sha256:2c9c91d9f20f89642f00d533525f179586f8892af8aa451d71265824a6569582 + # via -r actions/review_checklists/requirements.txt.in +certifi==2026.2.25 \ + --hash=sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa \ + --hash=sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7 + # via requests +cffi==2.0.0 \ + --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ + --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ + --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ + --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ + --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \ + --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \ + --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ + --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ + --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \ + --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ + --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \ + --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ + --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ + --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \ + --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ + --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ + --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ + --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ + --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ + --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \ + --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ + --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ + --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ + --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \ + --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \ + --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ + --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ + --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \ + --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ + --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \ + --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \ + --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \ + --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \ + --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \ + --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \ + --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ + --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ + --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ + --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \ + --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ + --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ + --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ + --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ + --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ + --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ + --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \ + --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \ + --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \ + --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ + --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ + --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \ + --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \ + --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ + --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \ + --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \ + --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ + --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \ + --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ + --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ + --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \ + --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \ + --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \ + --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \ + --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ + --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \ + --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ + --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \ + --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \ + --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ + --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ + --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ + --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ + --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ + --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \ + --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ + --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ + --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \ + --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \ + --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \ + --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \ + --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ + --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ + --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ + --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf + # via + # cryptography + # pynacl +charset-normalizer==3.4.5 \ + --hash=sha256:014837af6fabf57121b6254fa8ade10dceabc3528b27b721a64bbc7b8b1d4eb4 \ + --hash=sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66 \ + --hash=sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54 \ + --hash=sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05 \ + --hash=sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765 \ + --hash=sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064 \ + --hash=sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819 \ + --hash=sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e \ + --hash=sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412 \ + --hash=sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc \ + --hash=sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e \ + --hash=sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281 \ + --hash=sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af \ + --hash=sha256:14498a429321de554b140013142abe7608f9d8ccc04d7baf2ad60498374aefa2 \ + --hash=sha256:149ec69866c3d6c2fb6f758dbc014ecb09f30b35a5ca90b6a8a2d4e54e18fdfe \ + --hash=sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8 \ + --hash=sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262 \ + --hash=sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac \ + --hash=sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85 \ + --hash=sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c \ + --hash=sha256:1f2da5cbb9becfcd607757a169e38fb82aa5fd86fae6653dea716e7b613fe2cf \ + --hash=sha256:259cd1ca995ad525f638e131dbcc2353a586564c038fc548a3fe450a91882139 \ + --hash=sha256:2820a98460c83663dd8ec015d9ddfd1e4879f12e06bb7d0500f044fb477d2770 \ + --hash=sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d \ + --hash=sha256:2b970382e4a36bed897c19f310f31d7d13489c11b4f468ddfba42d41cddfb918 \ + --hash=sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3 \ + --hash=sha256:30987f4a8ed169983f93e1be8ffeea5214a779e27ed0b059835c7afe96550ad7 \ + --hash=sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39 \ + --hash=sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d \ + --hash=sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990 \ + --hash=sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765 \ + --hash=sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1 \ + --hash=sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa \ + --hash=sha256:4b8551b6e6531e156db71193771c93bda78ffc4d1e6372517fe58ad3b91e4659 \ + --hash=sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d \ + --hash=sha256:50bcbca6603c06a1dcc7b056ed45c37715fb5d2768feb3bcd37d2313c587a5b9 \ + --hash=sha256:530beedcec9b6e027e7a4b6ce26eed36678aa39e17da85e6e03d7bd9e8e9d7c9 \ + --hash=sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2 \ + --hash=sha256:573ef5814c4b7c0d59a7710aa920eaaaef383bd71626aa420fba27b5cab92e8d \ + --hash=sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475 \ + --hash=sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c \ + --hash=sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81 \ + --hash=sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67 \ + --hash=sha256:5fea359734b140d0d6741189fea5478c6091b54ffc69d7ce119e0a05637d8c99 \ + --hash=sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5 \ + --hash=sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694 \ + --hash=sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf \ + --hash=sha256:65b3c403a5b6b8034b655e7385de4f72b7b244869a22b32d4030b99a60593eca \ + --hash=sha256:66dee73039277eb35380d1b82cccc69cc82b13a66f9f4a18da32d573acf02b7c \ + --hash=sha256:708c7acde173eedd4bfa4028484426ba689d2103b28588c513b9db2cd5ecde9c \ + --hash=sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636 \ + --hash=sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f \ + --hash=sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02 \ + --hash=sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497 \ + --hash=sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f \ + --hash=sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2 \ + --hash=sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d \ + --hash=sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873 \ + --hash=sha256:82cc7c2ad42faec8b574351f8bc2a0c049043893853317bd9bb309f5aba6cb5a \ + --hash=sha256:8a28afb04baa55abf26df544e3e5c6534245d3daa5178bc4a8eeb48202060d0e \ + --hash=sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1 \ + --hash=sha256:8ce11cd4d62d11166f2b441e30ace226c19a3899a7cf0796f668fba49a9fb123 \ + --hash=sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550 \ + --hash=sha256:92263f7eca2f4af326cd20de8d16728d2602f7cfea02e790dcde9d83c365d7cc \ + --hash=sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36 \ + --hash=sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644 \ + --hash=sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4 \ + --hash=sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0 \ + --hash=sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e \ + --hash=sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f \ + --hash=sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4 \ + --hash=sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98 \ + --hash=sha256:aa2f963b4da26daf46231d9b9e0e2c9408a751f8f0d0f44d2de56d3caf51d294 \ + --hash=sha256:aa92ec1102eaff840ccd1021478af176a831f1bccb08e526ce844b7ddda85c22 \ + --hash=sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23 \ + --hash=sha256:ae8b03427410731469c4033934cf473426faff3e04b69d2dfb64a4281a3719f8 \ + --hash=sha256:afca7f78067dd27c2b848f1b234623d26b87529296c6c5652168cc1954f2f3b2 \ + --hash=sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362 \ + --hash=sha256:b3e71afc578b98512bfe7bdb822dd6bc57d4b0093b4b6e5487c1e96ad4ace242 \ + --hash=sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4 \ + --hash=sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95 \ + --hash=sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d \ + --hash=sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94 \ + --hash=sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6 \ + --hash=sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2 \ + --hash=sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4 \ + --hash=sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8 \ + --hash=sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e \ + --hash=sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a \ + --hash=sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce \ + --hash=sha256:d29dd9c016f2078b43d0c357511e87eee5b05108f3dd603423cb389b89813969 \ + --hash=sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f \ + --hash=sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923 \ + --hash=sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6 \ + --hash=sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee \ + --hash=sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6 \ + --hash=sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467 \ + --hash=sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f \ + --hash=sha256:e22d1059b951e7ae7c20ef6b06afd10fb95e3c41bf3c4fbc874dba113321c193 \ + --hash=sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7 \ + --hash=sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9 \ + --hash=sha256:e545b51da9f9af5c67815ca0eb40676c0f016d0b0381c86f20451e35696c5f95 \ + --hash=sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763 \ + --hash=sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7 \ + --hash=sha256:ec56a2266f32bc06ed3c3e2a8f58417ce02f7e0356edc89786e52db13c593c98 \ + --hash=sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60 \ + --hash=sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade \ + --hash=sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c \ + --hash=sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2 \ + --hash=sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f \ + --hash=sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a \ + --hash=sha256:fc1c64934b8faf7584924143eb9db4770bbdb16659626e1a1a4d9efbcb68d947 \ + --hash=sha256:ff95a9283de8a457e6b12989de3f9f5193430f375d64297d323a615ea52cbdb3 + # via requests +cryptography==46.0.5 \ + --hash=sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72 \ + --hash=sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235 \ + --hash=sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9 \ + --hash=sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356 \ + --hash=sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257 \ + --hash=sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad \ + --hash=sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4 \ + --hash=sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c \ + --hash=sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614 \ + --hash=sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed \ + --hash=sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31 \ + --hash=sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229 \ + --hash=sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0 \ + --hash=sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731 \ + --hash=sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b \ + --hash=sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4 \ + --hash=sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4 \ + --hash=sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263 \ + --hash=sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595 \ + --hash=sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1 \ + --hash=sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678 \ + --hash=sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48 \ + --hash=sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76 \ + --hash=sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0 \ + --hash=sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18 \ + --hash=sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d \ + --hash=sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d \ + --hash=sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1 \ + --hash=sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981 \ + --hash=sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7 \ + --hash=sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82 \ + --hash=sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2 \ + --hash=sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4 \ + --hash=sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663 \ + --hash=sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c \ + --hash=sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d \ + --hash=sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a \ + --hash=sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a \ + --hash=sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d \ + --hash=sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b \ + --hash=sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a \ + --hash=sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826 \ + --hash=sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee \ + --hash=sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9 \ + --hash=sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648 \ + --hash=sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da \ + --hash=sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2 \ + --hash=sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2 \ + --hash=sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87 + # via pyjwt +gql[requests]==4.0.0 \ + --hash=sha256:f22980844eb6a7c0266ffc70f111b9c7e7c7c13da38c3b439afc7eab3d7c9c8e \ + --hash=sha256:f3beed7c531218eb24d97cb7df031b4a84fdb462f4a2beb86e2633d395937479 + # via -r actions/review_checklists/requirements.txt.in +graphql-core==3.2.8 \ + --hash=sha256:015457da5d996c924ddf57a43f4e959b0b94fb695b85ed4c29446e508ed65cf3 \ + --hash=sha256:cbee07bee1b3ed5e531723685369039f32ff815ef60166686e0162f540f1520c + # via gql +idna==3.11 \ + --hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \ + --hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902 + # via + # anyio + # requests + # yarl +iniconfig==2.3.0 \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + # via pytest +multidict==6.7.1 \ + --hash=sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0 \ + --hash=sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9 \ + --hash=sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581 \ + --hash=sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2 \ + --hash=sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941 \ + --hash=sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3 \ + --hash=sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43 \ + --hash=sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962 \ + --hash=sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1 \ + --hash=sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f \ + --hash=sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c \ + --hash=sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8 \ + --hash=sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa \ + --hash=sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6 \ + --hash=sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c \ + --hash=sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991 \ + --hash=sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262 \ + --hash=sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd \ + --hash=sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d \ + --hash=sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d \ + --hash=sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5 \ + --hash=sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3 \ + --hash=sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601 \ + --hash=sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505 \ + --hash=sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0 \ + --hash=sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292 \ + --hash=sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed \ + --hash=sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362 \ + --hash=sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511 \ + --hash=sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23 \ + --hash=sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2 \ + --hash=sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb \ + --hash=sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e \ + --hash=sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582 \ + --hash=sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0 \ + --hash=sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2 \ + --hash=sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e \ + --hash=sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d \ + --hash=sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65 \ + --hash=sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a \ + --hash=sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd \ + --hash=sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d \ + --hash=sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108 \ + --hash=sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177 \ + --hash=sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144 \ + --hash=sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5 \ + --hash=sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd \ + --hash=sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5 \ + --hash=sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060 \ + --hash=sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37 \ + --hash=sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56 \ + --hash=sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df \ + --hash=sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963 \ + --hash=sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568 \ + --hash=sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db \ + --hash=sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118 \ + --hash=sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84 \ + --hash=sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f \ + --hash=sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889 \ + --hash=sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71 \ + --hash=sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f \ + --hash=sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0 \ + --hash=sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7 \ + --hash=sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048 \ + --hash=sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8 \ + --hash=sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49 \ + --hash=sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0 \ + --hash=sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9 \ + --hash=sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59 \ + --hash=sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190 \ + --hash=sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709 \ + --hash=sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d \ + --hash=sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c \ + --hash=sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e \ + --hash=sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2 \ + --hash=sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40 \ + --hash=sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3 \ + --hash=sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee \ + --hash=sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609 \ + --hash=sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c \ + --hash=sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445 \ + --hash=sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1 \ + --hash=sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a \ + --hash=sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5 \ + --hash=sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31 \ + --hash=sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8 \ + --hash=sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33 \ + --hash=sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7 \ + --hash=sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca \ + --hash=sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8 \ + --hash=sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92 \ + --hash=sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733 \ + --hash=sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429 \ + --hash=sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9 \ + --hash=sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4 \ + --hash=sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6 \ + --hash=sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2 \ + --hash=sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172 \ + --hash=sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981 \ + --hash=sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5 \ + --hash=sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de \ + --hash=sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52 \ + --hash=sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7 \ + --hash=sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c \ + --hash=sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2 \ + --hash=sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6 \ + --hash=sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf \ + --hash=sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f \ + --hash=sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b \ + --hash=sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961 \ + --hash=sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a \ + --hash=sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3 \ + --hash=sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b \ + --hash=sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358 \ + --hash=sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6 \ + --hash=sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e \ + --hash=sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1 \ + --hash=sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c \ + --hash=sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5 \ + --hash=sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53 \ + --hash=sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872 \ + --hash=sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e \ + --hash=sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df \ + --hash=sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03 \ + --hash=sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8 \ + --hash=sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a \ + --hash=sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122 \ + --hash=sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a \ + --hash=sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee \ + --hash=sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32 \ + --hash=sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3 \ + --hash=sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489 \ + --hash=sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23 \ + --hash=sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34 \ + --hash=sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75 \ + --hash=sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8 \ + --hash=sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a \ + --hash=sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d \ + --hash=sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855 \ + --hash=sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b \ + --hash=sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4 \ + --hash=sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4 \ + --hash=sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d \ + --hash=sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0 \ + --hash=sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba \ + --hash=sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19 + # via yarl +packaging==26.0 \ + --hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \ + --hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529 + # via pytest +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via pytest +propcache==0.4.1 \ + --hash=sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e \ + --hash=sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4 \ + --hash=sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be \ + --hash=sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3 \ + --hash=sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85 \ + --hash=sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b \ + --hash=sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367 \ + --hash=sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf \ + --hash=sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393 \ + --hash=sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888 \ + --hash=sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37 \ + --hash=sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8 \ + --hash=sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60 \ + --hash=sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1 \ + --hash=sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4 \ + --hash=sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717 \ + --hash=sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7 \ + --hash=sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc \ + --hash=sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe \ + --hash=sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb \ + --hash=sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75 \ + --hash=sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6 \ + --hash=sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e \ + --hash=sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff \ + --hash=sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566 \ + --hash=sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12 \ + --hash=sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367 \ + --hash=sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874 \ + --hash=sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf \ + --hash=sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566 \ + --hash=sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a \ + --hash=sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc \ + --hash=sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a \ + --hash=sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1 \ + --hash=sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6 \ + --hash=sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61 \ + --hash=sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726 \ + --hash=sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49 \ + --hash=sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44 \ + --hash=sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af \ + --hash=sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa \ + --hash=sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153 \ + --hash=sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc \ + --hash=sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5 \ + --hash=sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938 \ + --hash=sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf \ + --hash=sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925 \ + --hash=sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8 \ + --hash=sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c \ + --hash=sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85 \ + --hash=sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e \ + --hash=sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0 \ + --hash=sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1 \ + --hash=sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0 \ + --hash=sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992 \ + --hash=sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db \ + --hash=sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f \ + --hash=sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d \ + --hash=sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1 \ + --hash=sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e \ + --hash=sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900 \ + --hash=sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89 \ + --hash=sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a \ + --hash=sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b \ + --hash=sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f \ + --hash=sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f \ + --hash=sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1 \ + --hash=sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183 \ + --hash=sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66 \ + --hash=sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21 \ + --hash=sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db \ + --hash=sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded \ + --hash=sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb \ + --hash=sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19 \ + --hash=sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0 \ + --hash=sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165 \ + --hash=sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778 \ + --hash=sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455 \ + --hash=sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f \ + --hash=sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b \ + --hash=sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237 \ + --hash=sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81 \ + --hash=sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859 \ + --hash=sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c \ + --hash=sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835 \ + --hash=sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393 \ + --hash=sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5 \ + --hash=sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641 \ + --hash=sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144 \ + --hash=sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74 \ + --hash=sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db \ + --hash=sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac \ + --hash=sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403 \ + --hash=sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9 \ + --hash=sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f \ + --hash=sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311 \ + --hash=sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581 \ + --hash=sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36 \ + --hash=sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00 \ + --hash=sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a \ + --hash=sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f \ + --hash=sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2 \ + --hash=sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7 \ + --hash=sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239 \ + --hash=sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757 \ + --hash=sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72 \ + --hash=sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9 \ + --hash=sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4 \ + --hash=sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24 \ + --hash=sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207 \ + --hash=sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e \ + --hash=sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1 \ + --hash=sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d \ + --hash=sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37 \ + --hash=sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c \ + --hash=sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e \ + --hash=sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570 \ + --hash=sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af \ + --hash=sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f \ + --hash=sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88 \ + --hash=sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48 \ + --hash=sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781 + # via yarl +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pygithub==2.8.1 \ + --hash=sha256:23a0a5bca93baef082e03411bf0ce27204c32be8bfa7abc92fe4a3e132936df0 \ + --hash=sha256:341b7c78521cb07324ff670afd1baa2bf5c286f8d9fd302c1798ba594a5400c9 + # via -r actions/review_checklists/requirements.txt.in +pygments==2.19.2 \ + --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ + --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b + # via pytest +pyjwt[crypto]==2.11.0 \ + --hash=sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623 \ + --hash=sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469 + # via pygithub +pynacl==1.6.2 \ + --hash=sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c \ + --hash=sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574 \ + --hash=sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4 \ + --hash=sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130 \ + --hash=sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b \ + --hash=sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590 \ + --hash=sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444 \ + --hash=sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634 \ + --hash=sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87 \ + --hash=sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa \ + --hash=sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594 \ + --hash=sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0 \ + --hash=sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e \ + --hash=sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c \ + --hash=sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0 \ + --hash=sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c \ + --hash=sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577 \ + --hash=sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145 \ + --hash=sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88 \ + --hash=sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14 \ + --hash=sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6 \ + --hash=sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465 \ + --hash=sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0 \ + --hash=sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2 \ + --hash=sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9 + # via pygithub +pytest==8.4.1 \ + --hash=sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7 \ + --hash=sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c + # via -r actions/review_checklists/requirements.txt.in +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via -r actions/review_checklists/requirements.txt.in +requests==2.32.5 \ + --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ + --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf + # via + # gql + # pygithub + # requests-toolbelt +requests-toolbelt==1.0.0 \ + --hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \ + --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06 + # via gql +typing-extensions==4.15.0 \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 + # via + # anyio + # pygithub +urllib3==2.6.3 \ + --hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \ + --hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4 + # via + # pygithub + # requests +yarl==1.23.0 \ + --hash=sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc \ + --hash=sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4 \ + --hash=sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85 \ + --hash=sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993 \ + --hash=sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222 \ + --hash=sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de \ + --hash=sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25 \ + --hash=sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e \ + --hash=sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2 \ + --hash=sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e \ + --hash=sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860 \ + --hash=sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957 \ + --hash=sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760 \ + --hash=sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52 \ + --hash=sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788 \ + --hash=sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912 \ + --hash=sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719 \ + --hash=sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035 \ + --hash=sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220 \ + --hash=sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412 \ + --hash=sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05 \ + --hash=sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41 \ + --hash=sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4 \ + --hash=sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4 \ + --hash=sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd \ + --hash=sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748 \ + --hash=sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a \ + --hash=sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4 \ + --hash=sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34 \ + --hash=sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069 \ + --hash=sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25 \ + --hash=sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2 \ + --hash=sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb \ + --hash=sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f \ + --hash=sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5 \ + --hash=sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8 \ + --hash=sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c \ + --hash=sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512 \ + --hash=sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6 \ + --hash=sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5 \ + --hash=sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9 \ + --hash=sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072 \ + --hash=sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5 \ + --hash=sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277 \ + --hash=sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a \ + --hash=sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6 \ + --hash=sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae \ + --hash=sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26 \ + --hash=sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2 \ + --hash=sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4 \ + --hash=sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70 \ + --hash=sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723 \ + --hash=sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c \ + --hash=sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9 \ + --hash=sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5 \ + --hash=sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e \ + --hash=sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c \ + --hash=sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4 \ + --hash=sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0 \ + --hash=sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2 \ + --hash=sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b \ + --hash=sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7 \ + --hash=sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750 \ + --hash=sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2 \ + --hash=sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474 \ + --hash=sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716 \ + --hash=sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7 \ + --hash=sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123 \ + --hash=sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007 \ + --hash=sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595 \ + --hash=sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe \ + --hash=sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea \ + --hash=sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598 \ + --hash=sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679 \ + --hash=sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8 \ + --hash=sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83 \ + --hash=sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6 \ + --hash=sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f \ + --hash=sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94 \ + --hash=sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51 \ + --hash=sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120 \ + --hash=sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039 \ + --hash=sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1 \ + --hash=sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05 \ + --hash=sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb \ + --hash=sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144 \ + --hash=sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa \ + --hash=sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a \ + --hash=sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99 \ + --hash=sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928 \ + --hash=sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d \ + --hash=sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3 \ + --hash=sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434 \ + --hash=sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86 \ + --hash=sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46 \ + --hash=sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319 \ + --hash=sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67 \ + --hash=sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c \ + --hash=sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169 \ + --hash=sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c \ + --hash=sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59 \ + --hash=sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107 \ + --hash=sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4 \ + --hash=sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a \ + --hash=sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb \ + --hash=sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f \ + --hash=sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769 \ + --hash=sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432 \ + --hash=sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090 \ + --hash=sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764 \ + --hash=sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d \ + --hash=sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4 \ + --hash=sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b \ + --hash=sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d \ + --hash=sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543 \ + --hash=sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24 \ + --hash=sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5 \ + --hash=sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b \ + --hash=sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d \ + --hash=sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b \ + --hash=sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6 \ + --hash=sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735 \ + --hash=sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e \ + --hash=sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28 \ + --hash=sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3 \ + --hash=sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401 \ + --hash=sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6 \ + --hash=sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d + # via gql diff --git a/actions/review_checklists/requirements.txt.in b/actions/review_checklists/requirements.txt.in new file mode 100644 index 000000000..e7c5322d0 --- /dev/null +++ b/actions/review_checklists/requirements.txt.in @@ -0,0 +1,18 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +bazel-runfiles==1.8.4 +gql[requests]>=3.5.0 +PyGithub>=2.1.1 +PyYAML>=6.0.1 +pytest==8.4.1 diff --git a/actions/review_checklists/scripts/BUILD b/actions/review_checklists/scripts/BUILD new file mode 100644 index 000000000..534d8c155 --- /dev/null +++ b/actions/review_checklists/scripts/BUILD @@ -0,0 +1,117 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +load("@review_checklists_dependencies//:requirements.bzl", "requirement") +load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test") + +py_library( + name = "helpers", + srcs = ["helpers.py"], + data = ["//.github:review_checklists_config"], + imports = ["."], + visibility = ["//actions/review_checklists:__subpackages__"], + deps = [ + requirement("bazel-runfiles"), + requirement("gql"), + requirement("pygithub"), + requirement("pyyaml"), + ], +) + +py_library( + name = "post_checklists_lib", + srcs = ["post_checklists.py"], + imports = ["."], + deps = [":helpers"], +) + +py_library( + name = "check_acknowledgements_lib", + srcs = ["check_acknowledgements.py"], + imports = ["."], + deps = [":helpers"], +) + +py_library( + name = "dismiss_and_invalidate_lib", + srcs = ["dismiss_and_invalidate.py"], + imports = ["."], + deps = [":helpers"], +) + +py_binary( + name = "post_checklists", + srcs = ["post_checklists.py"], + main = "post_checklists.py", + visibility = ["//visibility:public"], + deps = [":helpers"], +) + +py_binary( + name = "check_acknowledgements", + srcs = ["check_acknowledgements.py"], + main = "check_acknowledgements.py", + visibility = ["//visibility:public"], + deps = [":helpers"], +) + +py_binary( + name = "dismiss_and_invalidate", + srcs = ["dismiss_and_invalidate.py"], + main = "dismiss_and_invalidate.py", + visibility = ["//visibility:public"], + deps = [":helpers"], +) + +py_test( + name = "helpers_test", + srcs = ["helpers_test.py"], + imports = ["."], + deps = [ + ":helpers", + requirement("bazel-runfiles"), + requirement("pytest"), + ], +) + +py_test( + name = "post_checklists_test", + srcs = ["post_checklists_test.py"], + imports = ["."], + deps = [ + ":post_checklists_lib", + requirement("bazel-runfiles"), + requirement("pytest"), + ], +) + +py_test( + name = "check_acknowledgements_test", + srcs = ["check_acknowledgements_test.py"], + imports = ["."], + deps = [ + ":check_acknowledgements_lib", + requirement("bazel-runfiles"), + requirement("pytest"), + ], +) + +py_test( + name = "dismiss_and_invalidate_test", + srcs = ["dismiss_and_invalidate_test.py"], + imports = ["."], + deps = [ + ":dismiss_and_invalidate_lib", + requirement("bazel-runfiles"), + requirement("pytest"), + ], +) diff --git a/actions/review_checklists/scripts/__init__.py b/actions/review_checklists/scripts/__init__.py new file mode 100644 index 000000000..fe8d40d4a --- /dev/null +++ b/actions/review_checklists/scripts/__init__.py @@ -0,0 +1,13 @@ +# ******************************************************************************* +# Copyright (c) 2024 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + diff --git a/actions/review_checklists/scripts/check_acknowledgements.py b/actions/review_checklists/scripts/check_acknowledgements.py new file mode 100644 index 000000000..2a04390e3 --- /dev/null +++ b/actions/review_checklists/scripts/check_acknowledgements.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Verify that all relevant checklists have been acknowledged by every approving +reviewer, and set the commit status accordingly. + +An acknowledgement is a reply in the threaded conversation of a checklist +review comment (finding) that contains the ``OK`` keyword. This script: + +1. Enumerates relevant checklists for the PR. +2. For each checklist, finds the bot-posted review comment and its OK replies. +3. Builds a mapping: checklist-id → set of reviewers who said OK. +4. Compares against the set of approving reviewers. +5. Sets commit status to *success* only when every approving reviewer has + acknowledged every relevant checklist. Otherwise sets *pending* or + *failure*. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any + +from helpers import ( + _collect_acknowledgement_details, + OK_KEYWORD, + build_evidence_block, + find_existing_checklist_comments, + get_approving_reviewers, + get_changed_files, + get_github_client, + get_repo_and_pr, + load_checklists, + match_checklists, + set_commit_status, + update_pr_description_with_evidence, +) + + +def _collect_ok_acknowledgements( + pr: Any, existing_comments: dict[str, Any], relevant_ids: list[str] +) -> dict[str, set[str]]: + """Return a mapping of checklist_id → set of usernames who acknowledged. + + We inspect PR review comment replies (threaded conversations). A reply + counts as an OK for a checklist if: + - Its ``in_reply_to_id`` matches the checklist finding comment id, AND + - Its body (stripped, case-insensitive) equals the ``OK`` keyword. + + The conversation thread itself associates the reply with the checklist. + """ + acks: dict[str, set[str]] = {cid: set() for cid in relevant_ids} + + # Build a mapping of checklist comment id → checklist id. + cl_comment_ids: dict[int, str] = {} + for cid, comment in existing_comments.items(): + if cid in relevant_ids: + cl_comment_ids[comment.id] = cid + + # Walk all review comments looking for replies to checklist findings. + for comment in pr.get_review_comments(): + reply_to = getattr(comment, "in_reply_to_id", None) + if reply_to is None or reply_to not in cl_comment_ids: + continue + + cid = cl_comment_ids[reply_to] # type: ignore[index] + body = (comment.body or "").strip() + user = comment.user.login + + if body.upper() == OK_KEYWORD: + acks[cid].add(user) + + return acks + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Verify review-checklist acknowledgements on a PR." + ) + parser.add_argument( + "--config-path", + default=".github/review_checklists.yml", + help="Path to checklist configuration file (default: .github/review_checklists.yml)", + ) + args = parser.parse_args() + + gh = get_github_client() + event_name = os.environ.get("GITHUB_EVENT_NAME", "") + if event_name == "merge_group": + head_sha = os.environ.get("HEAD_SHA") + if not head_sha: + print("HEAD_SHA is required for merge_group events.") + sys.exit(1) + repo_name = os.environ["GITHUB_REPOSITORY"] + repo = gh.get_repo(repo_name) + set_commit_status( + repo, + head_sha, + "success", + "Merge queue: checklists assumed OK", + ) + return + + repo, pr = get_repo_and_pr(gh) + + checklists = load_checklists(args.config_path) + changed_files = get_changed_files(pr) + relevant = match_checklists(checklists, changed_files) + + if not relevant: + set_commit_status(repo, pr.head.sha, "success", "No checklists applicable") + return + + existing = find_existing_checklist_comments(pr) + relevant_ids = [cl["id"] for cl in relevant if cl["id"] in existing] + + if not relevant_ids: + # Checklists haven't been posted yet — keep pending. + set_commit_status( + repo, + pr.head.sha, + "pending", + "Checklist comments not yet posted", + ) + return + + acks = _collect_ok_acknowledgements(pr, existing, relevant_ids) + + # Refresh evidence block in PR description based on current acknowledgements. + ack_details = _collect_acknowledgement_details(pr, existing, relevant_ids) + evidence_block = build_evidence_block(relevant, ack_details) + update_pr_description_with_evidence(pr, evidence_block) + + approvers = get_approving_reviewers(pr) + + if not approvers: + set_commit_status( + repo, + pr.head.sha, + "pending", + "Awaiting at least one approving review", + ) + print("No approving reviewers yet.") + return + + # Check: every approver must have acknowledged every relevant checklist. + missing: dict[str, list[str]] = {} + for cid in relevant_ids: + not_acked = [u for u in approvers if u not in acks[cid]] + if not_acked: + missing[cid] = not_acked + + if missing: + summary_parts = [] + for cid, users in missing.items(): + summary_parts.append(f"{cid}: awaiting {', '.join(users)}") + summary = "; ".join(summary_parts) + set_commit_status( + repo, + pr.head.sha, + "pending", + summary, + ) + print(f"Missing acknowledgements: {summary}") + else: + set_commit_status( + repo, + pr.head.sha, + "success", + "All checklists acknowledged by all approving reviewers", + ) + print("All checklists acknowledged ✅") + + # Write acknowledgement data for downstream use (merge evidence). + ack_data = {cid: sorted(users) for cid, users in acks.items()} + runner_temp = os.environ.get("RUNNER_TEMP", "./") + output_path = os.environ.get( + "ACK_OUTPUT_PATH", runner_temp + "/checklist_acks.json" + ) + with open(output_path, "w") as f: + json.dump(ack_data, f, indent=2) + print(f"Acknowledgement data written to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/actions/review_checklists/scripts/check_acknowledgements_test.py b/actions/review_checklists/scripts/check_acknowledgements_test.py new file mode 100644 index 000000000..65fbc2a0c --- /dev/null +++ b/actions/review_checklists/scripts/check_acknowledgements_test.py @@ -0,0 +1,408 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Tests for check_acknowledgements.py.""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest +import sys + +from check_acknowledgements import _collect_ok_acknowledgements, main + + +def _make_comment(comment_id, body, user_login="reviewer", created_at=None): + c = MagicMock() + c.id = comment_id + c.body = body + c.user.login = user_login + c.created_at = created_at or datetime(2026, 1, 1, tzinfo=timezone.utc) + return c + + +def _make_file(filename): + f = MagicMock() + f.filename = filename + return f + + +def _make_review(user_login, state): + r = MagicMock() + r.user.login = user_login + r.state = state + return r + + +SAMPLE_CHECKLISTS = [ + { + "id": "api-review", + "name": "API Review", + "include": ["src/api/*.py"], + "checklist": "- [ ] Reviewed", + }, +] + + +# --------------------------------------------------------------------------- +# _collect_ok_acknowledgements +# --------------------------------------------------------------------------- + + +class TestCollectOkAcknowledgements: + def test_ok_reply_recognized(self): + """A reply with OK is recognized.""" + cl_comment = MagicMock(id=100) + ok_reply = _make_comment( + 101, + "OK", + "alice", + datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), + ) + ok_reply.in_reply_to_id = 100 + pr = MagicMock() + pr.get_review_comments.return_value = [ok_reply] + + existing = {"api-review": cl_comment} + acks = _collect_ok_acknowledgements(pr, existing, ["api-review"]) + assert "alice" in acks["api-review"] + + def test_bare_ok_reply_recognized(self): + """A bare 'OK' reply is recognized.""" + cl_comment = MagicMock(id=100) + ok_reply = _make_comment( + 101, + "OK", + "bob", + datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), + ) + ok_reply.in_reply_to_id = 100 + pr = MagicMock() + pr.get_review_comments.return_value = [ok_reply] + + existing = {"api-review": cl_comment} + acks = _collect_ok_acknowledgements(pr, existing, ["api-review"]) + assert "bob" in acks["api-review"] + + def test_reply_to_different_comment_ignored(self): + """A reply to a different comment is not counted.""" + cl_comment = MagicMock(id=100) + ok_reply = _make_comment( + 101, + "OK", + "bob", + datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), + ) + ok_reply.in_reply_to_id = 999 # different comment + pr = MagicMock() + pr.get_review_comments.return_value = [ok_reply] + + existing = {"api-review": cl_comment} + acks = _collect_ok_acknowledgements(pr, existing, ["api-review"]) + assert acks["api-review"] == set() + + def test_multiple_reviewers(self): + """Two reviewers both replying OK are collected.""" + cl_comment = MagicMock(id=100) + ok1 = _make_comment( + 101, + "OK", + "alice", + datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), + ) + ok1.in_reply_to_id = 100 + ok2 = _make_comment( + 102, + "OK", + "bob", + datetime(2026, 1, 1, 0, 2, tzinfo=timezone.utc), + ) + ok2.in_reply_to_id = 100 + pr = MagicMock() + pr.get_review_comments.return_value = [ok1, ok2] + + existing = {"api-review": cl_comment} + acks = _collect_ok_acknowledgements(pr, existing, ["api-review"]) + assert acks["api-review"] == {"alice", "bob"} + + def test_unrelated_reply_ignored(self): + """A reply that is not OK is not counted.""" + cl_comment = MagicMock(id=100) + normal = _make_comment( + 101, + "Looks good to me!", + "alice", + datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), + ) + normal.in_reply_to_id = 100 + pr = MagicMock() + pr.get_review_comments.return_value = [normal] + + existing = {"api-review": cl_comment} + acks = _collect_ok_acknowledgements(pr, existing, ["api-review"]) + assert acks["api-review"] == set() + + +# --------------------------------------------------------------------------- +# main() +# --------------------------------------------------------------------------- + + +class TestCheckAcknowledgementsMain: + @patch("check_acknowledgements.set_commit_status") + @patch( + "check_acknowledgements.load_checklists", + return_value=SAMPLE_CHECKLISTS, + ) + @patch("check_acknowledgements.get_repo_and_pr") + @patch("check_acknowledgements.get_github_client") + def test_no_relevant_checklists_sets_success( + self, mock_gh, mock_repo_pr, mock_load, mock_status + ): + repo = MagicMock() + pr = MagicMock() + pr.head.sha = "abc" + pr.get_files.return_value = [_make_file("unrelated.txt")] + mock_repo_pr.return_value = (repo, pr) + # Ensure the argument parser doesn't see pytest's CLI arguments. + with patch.object(sys, "argv", ["check_acknowledgements"]): + main() + mock_status.assert_called_once_with( + repo, "abc", "success", "No checklists applicable" + ) + + @patch("check_acknowledgements.set_commit_status") + @patch( + "check_acknowledgements.find_existing_checklist_comments", + return_value={}, + ) + @patch( + "check_acknowledgements.load_checklists", + return_value=SAMPLE_CHECKLISTS, + ) + @patch("check_acknowledgements.get_repo_and_pr") + @patch("check_acknowledgements.get_github_client") + def test_no_existing_comments_sets_pending( + self, mock_gh, mock_repo_pr, mock_load, mock_existing, mock_status + ): + repo = MagicMock() + pr = MagicMock() + pr.head.sha = "abc" + pr.get_files.return_value = [_make_file("src/api/foo.py")] + mock_repo_pr.return_value = (repo, pr) + + # Ensure the argument parser doesn't see pytest's CLI arguments. + with patch.object(sys, "argv", ["check_acknowledgements"]): + main() + + mock_status.assert_called_once_with( + repo, "abc", "pending", "Checklist comments not yet posted" + ) + + @patch("check_acknowledgements.set_commit_status") + @patch("check_acknowledgements.get_approving_reviewers", return_value=[]) + @patch( + "check_acknowledgements.load_checklists", + return_value=SAMPLE_CHECKLISTS, + ) + @patch("check_acknowledgements.get_repo_and_pr") + @patch("check_acknowledgements.get_github_client") + def test_no_approvers_sets_pending( + self, mock_gh, mock_repo_pr, mock_load, mock_approvers, mock_status + ): + repo = MagicMock() + pr = MagicMock() + pr.head.sha = "abc" + pr.get_files.return_value = [_make_file("src/api/foo.py")] + + cl_review = MagicMock() + cl_review.id = 100 + cl_review.body = "" + pr.get_review_comments.return_value = [] + mock_repo_pr.return_value = (repo, pr) + + with ( + patch( + "check_acknowledgements.find_existing_checklist_comments", + return_value={"api-review": cl_review}, + ), + # Ensure the argument parser doesn't see pytest's CLI arguments. + patch.object(sys, "argv", ["check_acknowledgements"]), + ): + main() + + mock_status.assert_called_with( + repo, "abc", "pending", "Awaiting at least one approving review" + ) + + @patch("check_acknowledgements.set_commit_status") + @patch( + "check_acknowledgements.get_approving_reviewers", + return_value=["alice"], + ) + @patch( + "check_acknowledgements.load_checklists", + return_value=SAMPLE_CHECKLISTS, + ) + @patch("check_acknowledgements.get_repo_and_pr") + @patch("check_acknowledgements.get_github_client") + def test_all_acked_sets_success( + self, + mock_gh, + mock_repo_pr, + mock_load, + mock_approvers, + mock_status, + tmp_path, + ): + repo = MagicMock() + pr = MagicMock() + pr.head.sha = "abc" + pr.get_files.return_value = [_make_file("src/api/foo.py")] + + cl_review = MagicMock() + cl_review.id = 100 + cl_review.body = "" + + ok_reply = _make_comment( + 101, + "OK", + "alice", + datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), + ) + ok_reply.in_reply_to_id = 100 + pr.get_review_comments.return_value = [ok_reply] + mock_repo_pr.return_value = (repo, pr) + + ack_path = str(tmp_path / "acks.json") + + with ( + patch( + "check_acknowledgements.find_existing_checklist_comments", + return_value={"api-review": cl_review}, + ), + patch.dict(os.environ, {"ACK_OUTPUT_PATH": ack_path}), + # Ensure the argument parser doesn't see pytest's CLI arguments. + patch.object(sys, "argv", ["check_acknowledgements"]), + ): + main() + + # The last call should be success. + mock_status.assert_called_with( + repo, + "abc", + "success", + "All checklists acknowledged by all approving reviewers", + ) + # Check that ack data was written. + with open(ack_path) as f: + data = json.load(f) + assert data["api-review"] == ["alice"] + + @patch("check_acknowledgements.set_commit_status") + @patch( + "check_acknowledgements.get_approving_reviewers", + return_value=["alice", "bob"], + ) + @patch( + "check_acknowledgements.load_checklists", + return_value=SAMPLE_CHECKLISTS, + ) + @patch("check_acknowledgements.get_repo_and_pr") + @patch("check_acknowledgements.get_github_client") + def test_missing_ack_sets_pending( + self, + mock_gh, + mock_repo_pr, + mock_load, + mock_approvers, + mock_status, + tmp_path, + ): + repo = MagicMock() + pr = MagicMock() + pr.head.sha = "abc" + pr.get_files.return_value = [_make_file("src/api/foo.py")] + + cl_review = MagicMock() + cl_review.id = 100 + cl_review.body = "" + + # Only alice acked, bob did not. + ok_reply = _make_comment( + 101, + "OK", + "alice", + datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), + ) + ok_reply.in_reply_to_id = 100 + pr.get_review_comments.return_value = [ok_reply] + mock_repo_pr.return_value = (repo, pr) + + ack_path = str(tmp_path / "acks.json") + + with ( + patch( + "check_acknowledgements.find_existing_checklist_comments", + return_value={"api-review": cl_review}, + ), + patch.dict(os.environ, {"ACK_OUTPUT_PATH": ack_path}), + # Ensure the argument parser doesn't see pytest's CLI arguments. + patch.object(sys, "argv", ["check_acknowledgements"]), + ): + main() + + mock_status.assert_called_with( + repo, "abc", "pending", "api-review: awaiting bob" + ) + + @patch("check_acknowledgements.set_commit_status") + @patch("check_acknowledgements.get_repo_and_pr") + @patch("check_acknowledgements.get_github_client") + def test_merge_group_sets_success_without_pr_lookup( + self, mock_gh, mock_repo_pr, mock_status + ): + repo = MagicMock() + gh = MagicMock() + gh.get_repo.return_value = repo + mock_gh.return_value = gh + + with ( + patch.dict( + os.environ, + { + "GITHUB_EVENT_NAME": "merge_group", + "HEAD_SHA": "merge123", + "GITHUB_REPOSITORY": "acme/widgets", + }, + ), + # Ensure the argument parser doesn't see pytest's CLI arguments. + patch.object(sys, "argv", ["check_acknowledgements"]), + ): + main() + + mock_repo_pr.assert_not_called() + mock_status.assert_called_once_with( + repo, + "merge123", + "success", + "Merge queue: checklists assumed OK", + ) + + +if __name__ == "__main__": + sys.exit(pytest.main(sys.argv[1:])) diff --git a/actions/review_checklists/scripts/dismiss_and_invalidate.py b/actions/review_checklists/scripts/dismiss_and_invalidate.py new file mode 100644 index 000000000..ecec30d0c --- /dev/null +++ b/actions/review_checklists/scripts/dismiss_and_invalidate.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +# ******************************************************************************* +# Copyright (c) 2024 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Invalidate OK acknowledgements. + +This script handles two scenarios: + +1. **New push (synchronize)**: When new commits are pushed to the PR, we + determine which checklist paths are affected by the *new* changes. For + each affected checklist, all existing OK replies are deleted and the + commit status is set back to pending. Approvals are **not** dismissed + here — branch rulesets handle dismissing stale reviews on new pushes. + +2. **OK modified or deleted**: When a reviewer edits or deletes their OK + comment, the commit status is set back to pending so the checklist + must be re-acknowledged. The reviewer's approval is **not** dismissed. + +The script is invoked with a ``--trigger`` argument: + + python dismiss_and_invalidate.py --trigger synchronize + python dismiss_and_invalidate.py --trigger comment_changed +""" + +from __future__ import annotations + +import argparse +import json +import os +from typing import Any + +from helpers import ( + OK_KEYWORD, + ensure_merge_queue_notice_comment, + ensure_merge_queue_notice_description, + find_existing_checklist_comments, + get_changed_files, + get_github_client, + get_repo_and_pr, + is_pr_in_merge_queue, + load_checklists, + match_checklists, + set_commit_status, +) + + +def _get_files_in_latest_push(pr: Any) -> list[str]: + """Return files changed in the most recent push to the PR. + + For the ``synchronize`` event we ideally look at only the files changed + in the new commits. The GitHub event payload provides ``before`` and + ``after`` SHAs which can be compared. As a practical fallback we use + the full PR changed-file list. + """ + before_sha = os.environ.get("BEFORE_SHA", "") + after_sha = os.environ.get("AFTER_SHA", "") + + if before_sha and after_sha: + repo_name = os.environ["GITHUB_REPOSITORY"] + gh = get_github_client() + repo = gh.get_repo(repo_name) + comparison = repo.compare(before_sha, after_sha) + return [f.filename for f in comparison.files] + + # Fallback: treat all PR files as potentially changed. + return get_changed_files(pr) + + +def _find_ok_comments_for_checklist(pr: Any, checklist_comment_id: int) -> list[Any]: + """Find all OK reply comments for a given checklist. + + Checklist findings are posted as file-level PR review comments; OK + replies are threaded review comment replies whose ``in_reply_to_id`` + matches the checklist comment id and whose body equals the OK keyword. + """ + ok_comments = [] + + for comment in pr.get_review_comments(): + reply_to = getattr(comment, "in_reply_to_id", None) + if reply_to != checklist_comment_id: + continue + body = (comment.body or "").strip() + + if body.upper() == OK_KEYWORD: + ok_comments.append(comment) + + return ok_comments + + +def handle_synchronize(pr: Any, config_path: str) -> None: + """Handle new commits pushed to the PR. + + For each checklist whose covered paths were touched by the new push, + delete all OK replies and set the commit status back to pending. + Approvals are not dismissed — branch rulesets handle that. + """ + checklists = load_checklists(config_path) + new_files = _get_files_in_latest_push(pr) + affected = match_checklists(checklists, new_files) + + if not affected: + print("No checklist-relevant files changed in latest push.") + return + + existing = find_existing_checklist_comments(pr) + + any_invalidated = False + + for cl in affected: + cid = cl["id"] + if cid not in existing: + continue + + review = existing[cid] + ok_comments = _find_ok_comments_for_checklist(pr, review.id) + + for ok_comment in ok_comments: + user = ok_comment.user.login + any_invalidated = True + try: + ok_comment.delete() + print( + f"Deleted OK comment {ok_comment.id} from {user} " + f"for checklist '{review.id}'" + ) + except Exception as e: + print(f"Warning: could not delete comment {ok_comment.id}: {e}") + + if any_invalidated: + repo_name = os.environ["GITHUB_REPOSITORY"] + gh = get_github_client() + repo = gh.get_repo(repo_name) + set_commit_status( + repo, + pr.head.sha, + "pending", + "Checklist acknowledgements invalidated due to new changes", + ) + + if is_pr_in_merge_queue(pr): + ensure_merge_queue_notice_comment(pr) + ensure_merge_queue_notice_description(pr) + + +def handle_comment_changed(pr: Any) -> None: + """Handle an OK comment being edited or deleted. + + When a reviewer modifies or removes their OK, set the commit status + back to pending. The reviewer's approval is preserved. + """ + event_path = os.environ.get("GITHUB_EVENT_PATH", "") + if not event_path: + print("No event payload available.") + return + + with open(event_path) as f: + event = json.load(f) + + comment_body = event.get("comment", {}).get("body", "") + comment_user = event.get("comment", {}).get("user", {}).get("login", "") + action = event.get("action", "") + + if not comment_user: + print("Could not determine comment author.") + return + + # Check if this was an OK-related comment. + was_ok = False + if action == "deleted": + # For deleted comments the body is the content at time of deletion. + was_ok = comment_body.strip().upper() == OK_KEYWORD + elif action == "edited": + old_body = event.get("changes", {}).get("body", {}).get("from", "") + # If old body was OK but new body is not, this is a retraction. + old_is_ok = old_body.strip().upper() == OK_KEYWORD + new_is_ok = comment_body.strip().upper() == OK_KEYWORD + was_ok = old_is_ok and not new_is_ok + + if was_ok: + print(f"Checklist OK retracted by {comment_user} (approval preserved)") + + repo_name = os.environ["GITHUB_REPOSITORY"] + gh = get_github_client() + repo = gh.get_repo(repo_name) + set_commit_status( + repo, + pr.head.sha, + "pending", + f"Checklist OK retracted by {comment_user}", + ) + + if is_pr_in_merge_queue(pr): + ensure_merge_queue_notice_comment(pr) + ensure_merge_queue_notice_description(pr) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Invalidate OK acknowledgements.") + parser.add_argument( + "--trigger", + required=True, + choices=["synchronize", "comment_changed"], + help="The event trigger type.", + ) + parser.add_argument( + "--config-path", + default=".github/review_checklists.yml", + help="Path to checklist configuration file (default: .github/review_checklists.yml)", + ) + args = parser.parse_args() + + gh = get_github_client() + _, pr = get_repo_and_pr(gh) + + if args.trigger == "synchronize": + handle_synchronize(pr, args.config_path) + elif args.trigger == "comment_changed": + handle_comment_changed(pr) + + +if __name__ == "__main__": + main() diff --git a/actions/review_checklists/scripts/dismiss_and_invalidate_test.py b/actions/review_checklists/scripts/dismiss_and_invalidate_test.py new file mode 100644 index 000000000..b54986f5c --- /dev/null +++ b/actions/review_checklists/scripts/dismiss_and_invalidate_test.py @@ -0,0 +1,363 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Tests for dismiss_and_invalidate.py.""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest +import sys + +from dismiss_and_invalidate import ( + _find_ok_comments_for_checklist, + handle_comment_changed, + handle_synchronize, +) + + +def _make_comment(comment_id, body, user_login="reviewer", created_at=None): + c = MagicMock() + c.id = comment_id + c.body = body + c.user.login = user_login + c.created_at = created_at or datetime(2026, 1, 1, tzinfo=timezone.utc) + return c + + +def _make_file(filename): + f = MagicMock() + f.filename = filename + return f + + +def _make_review(user_login, state, review_id=1): + r = MagicMock() + r.user.login = user_login + r.state = state + r.id = review_id + return r + + +SAMPLE_CHECKLISTS = [ + { + "id": "api-review", + "name": "API Review", + "include": ["src/api/*.py"], + "checklist": "- [ ] Reviewed", + }, +] + + +# --------------------------------------------------------------------------- +# _find_ok_comments_for_checklist +# --------------------------------------------------------------------------- + + +class TestFindOkCommentsForChecklist: + def test_finds_marker_ok_reply(self): + ok = _make_comment( + 101, + "OK", + "alice", + datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), + ) + ok.in_reply_to_id = 100 + pr = MagicMock() + pr.get_review_comments.return_value = [ok] + + result = _find_ok_comments_for_checklist(pr, 100) + assert len(result) == 1 + assert result[0].id == 101 + + def test_finds_bare_ok_reply(self): + ok = _make_comment( + 101, + "OK", + "alice", + datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), + ) + ok.in_reply_to_id = 100 + pr = MagicMock() + pr.get_review_comments.return_value = [ok] + + result = _find_ok_comments_for_checklist(pr, 100) + assert len(result) == 1 + + def test_ignores_reply_to_different_comment(self): + ok = _make_comment( + 101, + "OK", + "alice", + datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), + ) + ok.in_reply_to_id = 999 # different checklist + pr = MagicMock() + pr.get_review_comments.return_value = [ok] + + result = _find_ok_comments_for_checklist(pr, 100) + assert len(result) == 0 + + def test_ignores_unrelated_reply(self): + normal = _make_comment( + 101, + "looks good", + "alice", + datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), + ) + normal.in_reply_to_id = 100 + pr = MagicMock() + pr.get_review_comments.return_value = [normal] + + result = _find_ok_comments_for_checklist(pr, 100) + assert len(result) == 0 + + +# --------------------------------------------------------------------------- +# handle_synchronize +# --------------------------------------------------------------------------- + + +class TestHandleSynchronize: + @patch("dismiss_and_invalidate.ensure_merge_queue_notice_description") + @patch("dismiss_and_invalidate.ensure_merge_queue_notice_comment") + @patch("dismiss_and_invalidate.is_pr_in_merge_queue", return_value=False) + @patch("dismiss_and_invalidate.set_commit_status") + @patch("dismiss_and_invalidate.get_github_client") + @patch("dismiss_and_invalidate.load_checklists", return_value=SAMPLE_CHECKLISTS) + def test_no_affected_checklists( + self, + mock_load, + mock_gh, + mock_status, + mock_in_queue, + mock_notice_comment, + mock_notice_description, + monkeypatch, + ): + monkeypatch.delenv("BEFORE_SHA", raising=False) + monkeypatch.delenv("AFTER_SHA", raising=False) + + pr = MagicMock() + pr.get_files.return_value = [_make_file("unrelated.txt")] + + handle_synchronize(pr, ".github/review_checklists.yml") + + mock_status.assert_not_called() + mock_notice_comment.assert_not_called() + mock_notice_description.assert_not_called() + + @patch("dismiss_and_invalidate.ensure_merge_queue_notice_description") + @patch("dismiss_and_invalidate.ensure_merge_queue_notice_comment") + @patch("dismiss_and_invalidate.is_pr_in_merge_queue", return_value=True) + @patch("dismiss_and_invalidate.set_commit_status") + @patch("dismiss_and_invalidate.get_github_client") + @patch("dismiss_and_invalidate.load_checklists", return_value=SAMPLE_CHECKLISTS) + def test_deletes_ok_without_dismissing( + self, + mock_load, + mock_gh, + mock_status, + mock_in_queue, + mock_notice_comment, + mock_notice_description, + monkeypatch, + ): + monkeypatch.delenv("BEFORE_SHA", raising=False) + monkeypatch.delenv("AFTER_SHA", raising=False) + monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") + + pr = MagicMock() + pr.head.sha = "newsha" + pr.get_files.return_value = [_make_file("src/api/handler.py")] + + cl_comment = MagicMock() + cl_comment.id = 100 + cl_comment.body = "" + + ok_reply = _make_comment( + 101, + "OK", + "alice", + datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), + ) + ok_reply.in_reply_to_id = 100 + pr.get_review_comments.return_value = [ok_reply] + + review = _make_review("alice", "APPROVED", 42) + pr.get_reviews.return_value = [review] + + with patch( + "dismiss_and_invalidate.find_existing_checklist_comments", + return_value={"api-review": cl_comment}, + ): + handle_synchronize(pr, ".github/review_checklists.yml") + + ok_reply.delete.assert_called_once() + review.dismiss.assert_not_called() + mock_status.assert_called_once() + mock_notice_comment.assert_called_once_with(pr) + mock_notice_description.assert_called_once_with(pr) + + +# --------------------------------------------------------------------------- +# handle_comment_changed +# --------------------------------------------------------------------------- + + +class TestHandleCommentChanged: + def test_no_event_path(self, capsys): + pr = MagicMock() + with patch.dict(os.environ, {"GITHUB_EVENT_PATH": ""}): + handle_comment_changed(pr) + assert "No event payload" in capsys.readouterr().out + + @patch("dismiss_and_invalidate.set_commit_status") + @patch("dismiss_and_invalidate.get_github_client") + @patch("dismiss_and_invalidate.ensure_merge_queue_notice_description") + @patch("dismiss_and_invalidate.ensure_merge_queue_notice_comment") + @patch("dismiss_and_invalidate.is_pr_in_merge_queue", return_value=True) + def test_deleted_ok_sets_pending_without_dismissing( + self, + mock_in_queue, + mock_notice_comment, + mock_notice_description, + mock_gh, + mock_status, + tmp_path, + monkeypatch, + ): + monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") + + event = { + "action": "deleted", + "comment": { + "body": "OK", + "user": {"login": "alice"}, + }, + } + event_file = tmp_path / "event.json" + event_file.write_text(json.dumps(event)) + + pr = MagicMock() + pr.head.sha = "sha123" + review = _make_review("alice", "APPROVED", 10) + pr.get_reviews.return_value = [review] + + with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(event_file)}): + handle_comment_changed(pr) + + review.dismiss.assert_not_called() + mock_status.assert_called_once() + mock_notice_comment.assert_called_once_with(pr) + mock_notice_description.assert_called_once_with(pr) + + @patch("dismiss_and_invalidate.ensure_merge_queue_notice_description") + @patch("dismiss_and_invalidate.ensure_merge_queue_notice_comment") + @patch("dismiss_and_invalidate.is_pr_in_merge_queue", return_value=False) + @patch("dismiss_and_invalidate.set_commit_status") + @patch("dismiss_and_invalidate.get_github_client") + def test_edited_ok_retraction_sets_pending_without_dismissing( + self, + mock_gh, + mock_status, + mock_in_queue, + mock_notice_comment, + mock_notice_description, + tmp_path, + monkeypatch, + ): + monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") + + event = { + "action": "edited", + "comment": { + "body": "never mind", + "user": {"login": "bob"}, + }, + "changes": { + "body": {"from": "OK"}, + }, + } + event_file = tmp_path / "event.json" + event_file.write_text(json.dumps(event)) + + pr = MagicMock() + pr.head.sha = "sha456" + review = _make_review("bob", "APPROVED", 20) + pr.get_reviews.return_value = [review] + + with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(event_file)}): + handle_comment_changed(pr) + + review.dismiss.assert_not_called() + mock_status.assert_called_once() + mock_notice_comment.assert_not_called() + mock_notice_description.assert_not_called() + + @patch("dismiss_and_invalidate.set_commit_status") + @patch("dismiss_and_invalidate.get_github_client") + def test_edited_non_ok_does_not_dismiss( + self, mock_gh, mock_status, tmp_path, monkeypatch + ): + monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") + + event = { + "action": "edited", + "comment": { + "body": "updated comment", + "user": {"login": "bob"}, + }, + "changes": { + "body": {"from": "original non-ok comment"}, + }, + } + event_file = tmp_path / "event.json" + event_file.write_text(json.dumps(event)) + + pr = MagicMock() + pr.head.sha = "sha789" + pr.get_reviews.return_value = [] + + with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(event_file)}): + handle_comment_changed(pr) + + mock_status.assert_not_called() + + def test_deleted_non_ok_does_not_dismiss(self, tmp_path): + event = { + "action": "deleted", + "comment": { + "body": "just a regular comment", + "user": {"login": "bob"}, + }, + } + event_file = tmp_path / "event.json" + event_file.write_text(json.dumps(event)) + + pr = MagicMock() + pr.get_reviews.return_value = [] + + with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(event_file)}): + handle_comment_changed(pr) + + # No dismiss should have been called (no reviews to dismiss anyway). + # The key assertion is that no exception was raised. + + +if __name__ == "__main__": + sys.exit(pytest.main(sys.argv[1:])) diff --git a/actions/review_checklists/scripts/helpers.py b/actions/review_checklists/scripts/helpers.py new file mode 100644 index 000000000..2cab94cbc --- /dev/null +++ b/actions/review_checklists/scripts/helpers.py @@ -0,0 +1,515 @@ +# ******************************************************************************* +# Copyright (c) 2024 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Shared helpers for review-checklist scripts.""" + +from __future__ import annotations + +import fnmatch +import os +from typing import Any + +import yaml +from github import Github +from github.PullRequest import PullRequest + +# Marker prefix used to identify bot-managed checklist reviews. +CHECKLIST_MARKER = "" + +# The keyword a reviewer must post to acknowledge a checklist. +OK_KEYWORD = "OK" + + +def _get_github_token() -> str: + """Return the GitHub token.""" + return os.environ["GITHUB_TOKEN"] + + +def get_github_client() -> Github: + """Return an authenticated PyGithub client.""" + token = _get_github_token() + return Github(token) + + +def get_repo_and_pr(gh: Github) -> tuple[Any, PullRequest]: + """Return the repository and pull-request objects from environment.""" + repo_name = os.environ["GITHUB_REPOSITORY"] + pr_number = int(os.environ["PR_NUMBER"]) + repo = gh.get_repo(repo_name) + pr = repo.get_pull(pr_number) + return repo, pr + + +def _find_checklists_config( + config_relpath: str = ".github/review_checklists.yml", +) -> str: + """Locate checklist config via runfiles or path heuristics. + + Args: + config_relpath: Relative path to the checklist config file. + Defaults to '.github/review_checklists.yml'. + + Returns: + Absolute path to the config file. + + Raises: + FileNotFoundError: If the config file cannot be located. + """ + # 1. Bazel runfiles via the bazel-runfiles library (Rlocation API). + try: + from runfiles import Runfiles # type: ignore[import-untyped] + + r = Runfiles.Create() + if r: + candidate = r.Rlocation(f"_main/{config_relpath}") + if candidate and os.path.isfile(candidate): + return candidate + except (ImportError, Exception): + pass + + # 2. Fallback: relative to working directory (works outside Bazel). + if os.path.isfile(config_relpath): + return config_relpath + + raise FileNotFoundError(f"Cannot locate {config_relpath}") + + +def load_checklists( + config_relpath: str = ".github/review_checklists.yml", +) -> list[dict]: + """Load checklist definitions from the YAML configuration file. + + Args: + config_relpath: Relative path to the checklist config file. + Defaults to '.github/review_checklists.yml'. + + Returns: + List of checklist definitions. + """ + config_path = _find_checklists_config(config_relpath) + with open(config_path, "r") as f: + data = yaml.safe_load(f) + return data["checklists"] + + +def get_changed_files(pr: PullRequest) -> list[str]: + """Return the list of files changed in the pull request.""" + return [f.filename for f in pr.get_files()] + + +def _file_matches_patterns(filepath: str, patterns: list[str]) -> bool: + """Return True if filepath matches any of the given glob patterns.""" + return any(fnmatch.fnmatch(filepath, p) for p in patterns) + + +def match_checklists(checklists: list[dict], changed_files: list[str]) -> list[dict]: + """Return checklists whose path patterns match at least one changed file. + + Each checklist may have: + ``include``: list of glob patterns; a file must match at least one. + ``exclude``: (optional) list of glob patterns; matching files are removed. + + Each returned checklist dict is augmented with a ``matched_files`` key + containing the list of changed files that triggered the match. + """ + relevant = [] + for cl in checklists: + include_patterns: list[str] = cl.get("include", []) + exclude_patterns: list[str] = cl.get("exclude", []) + + matched = set() + for filepath in changed_files: + if _file_matches_patterns(filepath, include_patterns): + if not _file_matches_patterns(filepath, exclude_patterns): + matched.add(filepath) + + if matched: + cl_copy = dict(cl) + cl_copy["matched_files"] = sorted(matched) + relevant.append(cl_copy) + return relevant + + +def make_checklist_comment_body(checklist: dict) -> str: + """Build the Markdown body for a checklist PR review comment (finding).""" + marker = CHECKLIST_MARKER.format(checklist_id=checklist["id"]) + include_patterns = checklist.get("include", []) + exclude_patterns = checklist.get("exclude", []) + exclude_line = ( + f"**Excluding files matching:** `{'`, `'.join(exclude_patterns)}`\n\n" + if exclude_patterns + else "" + ) + body = ( + f"{marker}\n" + f"## 📋 {checklist['name']}\n\n" + f"**Checklist ID:** `{checklist['id']}`\n\n" + f"**Applicable to files matching:** `{'`, `'.join(include_patterns)}`\n\n" + f"{exclude_line}" + f"### Checklist\n\n" + f"{checklist['checklist'].strip()}\n\n" + f"---\n" + f"**To acknowledge this checklist, reply to this conversation " + f"with exactly `{OK_KEYWORD}`.** Each approving reviewer must " + f"acknowledge every applicable checklist before the PR can be merged.\n" + ) + return body + + +def find_existing_checklist_comments(pr: PullRequest) -> dict[str, Any]: + """Find existing bot-managed checklist review comments (findings) on the PR. + + Returns a dict mapping checklist-id → PullRequestComment object. + + Checklist findings are identified by the ``CHECKLIST_MARKER`` HTML comment + in their body. We search PR review comments (``get_review_comments()``) + because checklists are posted as file-level review comments that support + threaded conversations where reviewers can reply with OK. + """ + result = {} + for comment in pr.get_review_comments(): + body = comment.body or "" + prefix = "", start) + cid = body[start:end] + # Only keep top-level checklist comments (not replies). + if not getattr(comment, "in_reply_to_id", None): + result[cid] = comment + return result + + +def find_ok_replies( + pr: PullRequest, checklist_comment_id: int, checklist_id: str +) -> list[Any]: + """Find valid OK reply comments for a given checklist review comment. + + We look at PR review comment replies (threaded conversation) where + ``in_reply_to_id`` matches the checklist comment id. A reply counts + as an OK if its body (stripped, case-insensitive) equals the OK keyword. + The conversation thread itself associates the reply with the checklist. + """ + ok_replies = [] + for comment in pr.get_review_comments(): + reply_to = getattr(comment, "in_reply_to_id", None) + if reply_to != checklist_comment_id: + continue + body = (comment.body or "").strip() + if body.upper() == OK_KEYWORD: + ok_replies.append(comment) + return ok_replies + + +def _collect_acknowledgement_details( + pr: PullRequest, existing_comments: dict[str, Any], relevant_ids: list[str] +) -> dict[str, list[dict[str, str]]]: + """Return acknowledgement details for relevant checklist review threads.""" + details: dict[str, list[dict[str, str]]] = {cid: [] for cid in relevant_ids} + + cl_comment_ids: dict[int, str] = {} + for cid, comment in existing_comments.items(): + if cid in relevant_ids: + cl_comment_ids[comment.id] = cid + + for comment in pr.get_review_comments(): + reply_to = getattr(comment, "in_reply_to_id", None) + if not isinstance(reply_to, int) or reply_to not in cl_comment_ids: + continue + + cid = cl_comment_ids[reply_to] + body = (comment.body or "").strip() + user = comment.user.login + + if body.upper() == OK_KEYWORD: + details[cid].append( + { + "reviewer": user, + "acknowledged_at": comment.created_at.isoformat(), + } + ) + + return details + + +def get_approving_reviewers(pr: PullRequest) -> list[str]: + """Return a list of usernames who have an active APPROVED review.""" + approvers = set() + for review in pr.get_reviews(): + if review.state == "APPROVED": + approvers.add(review.user.login) + elif review.state in ("CHANGES_REQUESTED", "DISMISSED"): + approvers.discard(review.user.login) + return sorted(approvers) + + +def set_commit_status( + repo: Any, + sha: str, + state: str, + description: str, + context: str = "review-checklists", +) -> None: + """Set a commit status on the given SHA.""" + desc = description[:140] + print( + f"Setting commit status: context='{context}', state='{state}', " + f"sha='{sha}', description='{desc}'" + ) + repo.get_commit(sha).create_status( + state=state, + description=desc, + context=context, + ) + print("Commit status set successfully.") + + +# Evidence block markers for PR description +EVIDENCE_BLOCK_START = "" +EVIDENCE_BLOCK_END = "" + +# Standalone merge-queue notice block in PR description. +MERGE_QUEUE_NOTICE_START = "" +MERGE_QUEUE_NOTICE_END = "" + +# Marker for a bot-managed PR comment carrying the same notice. +MERGE_QUEUE_COMMENT_MARKER = "" + +MERGE_QUEUE_NOTICE = [ + MERGE_QUEUE_NOTICE_START, + "## Review Checklist Evidence Notice - Merge Queue", + "", + "This pull request was modified after the review checklist evidence was recorded.", + "The review checklist evidence visible here does no longer reflect the evidence that will be recorded at merge.", + "Please rely on the evidence in the git history once the pull request was merged.", + "", + "The git history shows the evidence state at the time of merge queue entry.", + "A pull request may only enter the merge queue when all necessary review checklist acknowledgements are in place.", + "Changes made after this pull request enters the merge queue may update the evidence here,", + "but they do not affect the evidence recorded in the git history.", + MERGE_QUEUE_NOTICE_END, +] + + +def extract_evidence_block(description: str) -> str | None: + """Extract the evidence block from PR description, or None if not present.""" + if EVIDENCE_BLOCK_START not in description: + return None + try: + start = description.index(EVIDENCE_BLOCK_START) + end = description.index(EVIDENCE_BLOCK_END) + return description[start : end + len(EVIDENCE_BLOCK_END)] + except ValueError: + return None + + +def remove_evidence_block(description: str) -> str: + """Remove the evidence block from PR description.""" + if EVIDENCE_BLOCK_START not in description: + return description + try: + start = description.index(EVIDENCE_BLOCK_START) + end = description.index(EVIDENCE_BLOCK_END) + len(EVIDENCE_BLOCK_END) + # Remove the evidence block and any trailing whitespace + result = description[:start] + description[end:] + return result.rstrip() + "\n" + except ValueError: + return description + + +def build_evidence_block( + relevant: list[dict], + ack_details: dict[str, list[dict[str, str]]], +) -> str: + """Build the evidence block for the PR description.""" + from datetime import datetime, timezone + + lines = [ + EVIDENCE_BLOCK_START, + "
", + "Checklist Report (do not modify)", + "", + "## Review Checklist Evidence", + "", + f"**Last updated:** {datetime.now(timezone.utc).isoformat()}", + "", + ] + + for cl in relevant: + cid = cl["id"] + lines.append(f"### {cl['name']} (`{cid}`)") + lines.append("") + + acks = ack_details.get(cid, []) + if acks: + lines.append("**Acknowledged by:**") + for ack in acks: + lines.append(f"- {ack['reviewer']} at {ack['acknowledged_at']}") + else: + lines.append("**Acknowledged by:** No acknowledgements yet") + lines.append("") + + lines += ["
", EVIDENCE_BLOCK_END] + return "\n".join(lines) + + +def update_pr_description_with_evidence( + pr: Any, + evidence_block: str, +) -> None: + """Update PR description to include/replace evidence block.""" + current_description = pr.body or "" + + # Remove existing evidence block + new_description = remove_evidence_block(current_description) + + # Append new evidence block + new_description = new_description + "\n" + evidence_block + + # Only update if description changed + if new_description.strip() != current_description.strip(): + pr.edit(body=new_description) + print("Updated PR description with evidence block") + else: + print("PR description evidence is already up to date") + + +def is_pr_in_merge_queue(pr: Any) -> bool: + """Return whether the PR is currently in GitHub merge queue via GraphQL.""" + repo_name = getattr( + getattr(getattr(pr, "base", None), "repo", None), "full_name", "" + ) + if not repo_name or "/" not in repo_name: + repo_name = os.environ.get("GITHUB_REPOSITORY", "") + if "/" not in repo_name: + print("Could not determine repository for merge-queue lookup") + return False + + number = getattr(pr, "number", None) + if not number: + try: + number = int(os.environ.get("PR_NUMBER", "0")) + except ValueError: + number = 0 + if not number: + print("Could not determine PR number for merge-queue lookup") + return False + + owner, name = repo_name.split("/", 1) + query = """ + query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + isInMergeQueue + } + } + } + """ + + try: + result = _run_graphql_query( + query, + {"owner": owner, "name": name, "number": int(number)}, + ) + except Exception as exc: + print(f"GraphQL merge-queue lookup failed: {exc}") + return False + + is_in_queue = ( + result.get("data", {}) + .get("repository", {}) + .get("pullRequest", {}) + .get("isInMergeQueue") + ) + if isinstance(is_in_queue, bool): + return is_in_queue + + print("GraphQL merge-queue lookup returned no boolean state") + return False + + +def _build_merge_queue_notice_block() -> str: + """Return the standalone merge-queue notice for PR description.""" + return "\n".join(MERGE_QUEUE_NOTICE) + + +def _remove_merge_queue_notice_block(description: str) -> str: + """Remove the standalone merge-queue notice block from PR description.""" + if MERGE_QUEUE_NOTICE_START not in description: + return description + try: + start = description.index(MERGE_QUEUE_NOTICE_START) + end = description.index(MERGE_QUEUE_NOTICE_END) + len(MERGE_QUEUE_NOTICE_END) + result = description[:start] + description[end:] + return result.rstrip() + "\n" + except ValueError: + return description + + +def ensure_merge_queue_notice_description(pr: Any) -> None: + """Ensure a standalone merge-queue notice exists in the PR description.""" + current_description = pr.body or "" + notice_block = _build_merge_queue_notice_block() + description_without_notice = _remove_merge_queue_notice_block(current_description) + base = description_without_notice.rstrip() + if base: + new_description = base + "\n\n" + notice_block + else: + new_description = notice_block + + if new_description.strip() != current_description.strip(): + pr.edit(body=new_description) + print("Updated PR description with merge-queue evidence notice") + + +def ensure_merge_queue_notice_comment(pr: Any) -> None: + """Ensure the PR has a single bot-managed merge-queue evidence notice.""" + body = "\n".join([MERGE_QUEUE_COMMENT_MARKER] + MERGE_QUEUE_NOTICE) + + existing_comment = None + for comment in pr.get_issue_comments(): + comment_body = comment.body or "" + if MERGE_QUEUE_COMMENT_MARKER in comment_body: + existing_comment = comment + break + + if existing_comment is None: + pr.create_issue_comment(body) + print("Posted merge-queue evidence notice comment") + return + + if (existing_comment.body or "").strip() != body.strip(): + existing_comment.edit(body) + print("Updated merge-queue evidence notice comment") + + +def _run_graphql_query(query: str, variables: dict[str, Any]) -> dict[str, Any]: + """Execute a GitHub GraphQL query via gql and return JSON-like data.""" + from gql import Client, gql + from gql.transport.requests import RequestsHTTPTransport + + token = _get_github_token() + transport = RequestsHTTPTransport( + url="https://api.github.com/graphql", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + }, + use_json=True, + ) + client = Client(transport=transport, fetch_schema_from_transport=False) + data = client.execute(gql(query), variable_values=variables) + + if not isinstance(data, dict): + raise RuntimeError("Unexpected GraphQL response type") + return {"data": data} diff --git a/actions/review_checklists/scripts/helpers_test.py b/actions/review_checklists/scripts/helpers_test.py new file mode 100644 index 000000000..9878e78ab --- /dev/null +++ b/actions/review_checklists/scripts/helpers_test.py @@ -0,0 +1,701 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Tests for helpers.py.""" + +from __future__ import annotations + +import os +import sys +import types +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +import helpers +from helpers import ( + CHECKLIST_MARKER, + MERGE_QUEUE_COMMENT_MARKER, + MERGE_QUEUE_NOTICE, + MERGE_QUEUE_NOTICE_END, + MERGE_QUEUE_NOTICE_START, + OK_KEYWORD, + _collect_acknowledgement_details, + _find_checklists_config, + ensure_merge_queue_notice_comment, + ensure_merge_queue_notice_description, + find_existing_checklist_comments, + find_ok_replies, + get_approving_reviewers, + get_changed_files, + get_github_client, + get_repo_and_pr, + is_pr_in_merge_queue, + load_checklists, + make_checklist_comment_body, + match_checklists, + set_commit_status, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +SAMPLE_CHECKLISTS = [ + { + "id": "api-review", + "name": "API Review", + "include": ["src/api/*.py", "src/api/*.h"], + "checklist": "- [ ] APIs documented\n- [ ] Tests added", + }, + { + "id": "docs-review", + "name": "Documentation Review", + "include": ["docs/**"], + "checklist": "- [ ] Spelling checked", + }, + { + "id": "build-review", + "name": "Build Review", + "include": ["**/BUILD", "**/*.bzl"], + "checklist": "- [ ] Targets correct", + }, + { + "id": "com-review", + "name": "COM Review", + "include": ["score/mw/com/**"], + "exclude": ["score/mw/com/design/**", "score/mw/com/impl/**"], + "checklist": "- [ ] API reviewed", + }, +] + + +@pytest.fixture() +def sample_config(tmp_path): + """Write a sample review_checklists.yml and return its path.""" + cfg = tmp_path / "review_checklists.yml" + cfg.write_text(yaml.dump({"checklists": SAMPLE_CHECKLISTS})) + return str(cfg) + + +def _make_comment(comment_id, body, user_login="bot", created_at=None): + """Build a lightweight mock issue-comment.""" + from datetime import datetime, timezone + + c = MagicMock() + c.id = comment_id + c.body = body + c.user.login = user_login + c.created_at = created_at or datetime(2026, 1, 1, tzinfo=timezone.utc) + return c + + +def _make_review(user_login, state, review_id=1, body=None): + r = MagicMock() + r.user.login = user_login + r.state = state + r.id = review_id + r.body = body or "" + return r + + +# --------------------------------------------------------------------------- +# get_github_client / get_repo_and_pr +# --------------------------------------------------------------------------- + + +class TestGetGithubClient: + def test_reads_token_from_env(self, monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "ghp_test123") + with patch("helpers.Github") as mock_cls: + get_github_client() + mock_cls.assert_called_once_with("ghp_test123") + + def test_missing_token_raises(self, monkeypatch): + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + with pytest.raises(KeyError): + get_github_client() + + +class TestGetRepoAndPr: + def test_returns_repo_and_pr(self, monkeypatch): + monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") + monkeypatch.setenv("PR_NUMBER", "42") + gh = MagicMock() + repo, pr = get_repo_and_pr(gh) + gh.get_repo.assert_called_once_with("org/repo") + gh.get_repo.return_value.get_pull.assert_called_once_with(42) + + +# --------------------------------------------------------------------------- +# load_checklists +# --------------------------------------------------------------------------- + + +class TestLoadChecklists: + def test_load_from_explicit_path(self, sample_config): + with patch("helpers._find_checklists_config", return_value=sample_config): + result = load_checklists() + assert len(result) == 4 + assert result[0]["id"] == "api-review" + + def test_file_not_found_raises(self, monkeypatch, tmp_path): + monkeypatch.delenv("RUNFILES_DIR", raising=False) + monkeypatch.delenv("RUNFILES_MANIFEST_FILE", raising=False) + with patch( + "helpers._find_checklists_config", + side_effect=FileNotFoundError( + "Cannot locate .github/review_checklists.yml" + ), + ): + with pytest.raises(FileNotFoundError): + load_checklists() + + +# --------------------------------------------------------------------------- +# get_changed_files +# --------------------------------------------------------------------------- + + +class TestGetChangedFiles: + def test_returns_filenames(self): + file1 = MagicMock() + file1.filename = "src/api/foo.py" + file2 = MagicMock() + file2.filename = "docs/readme.md" + pr = MagicMock() + pr.get_files.return_value = [file1, file2] + assert get_changed_files(pr) == ["src/api/foo.py", "docs/readme.md"] + + +# --------------------------------------------------------------------------- +# match_checklists +# --------------------------------------------------------------------------- + + +class TestMatchChecklists: + def test_single_match(self): + files = ["src/api/handler.py"] + result = match_checklists(SAMPLE_CHECKLISTS, files) + assert len(result) == 1 + assert result[0]["id"] == "api-review" + assert result[0]["matched_files"] == ["src/api/handler.py"] + + def test_multiple_matches(self): + files = ["src/api/handler.py", "docs/guide.md"] + result = match_checklists(SAMPLE_CHECKLISTS, files) + ids = {r["id"] for r in result} + assert ids == {"api-review", "docs-review"} + + def test_no_match(self): + files = ["unrelated/file.txt"] + result = match_checklists(SAMPLE_CHECKLISTS, files) + assert result == [] + + def test_glob_double_star(self): + files = ["docs/nested/deep/file.md"] + result = match_checklists(SAMPLE_CHECKLISTS, files) + assert len(result) == 1 + assert result[0]["id"] == "docs-review" + + def test_build_glob(self): + files = ["some/path/BUILD"] + result = match_checklists(SAMPLE_CHECKLISTS, files) + assert len(result) == 1 + assert result[0]["id"] == "build-review" + + def test_multiple_files_same_checklist(self): + files = ["src/api/a.py", "src/api/b.h"] + result = match_checklists(SAMPLE_CHECKLISTS, files) + assert len(result) == 1 + assert set(result[0]["matched_files"]) == { + "src/api/a.py", + "src/api/b.h", + } + + def test_does_not_mutate_input(self): + files = ["src/api/handler.py"] + original_len = len(SAMPLE_CHECKLISTS[0]) + match_checklists(SAMPLE_CHECKLISTS, files) + assert len(SAMPLE_CHECKLISTS[0]) == original_len + + def test_exclude_removes_matching_files(self): + # score/mw/com/design/** is excluded from com-review + files = ["score/mw/com/design/foo.md"] + result = match_checklists(SAMPLE_CHECKLISTS, files) + ids = {r["id"] for r in result} + assert "com-review" not in ids + + def test_include_minus_exclude_leaves_remainder(self): + files = [ + "score/mw/com/foo.h", # included, not excluded + "score/mw/com/design/bar.md", # excluded + "score/mw/com/impl/baz.cpp", # excluded + ] + result = match_checklists(SAMPLE_CHECKLISTS, files) + com = next((r for r in result if r["id"] == "com-review"), None) + assert com is not None + assert com["matched_files"] == ["score/mw/com/foo.h"] + + def test_all_files_excluded_means_no_match(self): + files = ["score/mw/com/design/only.md", "score/mw/com/impl/only.cpp"] + result = match_checklists(SAMPLE_CHECKLISTS, files) + ids = {r["id"] for r in result} + assert "com-review" not in ids + + +# --------------------------------------------------------------------------- +# make_checklist_comment_body +# --------------------------------------------------------------------------- + + +class TestMakeChecklistCommentBody: + def test_contains_marker(self): + cl = SAMPLE_CHECKLISTS[0] + body = make_checklist_comment_body(cl) + expected_marker = CHECKLIST_MARKER.format(checklist_id="api-review") + assert expected_marker in body + + def test_contains_name(self): + cl = SAMPLE_CHECKLISTS[0] + body = make_checklist_comment_body(cl) + assert cl["name"] in body + + def test_contains_checklist_content(self): + cl = SAMPLE_CHECKLISTS[0] + body = make_checklist_comment_body(cl) + assert "APIs documented" in body + + def test_contains_ok_instruction(self): + cl = SAMPLE_CHECKLISTS[0] + body = make_checklist_comment_body(cl) + assert OK_KEYWORD in body + + def test_contains_paths(self): + cl = SAMPLE_CHECKLISTS[0] + body = make_checklist_comment_body(cl) + for p in cl["include"]: + assert p in body + + def test_contains_exclude_when_present(self): + cl = SAMPLE_CHECKLISTS[3] # com-review, has exclude + body = make_checklist_comment_body(cl) + for p in cl["exclude"]: + assert p in body + + def test_no_exclude_line_when_absent(self): + cl = SAMPLE_CHECKLISTS[0] # api-review, no exclude + body = make_checklist_comment_body(cl) + assert "Excluding" not in body + + +# --------------------------------------------------------------------------- +# find_existing_checklist_comments +# --------------------------------------------------------------------------- + + +class TestFindExistingChecklistComments: + def test_finds_checklist_review_comments(self): + c1 = _make_comment(1, " body here") + c1.in_reply_to_id = None + c2 = _make_comment(2, "just a normal comment") + c2.in_reply_to_id = None + c3 = _make_comment(3, " docs body") + c3.in_reply_to_id = None + pr = MagicMock() + pr.get_review_comments.return_value = [c1, c2, c3] + + result = find_existing_checklist_comments(pr) + assert set(result.keys()) == {"api-review", "docs-review"} + assert result["api-review"].id == 1 + assert result["docs-review"].id == 3 + + def test_ignores_reply_comments(self): + """A reply to a checklist comment should not be treated as a checklist.""" + c1 = _make_comment(1, " body") + c1.in_reply_to_id = None + c2 = _make_comment(2, " reply copy") + c2.in_reply_to_id = 1 # this is a reply + pr = MagicMock() + pr.get_review_comments.return_value = [c1, c2] + + result = find_existing_checklist_comments(pr) + assert len(result) == 1 + assert result["api-review"].id == 1 + + def test_returns_empty_when_none(self): + pr = MagicMock() + c1 = _make_comment(1, "nothing here") + c1.in_reply_to_id = None + pr.get_review_comments.return_value = [c1] + assert find_existing_checklist_comments(pr) == {} + + +# --------------------------------------------------------------------------- +# find_ok_replies +# --------------------------------------------------------------------------- + + +class TestFindOkReplies: + def test_finds_ok_reply(self): + c1 = _make_comment(10, "checklist body", "bot") + c1.in_reply_to_id = None + c2 = _make_comment(11, "OK", "reviewer1") + c2.in_reply_to_id = 10 + pr = MagicMock() + pr.get_review_comments.return_value = [c1, c2] + + result = find_ok_replies(pr, 10, "api-review") + assert len(result) == 1 + assert result[0].id == 11 + + def test_finds_case_insensitive_ok(self): + for ok in ["OK", "ok", "oK", "Ok"]: + c1 = _make_comment(10, "checklist body", "bot") + c1.in_reply_to_id = None + c2 = _make_comment(11, ok, "reviewer1") + c2.in_reply_to_id = 10 + pr = MagicMock() + pr.get_review_comments.return_value = [c1, c2] + + result = find_ok_replies(pr, 10, "api-review") + assert len(result) == 1 + + def test_ignores_reply_to_different_comment(self): + c1 = _make_comment(11, "OK", "reviewer1") + c1.in_reply_to_id = 99 # different checklist + pr = MagicMock() + pr.get_review_comments.return_value = [c1] + + result = find_ok_replies(pr, 10, "api-review") + assert len(result) == 0 + + def test_ignores_unrelated_reply(self): + c1 = _make_comment(11, "looks good but not OK keyword", "reviewer1") + c1.in_reply_to_id = 10 + pr = MagicMock() + pr.get_review_comments.return_value = [c1] + + result = find_ok_replies(pr, 10, "api-review") + assert len(result) == 0 + + +# --------------------------------------------------------------------------- +# _collect_acknowledgement_details +# --------------------------------------------------------------------------- + + +class TestCollectAcknowledgementDetails: + def test_collects_ok_reply_details_for_relevant_checklists(self): + checklist_comment = _make_comment(10, "checklist body", "bot") + ok_reply = _make_comment(11, "OK", "reviewer1") + ok_reply.in_reply_to_id = 10 + other_reply = _make_comment(12, "looks good", "reviewer2") + other_reply.in_reply_to_id = 10 + unrelated_reply = _make_comment(13, "OK", "reviewer3") + unrelated_reply.in_reply_to_id = 999 + pr = MagicMock() + pr.get_review_comments.return_value = [ok_reply, other_reply, unrelated_reply] + + result = _collect_acknowledgement_details( + pr, + {"api-review": checklist_comment}, + ["api-review", "docs-review"], + ) + + assert result == { + "api-review": [ + { + "reviewer": "reviewer1", + "acknowledged_at": ok_reply.created_at.isoformat(), + } + ], + "docs-review": [], + } + + +# --------------------------------------------------------------------------- +# get_approving_reviewers +# --------------------------------------------------------------------------- + + +class TestGetApprovingReviewers: + def test_single_approver(self): + pr = MagicMock() + pr.get_reviews.return_value = [_make_review("alice", "APPROVED")] + assert get_approving_reviewers(pr) == ["alice"] + + def test_dismissed_not_counted(self): + pr = MagicMock() + pr.get_reviews.return_value = [ + _make_review("alice", "APPROVED"), + _make_review("alice", "DISMISSED"), + ] + assert get_approving_reviewers(pr) == [] + + def test_changes_requested_overrides(self): + pr = MagicMock() + pr.get_reviews.return_value = [ + _make_review("alice", "APPROVED"), + _make_review("alice", "CHANGES_REQUESTED"), + ] + assert get_approving_reviewers(pr) == [] + + def test_re_approval_after_changes_requested(self): + pr = MagicMock() + pr.get_reviews.return_value = [ + _make_review("alice", "APPROVED"), + _make_review("alice", "CHANGES_REQUESTED"), + _make_review("alice", "APPROVED"), + ] + assert get_approving_reviewers(pr) == ["alice"] + + def test_multiple_approvers_sorted(self): + pr = MagicMock() + pr.get_reviews.return_value = [ + _make_review("charlie", "APPROVED"), + _make_review("alice", "APPROVED"), + ] + assert get_approving_reviewers(pr) == ["alice", "charlie"] + + def test_no_reviews(self): + pr = MagicMock() + pr.get_reviews.return_value = [] + assert get_approving_reviewers(pr) == [] + + +# --------------------------------------------------------------------------- +# set_commit_status +# --------------------------------------------------------------------------- + + +class TestSetCommitStatus: + def test_creates_status(self): + repo = MagicMock() + set_commit_status(repo, "abc123", "success", "All good") + commit = repo.get_commit.return_value + commit.create_status.assert_called_once_with( + state="success", + description="All good", + context="review-checklists", + ) + + def test_truncates_long_description(self): + repo = MagicMock() + long_desc = "x" * 200 + set_commit_status(repo, "abc123", "pending", long_desc) + commit = repo.get_commit.return_value + call_kwargs = commit.create_status.call_args[1] + assert len(call_kwargs["description"]) == 140 + + def test_custom_context(self): + repo = MagicMock() + set_commit_status(repo, "abc123", "success", "ok", context="my-context") + commit = repo.get_commit.return_value + call_kwargs = commit.create_status.call_args[1] + assert call_kwargs["context"] == "my-context" + + +# --------------------------------------------------------------------------- +# _find_checklists_config +# --------------------------------------------------------------------------- + + +class TestFindChecklistsConfig: + def test_find_via_runfiles(self, tmp_path, monkeypatch): + cfg = tmp_path / "review_checklists.yml" + cfg.write_text(yaml.dump({"checklists": SAMPLE_CHECKLISTS})) + + class DummyRunfiles: + def __init__(self, path): + self._path = path + + def Rlocation(self, _): + return self._path + + @staticmethod + def Create(): + return DummyRunfiles(str(cfg)) + + runfiles_mod = types.ModuleType("runfiles") + runfiles_mod.Runfiles = DummyRunfiles + + monkeypatch.setitem(sys.modules, "runfiles", runfiles_mod) + assert _find_checklists_config() == str(cfg) + + def test_find_via_relative_fallback(self, monkeypatch): + class DummyRunfiles: + @staticmethod + def Create(): + return None + + runfiles_mod = types.ModuleType("runfiles") + runfiles_mod.Runfiles = DummyRunfiles + + monkeypatch.setitem(sys.modules, "runfiles", runfiles_mod) + # Test with default config path (.github/review_checklists.yml) + with patch("helpers.os.path.isfile", return_value=True): + result = _find_checklists_config() + assert result == ".github/review_checklists.yml" + + def test_find_via_custom_config_path(self, monkeypatch): + class DummyRunfiles: + @staticmethod + def Create(): + return None + + runfiles_mod = types.ModuleType("runfiles") + runfiles_mod.Runfiles = DummyRunfiles + + monkeypatch.setitem(sys.modules, "runfiles", runfiles_mod) + # Test with custom config path + with patch("helpers.os.path.isfile", return_value=True): + result = _find_checklists_config("custom/checklists.yml") + assert result == "custom/checklists.yml" + + +# --------------------------------------------------------------------------- +# merge-queue helpers +# --------------------------------------------------------------------------- + + +class TestIsPrInMergeQueue: + @patch("helpers._run_graphql_query") + def test_true_when_graphql_returns_true(self, mock_query): + mock_query.return_value = { + "data": {"repository": {"pullRequest": {"isInMergeQueue": True}}} + } + + pr = MagicMock() + pr.base.repo.full_name = "org/repo" + pr.number = 42 + + assert is_pr_in_merge_queue(pr) is True + mock_query.assert_called_once() + + @patch("helpers._run_graphql_query") + def test_false_when_graphql_returns_false(self, mock_query): + mock_query.return_value = { + "data": {"repository": {"pullRequest": {"isInMergeQueue": False}}} + } + + pr = MagicMock() + pr.base.repo.full_name = "org/repo" + pr.number = 7 + + assert is_pr_in_merge_queue(pr) is False + + @patch("helpers._run_graphql_query") + def test_false_when_graphql_payload_missing_field(self, mock_query): + mock_query.return_value = {"data": {"repository": {"pullRequest": {}}}} + + pr = MagicMock() + pr.base.repo.full_name = "org/repo" + pr.number = 99 + + assert is_pr_in_merge_queue(pr) is False + + @patch("helpers._run_graphql_query") + def test_false_when_graphql_call_fails(self, mock_query): + mock_query.side_effect = RuntimeError("boom") + + pr = MagicMock() + pr.base.repo.full_name = "org/repo" + pr.number = 101 + + assert is_pr_in_merge_queue(pr) is False + + +class TestEnsureMergeQueueNoticeDescription: + def test_adds_notice_block(self): + pr = MagicMock() + pr.body = "User summary" + + ensure_merge_queue_notice_description(pr) + + pr.edit.assert_called_once() + new_body = pr.edit.call_args.kwargs["body"] + assert MERGE_QUEUE_NOTICE_START in new_body + assert MERGE_QUEUE_NOTICE_END in new_body + + def test_updates_tampered_notice_block(self): + pr = MagicMock() + pr.body = ( + "User summary\n" + f"{MERGE_QUEUE_NOTICE_START}\n" + "tampered\n" + f"{MERGE_QUEUE_NOTICE_END}" + ) + + ensure_merge_queue_notice_description(pr) + + pr.edit.assert_called_once() + new_body = pr.edit.call_args.kwargs["body"] + assert "tampered" not in new_body + assert "Review Checklist Evidence Notice - Merge Queue" in new_body + + def test_no_update_when_notice_already_present(self): + pr = MagicMock() + pr.body = "Intro" + + ensure_merge_queue_notice_description(pr) + expected_body = pr.edit.call_args.kwargs["body"] + pr.reset_mock() + pr.body = expected_body + + ensure_merge_queue_notice_description(pr) + + pr.edit.assert_not_called() + + +class TestEnsureMergeQueueNoticeComment: + def test_creates_comment_when_missing(self): + pr = MagicMock() + pr.get_issue_comments.return_value = [] + + ensure_merge_queue_notice_comment(pr) + + pr.create_issue_comment.assert_called_once() + posted = pr.create_issue_comment.call_args.args[0] + assert MERGE_QUEUE_COMMENT_MARKER in posted + + def test_updates_existing_tampered_comment(self): + existing = MagicMock() + existing.body = f"{MERGE_QUEUE_COMMENT_MARKER}\nold text" + + pr = MagicMock() + pr.get_issue_comments.return_value = [existing] + + ensure_merge_queue_notice_comment(pr) + + existing.edit.assert_called_once() + updated = existing.edit.call_args.args[0] + assert "merge queue" in updated.lower() + + def test_noop_when_existing_comment_matches(self): + existing = MagicMock() + existing.body = "\n".join([MERGE_QUEUE_COMMENT_MARKER] + MERGE_QUEUE_NOTICE) + + pr = MagicMock() + pr.get_issue_comments.return_value = [existing] + + ensure_merge_queue_notice_comment(pr) + + existing.edit.assert_not_called() + pr.create_issue_comment.assert_not_called() + + +if __name__ == "__main__": + sys.exit(pytest.main(sys.argv[1:])) diff --git a/actions/review_checklists/scripts/post_checklists.py b/actions/review_checklists/scripts/post_checklists.py new file mode 100644 index 000000000..307fbc9b4 --- /dev/null +++ b/actions/review_checklists/scripts/post_checklists.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Post or update review-checklist findings on a pull request. + +For every relevant checklist (determined by path-matching against changed +files), a file-level PR review comment (finding) is created on the PR, +anchored to the first matched file. If the finding already exists it is +updated in place so that the conversation thread (and any replies) is +preserved. File-level review comments are used because they support +threaded conversations where reviewers can reply directly with OK. +""" + +from __future__ import annotations + +import argparse + +from helpers import ( + _collect_acknowledgement_details, + build_evidence_block, + ensure_merge_queue_notice_comment, + ensure_merge_queue_notice_description, + find_existing_checklist_comments, + get_changed_files, + get_github_client, + get_repo_and_pr, + is_pr_in_merge_queue, + load_checklists, + make_checklist_comment_body, + match_checklists, + set_commit_status, + update_pr_description_with_evidence, +) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Post or update review-checklist findings on a PR." + ) + parser.add_argument( + "--config-path", + default=".github/review_checklists.yml", + help="Path to checklist configuration file (default: .github/review_checklists.yml)", + ) + args = parser.parse_args() + + gh = get_github_client() + repo, pr = get_repo_and_pr(gh) + + checklists = load_checklists(args.config_path) + changed_files = get_changed_files(pr) + relevant = match_checklists(checklists, changed_files) + + if not relevant: + print("No checklists are relevant for this PR.") + set_commit_status( + repo, + pr.head.sha, + "success", + "No checklists applicable", + ) + return + + existing = find_existing_checklist_comments(pr) + + for cl in relevant: + body = make_checklist_comment_body(cl) + if cl["id"] in existing: + comment = existing[cl["id"]] + # Only update if the body actually changed (avoids notification spam). + if (comment.body or "").strip() != body.strip(): + comment.edit(body=body) + print(f"Updated checklist finding for '{cl['id']}'") + else: + print(f"Checklist finding for '{cl['id']}' is already up to date") + else: + # Post as a review with an inline comment (finding) on the first + # matched file. Using create_review with comments creates a + # PullRequestComment that supports threaded replies where + # reviewers can acknowledge with OK. + anchor_file = cl["matched_files"][0] + pr.create_review( + body="", + event="COMMENT", + comments=[ + { + "path": anchor_file, + "position": 1, + "body": body, + } + ], + ) + print(f"Created checklist finding for '{cl['id']}'") + + # Collect current acknowledgements and update evidence in PR description + relevant_ids = [cl["id"] for cl in relevant if cl["id"] in existing] + if relevant_ids: + ack_details = _collect_acknowledgement_details(pr, existing, relevant_ids) + evidence_block = build_evidence_block(relevant, ack_details) + update_pr_description_with_evidence(pr, evidence_block) + + if is_pr_in_merge_queue(pr): + ensure_merge_queue_notice_comment(pr) + ensure_merge_queue_notice_description(pr) + + # Set a pending check — actual pass/fail is determined by check_acknowledgements. + set_commit_status( + repo, + pr.head.sha, + "pending", + f"{len(relevant)} checklist(s) require reviewer acknowledgement", + ) + + print(f"Posted/updated {len(relevant)} checklist finding(s).") + + +if __name__ == "__main__": + main() diff --git a/actions/review_checklists/scripts/post_checklists_test.py b/actions/review_checklists/scripts/post_checklists_test.py new file mode 100644 index 000000000..347c1b273 --- /dev/null +++ b/actions/review_checklists/scripts/post_checklists_test.py @@ -0,0 +1,210 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Tests for post_checklists.py.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +import sys + +from post_checklists import main + + +def _make_file(filename): + f = MagicMock() + f.filename = filename + return f + + +SAMPLE_CHECKLISTS = [ + { + "id": "api-review", + "name": "API Review", + "include": ["src/api/*.py"], + "checklist": "- [ ] Reviewed", + }, +] + + +class TestPostChecklistsMain: + """Integration-level tests for the main() entry point.""" + + @patch("post_checklists.set_commit_status") + @patch("post_checklists.load_checklists", return_value=SAMPLE_CHECKLISTS) + @patch("post_checklists.get_repo_and_pr") + @patch("post_checklists.get_github_client") + def test_no_relevant_checklists_sets_success( + self, mock_gh, mock_repo_pr, mock_load, mock_status + ): + repo = MagicMock() + pr = MagicMock() + pr.head.sha = "abc123" + pr.get_files.return_value = [_make_file("unrelated/file.txt")] + mock_repo_pr.return_value = (repo, pr) + + main() + + mock_status.assert_called_once_with( + repo, "abc123", "success", "No checklists applicable" + ) + + @patch("post_checklists.set_commit_status") + @patch("post_checklists.find_existing_checklist_comments", return_value={}) + @patch("post_checklists.load_checklists", return_value=SAMPLE_CHECKLISTS) + @patch("post_checklists.get_repo_and_pr") + @patch("post_checklists.get_github_client") + def test_creates_new_review_with_inline_comment( + self, mock_gh, mock_repo_pr, mock_load, mock_existing, mock_status + ): + repo = MagicMock() + pr = MagicMock() + pr.head.sha = "abc123" + pr.get_files.return_value = [_make_file("src/api/handler.py")] + mock_repo_pr.return_value = (repo, pr) + + main() + + pr.create_review.assert_called_once() + call_kwargs = pr.create_review.call_args[1] + assert call_kwargs["event"] == "COMMENT" + comments = call_kwargs["comments"] + assert len(comments) == 1 + assert comments[0]["path"] == "src/api/handler.py" + assert comments[0]["position"] == 1 + assert "api-review" in comments[0]["body"] + mock_status.assert_called_with( + repo, + "abc123", + "pending", + "1 checklist(s) require reviewer acknowledgement", + ) + + @patch("post_checklists.set_commit_status") + @patch("post_checklists.load_checklists", return_value=SAMPLE_CHECKLISTS) + @patch("post_checklists.get_repo_and_pr") + @patch("post_checklists.get_github_client") + def test_updates_existing_review_when_body_changed( + self, mock_gh, mock_repo_pr, mock_load, mock_status + ): + repo = MagicMock() + pr = MagicMock() + pr.head.sha = "abc123" + pr.get_files.return_value = [_make_file("src/api/handler.py")] + + existing_review = MagicMock() + existing_review.body = "old body" + + with patch( + "post_checklists.find_existing_checklist_comments", + return_value={"api-review": existing_review}, + ): + mock_repo_pr.return_value = (repo, pr) + main() + + existing_review.edit.assert_called_once() + + @patch("post_checklists.set_commit_status") + @patch("post_checklists.load_checklists", return_value=SAMPLE_CHECKLISTS) + @patch("post_checklists.get_repo_and_pr") + @patch("post_checklists.get_github_client") + def test_skips_update_when_body_unchanged( + self, mock_gh, mock_repo_pr, mock_load, mock_status + ): + repo = MagicMock() + pr = MagicMock() + pr.head.sha = "abc123" + pr.get_files.return_value = [_make_file("src/api/handler.py")] + + # Import to build expected body + from helpers import make_checklist_comment_body + + expected_body = make_checklist_comment_body(SAMPLE_CHECKLISTS[0]) + + existing_review = MagicMock() + existing_review.body = expected_body + + with patch( + "post_checklists.find_existing_checklist_comments", + return_value={"api-review": existing_review}, + ): + mock_repo_pr.return_value = (repo, pr) + main() + + existing_review.edit.assert_not_called() + + @patch("post_checklists.ensure_merge_queue_notice_description") + @patch("post_checklists.ensure_merge_queue_notice_comment") + @patch("post_checklists.is_pr_in_merge_queue", return_value=True) + @patch("post_checklists.set_commit_status") + @patch("post_checklists.find_existing_checklist_comments", return_value={}) + @patch("post_checklists.load_checklists", return_value=SAMPLE_CHECKLISTS) + @patch("post_checklists.get_repo_and_pr") + @patch("post_checklists.get_github_client") + def test_merge_queue_posts_notice_comment_and_description( + self, + mock_gh, + mock_repo_pr, + mock_load, + mock_existing, + mock_status, + mock_in_queue, + mock_notice_comment, + mock_notice_description, + ): + repo = MagicMock() + pr = MagicMock() + pr.head.sha = "abc123" + pr.get_files.return_value = [_make_file("src/api/handler.py")] + mock_repo_pr.return_value = (repo, pr) + + main() + + mock_notice_comment.assert_called_once_with(pr) + mock_notice_description.assert_called_once_with(pr) + + @patch("post_checklists.ensure_merge_queue_notice_description") + @patch("post_checklists.ensure_merge_queue_notice_comment") + @patch("post_checklists.is_pr_in_merge_queue", return_value=False) + @patch("post_checklists.set_commit_status") + @patch("post_checklists.find_existing_checklist_comments", return_value={}) + @patch("post_checklists.load_checklists", return_value=SAMPLE_CHECKLISTS) + @patch("post_checklists.get_repo_and_pr") + @patch("post_checklists.get_github_client") + def test_non_merge_queue_does_not_post_notice( + self, + mock_gh, + mock_repo_pr, + mock_load, + mock_existing, + mock_status, + mock_in_queue, + mock_notice_comment, + mock_notice_description, + ): + repo = MagicMock() + pr = MagicMock() + pr.head.sha = "abc123" + pr.get_files.return_value = [_make_file("src/api/handler.py")] + mock_repo_pr.return_value = (repo, pr) + + main() + + mock_notice_comment.assert_not_called() + mock_notice_description.assert_not_called() + + +if __name__ == "__main__": + sys.exit(pytest.main(sys.argv[1:])) From 9078660fd9a8ca95621eb224d8f163cfa352d698 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Mon, 10 Aug 2026 08:54:55 +0200 Subject: [PATCH 02/34] Split review-checklists workflow into unprivileged trigger + privileged apply stages The single-stage Review Checklists workflow ran its "Check acknowledgements" step unconditionally, including when triggered by pull_request_review and pull_request_review_comment events raised for pull requests from forked repositories. GitHub always forces a read-only GITHUB_TOKEN for those events on fork PRs, regardless of the workflow's declared `permissions:` block, so the step's PR-description update (pr.edit()) failed with "403 Resource not accessible by integration". Split the workflow into two stages, following GitHub's documented "pwn request" mitigation pattern (workflow_run): - review_checklists_trigger.yml: unprivileged, reacts to all the original trigger events (pull_request_target, pull_request_review_comment, pull_request_review, merge_group). Runs no checkout and executes no repository/PR-supplied code -- it only captures the raw event payload and uploads it as an artifact. - review_checklists_apply.yml: privileged, triggered via workflow_run once the trigger stage completes. workflow_run always runs with the base repository's permissions regardless of where the original event originated, so this stage can safely hold pull-requests/statuses write access. It downloads the event artifact (outside the workspace), parses it, and runs the same post/dismiss_sync/dismiss_edit/check logic the single-stage workflow used to run directly. actions/review_checklists/action.yml gains optional event-name/event-path inputs so check_acknowledgements.py and dismiss_and_invalidate.py see the original captured event instead of the ambient workflow_run event, with no changes needed to the Python scripts themselves. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/review_checklists.yml | 113 ------------ .github/workflows/review_checklists_apply.yml | 172 ++++++++++++++++++ .../workflows/review_checklists_trigger.yml | 65 +++++++ actions/review_checklists/action.yml | 22 ++- 4 files changed, 258 insertions(+), 114 deletions(-) delete mode 100644 .github/workflows/review_checklists.yml create mode 100644 .github/workflows/review_checklists_apply.yml create mode 100644 .github/workflows/review_checklists_trigger.yml diff --git a/.github/workflows/review_checklists.yml b/.github/workflows/review_checklists.yml deleted file mode 100644 index fd3d54ac8..000000000 --- a/.github/workflows/review_checklists.yml +++ /dev/null @@ -1,113 +0,0 @@ -# ******************************************************************************* -# Copyright (c) 2026 Contributors to the Eclipse Foundation -# -# See the NOTICE file(s) distributed with this work for additional -# information regarding copyright ownership. -# -# This program and the accompanying materials are made available under the -# terms of the Apache License Version 2.0 which is available at -# https://www.apache.org/licenses/LICENSE-2.0 -# -# SPDX-License-Identifier: Apache-2.0 -# ******************************************************************************* - -# Keep review-checklists up to date on pull requests and report whether all -# checklists have been acknowledged by every approving reviewer. -# -# Triggers: -# pull_request_target (opened/reopened/synchronize/edited) -# -> Post or update checklist findings on the PR. -# -> On synchronize: invalidate stale OKs for affected checklists. -# -> On edited: refresh checklist evidence/notices after PR description changes. -# -> Re-check acknowledgements. -# pull_request_review_comment (created/edited/deleted) -# -> Restore checklist findings if they were tampered with or deleted. -# -> On edit/delete: handle OK retraction. -# -> Re-check acknowledgements. -# pull_request_review (submitted/dismissed) -# -> Re-check acknowledgements (approver set may have changed). -# -# When a PR is in merge queue, the workflow also ensures: -# - a persistent PR comment notice, and -# - a standalone PR-description notice, -# explaining that post-queue changes do not alter evidence recorded in git -# history by the merge commit evidence flow. - -name: Review Checklists - -on: - pull_request_target: - types: [opened, reopened, synchronize, edited] - branches: - - main - pull_request_review_comment: - types: [created, edited, deleted] - pull_request_review: - types: [submitted, dismissed] - merge_group: - types: [checks_requested] - -permissions: - pull-requests: write - statuses: write - -jobs: - review-checklists: - name: Review Checklists - runs-on: ubuntu-24.04 - steps: - - name: Checkout repository - uses: actions/checkout@v6.0.2 - - # Step 1: Post or update checklist findings. - # Runs on PR open/reopen/sync, on PR description edits, and on - # review comment events so tampered checklist findings and notices - # are restored. - - name: Post / update checklist findings - if: >- - github.event_name == 'pull_request_target' || - github.event_name == 'pull_request_review_comment' - uses: ./actions/review_checklists - with: - action: post - github-token: ${{ secrets.GITHUB_TOKEN }} - pr-number: ${{ github.event.pull_request.number }} - - # Step 2: Invalidate stale OKs when new commits are pushed. - - name: Invalidate stale acknowledgements - if: >- - github.event_name == 'pull_request_target' && - github.event.action == 'synchronize' - uses: ./actions/review_checklists - with: - action: dismiss_sync - github-token: ${{ secrets.GITHUB_TOKEN }} - pr-number: ${{ github.event.pull_request.number }} - before-sha: ${{ github.event.before }} - after-sha: ${{ github.event.after }} - - # Step 3: Dismiss approval if a reviewer retracted their OK. - - name: Handle OK retraction - if: >- - github.event_name == 'pull_request_review_comment' && - (github.event.action == 'edited' || github.event.action == 'deleted') - uses: ./actions/review_checklists - with: - action: dismiss_edit - github-token: ${{ secrets.GITHUB_TOKEN }} - pr-number: ${{ github.event.pull_request.number }} - - # Step 4: Always re-check acknowledgement status. - # The script sets the commit status context "review-checklists" - # to "pending" or "success". Commit statuses with the same - # context on the same SHA are deduplicated, so the latest result - # from any trigger always wins. The workflow job itself always - # succeeds — branch protection must require the commit status - # context (not the workflow check run) to gate merges. - - name: Check acknowledgements - uses: ./actions/review_checklists - with: - action: check - github-token: ${{ secrets.GITHUB_TOKEN }} - pr-number: ${{ github.event.pull_request.number || '' }} - head-sha: ${{ github.event.merge_group.head_sha || '' }} diff --git a/.github/workflows/review_checklists_apply.yml b/.github/workflows/review_checklists_apply.yml new file mode 100644 index 000000000..8fdb84adc --- /dev/null +++ b/.github/workflows/review_checklists_apply.yml @@ -0,0 +1,172 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# Privileged apply stage for the review-checklists workflow. +# +# Triggered via `workflow_run` once "Review Checklists (Trigger)" completes. +# `workflow_run` always executes using the base repository's workflow file +# and permissions, regardless of which repository (including forks) raised +# the original event — this is what lets this job safely hold a +# pull-requests/statuses write token even though some of the original +# events (pull_request_review, pull_request_review_comment on fork PRs) +# would otherwise force a read-only GITHUB_TOKEN. +# +# This workflow does all the actual work: it downloads the original event +# payload captured by the trigger stage, figures out which action(s) apply, +# and re-runs the exact same logic the previous single-stage workflow used +# to run directly on the untrusted events. +# +# Keep review-checklists up to date on pull requests and report whether all +# checklists have been acknowledged by every approving reviewer. +# +# Original events handled (as captured by the trigger stage): +# pull_request_target (opened/reopened/synchronize/edited) +# -> Post or update checklist findings on the PR. +# -> On synchronize: invalidate stale OKs for affected checklists. +# -> On edited: refresh checklist evidence/notices after PR description changes. +# -> Re-check acknowledgements. +# pull_request_review_comment (created/edited/deleted) +# -> Restore checklist findings if they were tampered with or deleted. +# -> On edit/delete: handle OK retraction. +# -> Re-check acknowledgements. +# pull_request_review (submitted/dismissed) +# -> Re-check acknowledgements (approver set may have changed). +# merge_group (checks_requested) +# -> Re-check acknowledgements (assumed OK; see check_acknowledgements.py). +# +# When a PR is in merge queue, the workflow also ensures: +# - a persistent PR comment notice, and +# - a standalone PR-description notice, +# explaining that post-queue changes do not alter evidence recorded in git +# history by the merge commit evidence flow. + +name: Review Checklists (Apply) + +on: + workflow_run: + workflows: ["Review Checklists (Trigger)"] + types: [completed] + +permissions: + pull-requests: write + statuses: write + actions: read + +concurrency: + group: >- + review-checklists-apply-${{ github.event.workflow_run.head_sha || github.event.workflow_run.id }} + +jobs: + review-checklists: + name: Review Checklists + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.2 + + # The event artifact is attacker-influenced (it originates from the + # forked PR's webhook payload), so it is downloaded outside of the + # checked-out workspace and only ever read as data (via jq/JSON + # parsing), never interpolated into a shell command or executed. + - name: Download event artifact + uses: actions/download-artifact@v4 + with: + name: event + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: ${{ runner.temp }}/event + + - name: Parse original event + id: parse + env: + EVENT_DIR: ${{ runner.temp }}/event + run: | + event_name="$(cat "$EVENT_DIR/event_name.txt")" + action="$(jq -r '.action // empty' "$EVENT_DIR/event.json")" + pr_number="$(jq -r '.pull_request.number // empty' "$EVENT_DIR/event.json")" + before_sha="$(jq -r '.before // empty' "$EVENT_DIR/event.json")" + after_sha="$(jq -r '.after // empty' "$EVENT_DIR/event.json")" + head_sha="$(jq -r '.merge_group.head_sha // empty' "$EVENT_DIR/event.json")" + + { + echo "event_name=${event_name}" + echo "action=${action}" + echo "pr_number=${pr_number}" + echo "before_sha=${before_sha}" + echo "after_sha=${after_sha}" + echo "head_sha=${head_sha}" + echo "event_path=${EVENT_DIR}/event.json" + } >> "$GITHUB_OUTPUT" + + # Step 1: Post or update checklist findings. + # Runs on PR open/reopen/sync, on PR description edits, and on + # review comment events so tampered checklist findings and notices + # are restored. + - name: Post / update checklist findings + if: >- + steps.parse.outputs.event_name == 'pull_request_target' || + steps.parse.outputs.event_name == 'pull_request_review_comment' + uses: ./actions/review_checklists + with: + action: post + github-token: ${{ secrets.GITHUB_TOKEN }} + pr-number: ${{ steps.parse.outputs.pr_number }} + event-name: ${{ steps.parse.outputs.event_name }} + event-path: ${{ steps.parse.outputs.event_path }} + + # Step 2: Invalidate stale OKs when new commits are pushed. + - name: Invalidate stale acknowledgements + if: >- + steps.parse.outputs.event_name == 'pull_request_target' && + steps.parse.outputs.action == 'synchronize' + uses: ./actions/review_checklists + with: + action: dismiss_sync + github-token: ${{ secrets.GITHUB_TOKEN }} + pr-number: ${{ steps.parse.outputs.pr_number }} + before-sha: ${{ steps.parse.outputs.before_sha }} + after-sha: ${{ steps.parse.outputs.after_sha }} + event-name: ${{ steps.parse.outputs.event_name }} + event-path: ${{ steps.parse.outputs.event_path }} + + # Step 3: Dismiss approval if a reviewer retracted their OK. + - name: Handle OK retraction + if: >- + steps.parse.outputs.event_name == 'pull_request_review_comment' && + (steps.parse.outputs.action == 'edited' || steps.parse.outputs.action == 'deleted') + uses: ./actions/review_checklists + with: + action: dismiss_edit + github-token: ${{ secrets.GITHUB_TOKEN }} + pr-number: ${{ steps.parse.outputs.pr_number }} + event-name: ${{ steps.parse.outputs.event_name }} + event-path: ${{ steps.parse.outputs.event_path }} + + # Step 4: Always re-check acknowledgement status. + # The script sets the commit status context "review-checklists" + # to "pending" or "success". Commit statuses with the same + # context on the same SHA are deduplicated, so the latest result + # from any trigger always wins. This job always has write access + # (it only ever runs via workflow_run) — branch protection must + # require the commit status context (not the workflow check run) + # to gate merges. + - name: Check acknowledgements + uses: ./actions/review_checklists + with: + action: check + github-token: ${{ secrets.GITHUB_TOKEN }} + pr-number: ${{ steps.parse.outputs.pr_number }} + head-sha: ${{ steps.parse.outputs.head_sha }} + event-name: ${{ steps.parse.outputs.event_name }} + event-path: ${{ steps.parse.outputs.event_path }} diff --git a/.github/workflows/review_checklists_trigger.yml b/.github/workflows/review_checklists_trigger.yml new file mode 100644 index 000000000..b6c4ea234 --- /dev/null +++ b/.github/workflows/review_checklists_trigger.yml @@ -0,0 +1,65 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# Unprivileged trigger stage for the review-checklists workflow. +# +# This workflow reacts to every event that can affect checklist state, +# including events raised for pull requests from forked repositories +# (pull_request_review, pull_request_review_comment). For those events +# GitHub always issues a read-only GITHUB_TOKEN, no matter what the +# `permissions:` block requests, so this workflow must not (and does not) +# attempt to write anything. +# +# It performs no checkout and executes no repository/PR-supplied code: it +# only captures the raw event payload GitHub already delivered and hands it +# off, as a build artifact, to the privileged `review_checklists_apply.yml` +# workflow, which runs via `workflow_run` and therefore always executes +# with the base repository's permissions regardless of where the +# triggering event originated. +# +# See "Preventing pwn requests" for background on this pattern: +# https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ + +name: Review Checklists (Trigger) + +on: + pull_request_target: + types: [opened, reopened, synchronize, edited] + branches: + - main + pull_request_review_comment: + types: [created, edited, deleted] + pull_request_review: + types: [submitted, dismissed] + merge_group: + types: [checks_requested] + +permissions: {} + +jobs: + capture-event: + name: Capture event + runs-on: ubuntu-24.04 + steps: + - name: Save event payload + run: | + mkdir -p ./event + cp "$GITHUB_EVENT_PATH" ./event/event.json + printf '%s' "$GITHUB_EVENT_NAME" > ./event/event_name.txt + + - name: Upload event artifact + uses: actions/upload-artifact@v4 + with: + name: event + path: event/ + retention-days: 1 diff --git a/actions/review_checklists/action.yml b/actions/review_checklists/action.yml index 13db2c93e..2f1a0a1cf 100644 --- a/actions/review_checklists/action.yml +++ b/actions/review_checklists/action.yml @@ -46,6 +46,25 @@ inputs: Commit SHA to set the status on (used for merge_group events). required: false default: "" + event-name: + description: > + Name of the original triggering event (e.g. 'pull_request_target', + 'pull_request_review_comment', 'merge_group'). Used to override the + ambient GITHUB_EVENT_NAME when this action is invoked from a + workflow_run-triggered privileged workflow, where the ambient event + differs from the event that should drive the checklist logic. + Defaults to the ambient github.event_name when not set. + required: false + default: "" + event-path: + description: > + Path to the JSON payload of the original triggering event. Used to + override the ambient GITHUB_EVENT_PATH when this action is invoked + from a workflow_run-triggered privileged workflow (e.g. to read a + pull_request_review_comment payload downloaded from an artifact). + Defaults to the ambient github.event_path when not set. + required: false + default: "" config-path: description: > Relative path to the checklist configuration file. @@ -77,6 +96,7 @@ runs: GITHUB_REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ inputs.pr-number }} HEAD_SHA: ${{ inputs.head-sha }} + GITHUB_EVENT_NAME: ${{ inputs.event-name || github.event_name }} run: python3 "$GITHUB_ACTION_PATH/scripts/check_acknowledgements.py" --config-path "${{ inputs.config-path }}" @@ -98,5 +118,5 @@ runs: GITHUB_TOKEN: ${{ inputs.github-token }} GITHUB_REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ inputs.pr-number }} - GITHUB_EVENT_PATH: ${{ github.event_path }} + GITHUB_EVENT_PATH: ${{ inputs.event-path || github.event_path }} run: python3 "$GITHUB_ACTION_PATH/scripts/dismiss_and_invalidate.py" --trigger comment_changed --config-path "${{ inputs.config-path }}" From cb8202ada56e2d4187b00f56bd4d934c6581a2c4 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Mon, 10 Aug 2026 10:48:59 +0200 Subject: [PATCH 03/34] Minimize stage1->stage2 data transfer in review-checklists split Reduce what the unprivileged trigger stage must hand off to the privileged apply stage, and derive as much as possible natively in stage 2 instead of trusting artifact-supplied data: - event_name and head_sha are now read directly from the workflow_run context (github.event.workflow_run.event / .head_sha), which are reliably correct even for fork-originated PRs. No longer parsed from the artifact. - pr_number is resolved authoritatively via the GitHub API (matching currently-open PRs by head SHA) instead of being trusted from the artifact. - dismiss_sync no longer needs before/after SHAs from the event payload at all: it looks up the immediately preceding "Review Checklists (Trigger)" run for the same head branch and uses that run's head SHA as "before", comparing against the current head. - dismiss_edit (OK-comment edit/delete detection) is dropped entirely. It was functionally redundant: check_acknowledgements always runs last and already re-scans the live comment state on every invocation, so an edited/deleted OK comment is naturally no longer counted on the very next check. The associated merge-queue notice refresh moved into check_acknowledgements, which now runs it unconditionally. - The trigger stage's artifact shrinks from a full raw webhook payload to a single whitelisted field ({"action": "..."}), the only piece of information that cannot be derived natively in stage 2. Net effect: no full (attacker-influenced) webhook payload is transferred between stages anymore, and the one output that discerns routing (the PR number) is now independently verified rather than blindly trusted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/review_checklists_apply.yml | 121 ++++---- .../workflows/review_checklists_trigger.yml | 20 +- actions/review_checklists/action.yml | 39 +-- .../scripts/check_acknowledgements.py | 24 +- .../scripts/check_acknowledgements_test.py | 45 ++- .../scripts/dismiss_and_invalidate.py | 162 ++++------ .../scripts/dismiss_and_invalidate_test.py | 281 +++++++----------- 7 files changed, 304 insertions(+), 388 deletions(-) diff --git a/.github/workflows/review_checklists_apply.yml b/.github/workflows/review_checklists_apply.yml index 8fdb84adc..ff72c1863 100644 --- a/.github/workflows/review_checklists_apply.yml +++ b/.github/workflows/review_checklists_apply.yml @@ -21,30 +21,34 @@ # events (pull_request_review, pull_request_review_comment on fork PRs) # would otherwise force a read-only GITHUB_TOKEN. # -# This workflow does all the actual work: it downloads the original event -# payload captured by the trigger stage, figures out which action(s) apply, -# and re-runs the exact same logic the previous single-stage workflow used -# to run directly on the untrusted events. +# This workflow does all the actual work: it derives the original event +# name/head SHA/PR number from the `workflow_run` context and the GitHub +# API, downloads a minimal artifact captured by the trigger stage (just the +# original event's `action` field), and re-runs the checklist logic that +# the previous single-stage workflow used to run directly on the untrusted +# events. # # Keep review-checklists up to date on pull requests and report whether all # checklists have been acknowledged by every approving reviewer. # -# Original events handled (as captured by the trigger stage): +# Original events handled: # pull_request_target (opened/reopened/synchronize/edited) # -> Post or update checklist findings on the PR. -# -> On synchronize: invalidate stale OKs for affected checklists. +# -> On synchronize: invalidate stale OKs for checklists affected by +# the new push (dismiss_sync resolves the "before" SHA itself). # -> On edited: refresh checklist evidence/notices after PR description changes. # -> Re-check acknowledgements. # pull_request_review_comment (created/edited/deleted) # -> Restore checklist findings if they were tampered with or deleted. -# -> On edit/delete: handle OK retraction. -# -> Re-check acknowledgements. +# -> Re-check acknowledgements (this naturally also handles OK-comment +# edits/deletions — see check_acknowledgements.py). # pull_request_review (submitted/dismissed) # -> Re-check acknowledgements (approver set may have changed). # merge_group (checks_requested) # -> Re-check acknowledgements (assumed OK; see check_acknowledgements.py). # -# When a PR is in merge queue, the workflow also ensures: +# When a PR is in merge queue, the "Check acknowledgements" step also +# ensures: # - a persistent PR comment notice, and # - a standalone PR-description notice, # explaining that post-queue changes do not alter evidence recorded in git @@ -78,7 +82,10 @@ jobs: # The event artifact is attacker-influenced (it originates from the # forked PR's webhook payload), so it is downloaded outside of the # checked-out workspace and only ever read as data (via jq/JSON - # parsing), never interpolated into a shell command or executed. + # parsing), never interpolated into a shell command or executed. It + # now only ever contains a single `action` field — everything else + # needed below is derived natively from the `workflow_run` context + # or resolved via the GitHub API. - name: Download event artifact uses: actions/download-artifact@v4 with: @@ -87,27 +94,31 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} path: ${{ runner.temp }}/event - - name: Parse original event + - name: Parse original event action id: parse env: EVENT_DIR: ${{ runner.temp }}/event run: | - event_name="$(cat "$EVENT_DIR/event_name.txt")" action="$(jq -r '.action // empty' "$EVENT_DIR/event.json")" - pr_number="$(jq -r '.pull_request.number // empty' "$EVENT_DIR/event.json")" - before_sha="$(jq -r '.before // empty' "$EVENT_DIR/event.json")" - after_sha="$(jq -r '.after // empty' "$EVENT_DIR/event.json")" - head_sha="$(jq -r '.merge_group.head_sha // empty' "$EVENT_DIR/event.json")" + echo "action=${action}" >> "$GITHUB_OUTPUT" - { - echo "event_name=${event_name}" - echo "action=${action}" - echo "pr_number=${pr_number}" - echo "before_sha=${before_sha}" - echo "after_sha=${after_sha}" - echo "head_sha=${head_sha}" - echo "event_path=${EVENT_DIR}/event.json" - } >> "$GITHUB_OUTPUT" + # Resolves the PR number authoritatively via the API instead of + # trusting an artifact-supplied value: matches the head SHA that this + # run actually executed against (github.event.workflow_run.head_sha, + # verified reliable even for fork PRs) against currently open PRs. + # Not needed for merge_group, which has no associated PR. + - name: Resolve PR number + id: resolve_pr + if: github.event.workflow_run.event != 'merge_group' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + pr_number="$(gh api "repos/${{ github.repository }}/pulls" \ + -X GET -f state=open --paginate \ + --jq --arg sha "$HEAD_SHA" '.[] | select(.head.sha == $sha) | .number' \ + | head -n1)" + echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT" # Step 1: Post or update checklist findings. # Runs on PR open/reopen/sync, on PR description edits, and on @@ -115,58 +126,48 @@ jobs: # are restored. - name: Post / update checklist findings if: >- - steps.parse.outputs.event_name == 'pull_request_target' || - steps.parse.outputs.event_name == 'pull_request_review_comment' + github.event.workflow_run.event == 'pull_request_target' || + github.event.workflow_run.event == 'pull_request_review_comment' uses: ./actions/review_checklists with: action: post github-token: ${{ secrets.GITHUB_TOKEN }} - pr-number: ${{ steps.parse.outputs.pr_number }} - event-name: ${{ steps.parse.outputs.event_name }} - event-path: ${{ steps.parse.outputs.event_path }} + pr-number: ${{ steps.resolve_pr.outputs.pr_number }} # Step 2: Invalidate stale OKs when new commits are pushed. + # dismiss_sync resolves the "before" SHA itself (via the previous + # Trigger-workflow run for this branch), so no push-specific data + # needs to travel through the artifact. - name: Invalidate stale acknowledgements if: >- - steps.parse.outputs.event_name == 'pull_request_target' && + github.event.workflow_run.event == 'pull_request_target' && steps.parse.outputs.action == 'synchronize' uses: ./actions/review_checklists with: action: dismiss_sync github-token: ${{ secrets.GITHUB_TOKEN }} - pr-number: ${{ steps.parse.outputs.pr_number }} - before-sha: ${{ steps.parse.outputs.before_sha }} - after-sha: ${{ steps.parse.outputs.after_sha }} - event-name: ${{ steps.parse.outputs.event_name }} - event-path: ${{ steps.parse.outputs.event_path }} - - # Step 3: Dismiss approval if a reviewer retracted their OK. - - name: Handle OK retraction - if: >- - steps.parse.outputs.event_name == 'pull_request_review_comment' && - (steps.parse.outputs.action == 'edited' || steps.parse.outputs.action == 'deleted') - uses: ./actions/review_checklists - with: - action: dismiss_edit - github-token: ${{ secrets.GITHUB_TOKEN }} - pr-number: ${{ steps.parse.outputs.pr_number }} - event-name: ${{ steps.parse.outputs.event_name }} - event-path: ${{ steps.parse.outputs.event_path }} + pr-number: ${{ steps.resolve_pr.outputs.pr_number }} + run-id: ${{ github.event.workflow_run.id }} + head-branch: ${{ github.event.workflow_run.head_branch }} - # Step 4: Always re-check acknowledgement status. + # Step 3: Always re-check acknowledgement status. # The script sets the commit status context "review-checklists" - # to "pending" or "success". Commit statuses with the same - # context on the same SHA are deduplicated, so the latest result - # from any trigger always wins. This job always has write access - # (it only ever runs via workflow_run) — branch protection must - # require the commit status context (not the workflow check run) - # to gate merges. + # to "pending" or "success", and (as of this stateless redesign) + # also refreshes the merge-queue notice. It always re-scans the + # live comment state, so it naturally also picks up OK-comment + # edits/deletions (there's no dedicated "dismiss_edit" action + # anymore) and merge_group's "assumed OK" case, needing only the + # original event name and head SHA — both read directly from the + # workflow_run context. Commit statuses with the same context on + # the same SHA are deduplicated, so the latest result from any + # trigger always wins. This job always has write access (it only + # ever runs via workflow_run) — branch protection must require the + # commit status context (not the workflow check run) to gate merges. - name: Check acknowledgements uses: ./actions/review_checklists with: action: check github-token: ${{ secrets.GITHUB_TOKEN }} - pr-number: ${{ steps.parse.outputs.pr_number }} - head-sha: ${{ steps.parse.outputs.head_sha }} - event-name: ${{ steps.parse.outputs.event_name }} - event-path: ${{ steps.parse.outputs.event_path }} + pr-number: ${{ steps.resolve_pr.outputs.pr_number }} + head-sha: ${{ github.event.workflow_run.head_sha }} + event-name: ${{ github.event.workflow_run.event }} diff --git a/.github/workflows/review_checklists_trigger.yml b/.github/workflows/review_checklists_trigger.yml index b6c4ea234..ca1230b0d 100644 --- a/.github/workflows/review_checklists_trigger.yml +++ b/.github/workflows/review_checklists_trigger.yml @@ -21,11 +21,16 @@ # attempt to write anything. # # It performs no checkout and executes no repository/PR-supplied code: it -# only captures the raw event payload GitHub already delivered and hands it -# off, as a build artifact, to the privileged `review_checklists_apply.yml` -# workflow, which runs via `workflow_run` and therefore always executes -# with the base repository's permissions regardless of where the -# triggering event originated. +# only extracts the single field it needs from the event GitHub already +# delivered (the `action` field, e.g. "opened"/"synchronize"/"edited") and +# hands it off, as a small build artifact, to the privileged +# `review_checklists_apply.yml` workflow, which runs via `workflow_run` and +# therefore always executes with the base repository's permissions +# regardless of where the triggering event originated. Everything else the +# apply stage needs (event name, head SHA, PR number) is derived natively +# from the `workflow_run` context / the GitHub API rather than transferred +# here, keeping the artifact minimal and avoiding trust in most of the +# forked-repo-supplied payload. # # See "Preventing pwn requests" for background on this pattern: # https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ @@ -51,11 +56,10 @@ jobs: name: Capture event runs-on: ubuntu-24.04 steps: - - name: Save event payload + - name: Save minimal event data run: | mkdir -p ./event - cp "$GITHUB_EVENT_PATH" ./event/event.json - printf '%s' "$GITHUB_EVENT_NAME" > ./event/event_name.txt + jq -c '{action: (.action // null)}' "$GITHUB_EVENT_PATH" > ./event/event.json - name: Upload event artifact uses: actions/upload-artifact@v4 diff --git a/actions/review_checklists/action.yml b/actions/review_checklists/action.yml index 2f1a0a1cf..d7c387022 100644 --- a/actions/review_checklists/action.yml +++ b/actions/review_checklists/action.yml @@ -24,7 +24,6 @@ inputs: post – Post/update checklist comments and evidence check – Verify acknowledgements; fail if incomplete dismiss_sync – Invalidate OKs after new push - dismiss_edit – Dismiss approval after OK edit/delete required: true github-token: description: "GitHub token with repo permissions" @@ -33,12 +32,17 @@ inputs: description: "Pull request number" required: false default: "" - before-sha: - description: "SHA before the push (for synchronize events)" + run-id: + description: > + The workflow run id of the currently-executing "Review Checklists + (Trigger)" run (used by the ``dismiss_sync`` action to look up the + previous trigger run for the same branch). required: false default: "" - after-sha: - description: "SHA after the push (for synchronize events)" + head-branch: + description: > + Head branch name of the pull request (used by the ``dismiss_sync`` + action to scope the run-history lookup). required: false default: "" head-sha: @@ -56,15 +60,6 @@ inputs: Defaults to the ambient github.event_name when not set. required: false default: "" - event-path: - description: > - Path to the JSON payload of the original triggering event. Used to - override the ambient GITHUB_EVENT_PATH when this action is invoked - from a workflow_run-triggered privileged workflow (e.g. to read a - pull_request_review_comment payload downloaded from an artifact). - Defaults to the ambient github.event_path when not set. - required: false - default: "" config-path: description: > Relative path to the checklist configuration file. @@ -107,16 +102,6 @@ runs: GITHUB_TOKEN: ${{ inputs.github-token }} GITHUB_REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ inputs.pr-number }} - BEFORE_SHA: ${{ inputs.before-sha }} - AFTER_SHA: ${{ inputs.after-sha }} - run: python3 "$GITHUB_ACTION_PATH/scripts/dismiss_and_invalidate.py" --trigger synchronize --config-path "${{ inputs.config-path }}" - - - name: Run dismiss_and_invalidate (comment changed) - if: inputs.action == 'dismiss_edit' - shell: bash - env: - GITHUB_TOKEN: ${{ inputs.github-token }} - GITHUB_REPOSITORY: ${{ github.repository }} - PR_NUMBER: ${{ inputs.pr-number }} - GITHUB_EVENT_PATH: ${{ inputs.event-path || github.event_path }} - run: python3 "$GITHUB_ACTION_PATH/scripts/dismiss_and_invalidate.py" --trigger comment_changed --config-path "${{ inputs.config-path }}" + CURRENT_RUN_ID: ${{ inputs.run-id }} + HEAD_BRANCH: ${{ inputs.head-branch }} + run: python3 "$GITHUB_ACTION_PATH/scripts/dismiss_and_invalidate.py" --config-path "${{ inputs.config-path }}" diff --git a/actions/review_checklists/scripts/check_acknowledgements.py b/actions/review_checklists/scripts/check_acknowledgements.py index 2a04390e3..b3c4bd76a 100644 --- a/actions/review_checklists/scripts/check_acknowledgements.py +++ b/actions/review_checklists/scripts/check_acknowledgements.py @@ -18,13 +18,20 @@ An acknowledgement is a reply in the threaded conversation of a checklist review comment (finding) that contains the ``OK`` keyword. This script: -1. Enumerates relevant checklists for the PR. -2. For each checklist, finds the bot-posted review comment and its OK replies. -3. Builds a mapping: checklist-id → set of reviewers who said OK. -4. Compares against the set of approving reviewers. -5. Sets commit status to *success* only when every approving reviewer has +1. Ensures the merge-queue notice (comment + PR description) is present if + the PR is currently enqueued in the merge queue. +2. Enumerates relevant checklists for the PR. +3. For each checklist, finds the bot-posted review comment and its OK replies. +4. Builds a mapping: checklist-id → set of reviewers who said OK. +5. Compares against the set of approving reviewers. +6. Sets commit status to *success* only when every approving reviewer has acknowledged every relevant checklist. Otherwise sets *pending* or *failure*. + +This script is invoked on every checklist-relevant event (it is always the +last step run), so it doubles as the single, fully stateless source of +truth for acknowledgement status: it always re-scans the current live +comment state rather than relying on which specific event triggered it. """ from __future__ import annotations @@ -39,11 +46,14 @@ _collect_acknowledgement_details, OK_KEYWORD, build_evidence_block, + ensure_merge_queue_notice_comment, + ensure_merge_queue_notice_description, find_existing_checklist_comments, get_approving_reviewers, get_changed_files, get_github_client, get_repo_and_pr, + is_pr_in_merge_queue, load_checklists, match_checklists, set_commit_status, @@ -117,6 +127,10 @@ def main() -> None: repo, pr = get_repo_and_pr(gh) + if is_pr_in_merge_queue(pr): + ensure_merge_queue_notice_comment(pr) + ensure_merge_queue_notice_description(pr) + checklists = load_checklists(args.config_path) changed_files = get_changed_files(pr) relevant = match_checklists(checklists, changed_files) diff --git a/actions/review_checklists/scripts/check_acknowledgements_test.py b/actions/review_checklists/scripts/check_acknowledgements_test.py index 65fbc2a0c..7938d712a 100644 --- a/actions/review_checklists/scripts/check_acknowledgements_test.py +++ b/actions/review_checklists/scripts/check_acknowledgements_test.py @@ -163,6 +163,7 @@ def test_unrelated_reply_ignored(self): class TestCheckAcknowledgementsMain: + @patch("check_acknowledgements.is_pr_in_merge_queue", return_value=False) @patch("check_acknowledgements.set_commit_status") @patch( "check_acknowledgements.load_checklists", @@ -171,7 +172,7 @@ class TestCheckAcknowledgementsMain: @patch("check_acknowledgements.get_repo_and_pr") @patch("check_acknowledgements.get_github_client") def test_no_relevant_checklists_sets_success( - self, mock_gh, mock_repo_pr, mock_load, mock_status + self, mock_gh, mock_repo_pr, mock_load, mock_status, mock_in_queue ): repo = MagicMock() pr = MagicMock() @@ -185,6 +186,7 @@ def test_no_relevant_checklists_sets_success( repo, "abc", "success", "No checklists applicable" ) + @patch("check_acknowledgements.is_pr_in_merge_queue", return_value=False) @patch("check_acknowledgements.set_commit_status") @patch( "check_acknowledgements.find_existing_checklist_comments", @@ -197,7 +199,7 @@ def test_no_relevant_checklists_sets_success( @patch("check_acknowledgements.get_repo_and_pr") @patch("check_acknowledgements.get_github_client") def test_no_existing_comments_sets_pending( - self, mock_gh, mock_repo_pr, mock_load, mock_existing, mock_status + self, mock_gh, mock_repo_pr, mock_load, mock_existing, mock_status, mock_in_queue ): repo = MagicMock() pr = MagicMock() @@ -213,6 +215,7 @@ def test_no_existing_comments_sets_pending( repo, "abc", "pending", "Checklist comments not yet posted" ) + @patch("check_acknowledgements.is_pr_in_merge_queue", return_value=False) @patch("check_acknowledgements.set_commit_status") @patch("check_acknowledgements.get_approving_reviewers", return_value=[]) @patch( @@ -222,7 +225,7 @@ def test_no_existing_comments_sets_pending( @patch("check_acknowledgements.get_repo_and_pr") @patch("check_acknowledgements.get_github_client") def test_no_approvers_sets_pending( - self, mock_gh, mock_repo_pr, mock_load, mock_approvers, mock_status + self, mock_gh, mock_repo_pr, mock_load, mock_approvers, mock_status, mock_in_queue ): repo = MagicMock() pr = MagicMock() @@ -249,6 +252,7 @@ def test_no_approvers_sets_pending( repo, "abc", "pending", "Awaiting at least one approving review" ) + @patch("check_acknowledgements.is_pr_in_merge_queue", return_value=False) @patch("check_acknowledgements.set_commit_status") @patch( "check_acknowledgements.get_approving_reviewers", @@ -267,6 +271,7 @@ def test_all_acked_sets_success( mock_load, mock_approvers, mock_status, + mock_in_queue, tmp_path, ): repo = MagicMock() @@ -313,6 +318,7 @@ def test_all_acked_sets_success( data = json.load(f) assert data["api-review"] == ["alice"] + @patch("check_acknowledgements.is_pr_in_merge_queue", return_value=False) @patch("check_acknowledgements.set_commit_status") @patch( "check_acknowledgements.get_approving_reviewers", @@ -331,6 +337,7 @@ def test_missing_ack_sets_pending( mock_load, mock_approvers, mock_status, + mock_in_queue, tmp_path, ): repo = MagicMock() @@ -370,6 +377,38 @@ def test_missing_ack_sets_pending( repo, "abc", "pending", "api-review: awaiting bob" ) + @patch("check_acknowledgements.ensure_merge_queue_notice_description") + @patch("check_acknowledgements.ensure_merge_queue_notice_comment") + @patch("check_acknowledgements.is_pr_in_merge_queue", return_value=True) + @patch("check_acknowledgements.set_commit_status") + @patch( + "check_acknowledgements.load_checklists", + return_value=SAMPLE_CHECKLISTS, + ) + @patch("check_acknowledgements.get_repo_and_pr") + @patch("check_acknowledgements.get_github_client") + def test_merge_queue_notice_refreshed_when_pr_enqueued( + self, + mock_gh, + mock_repo_pr, + mock_load, + mock_status, + mock_in_queue, + mock_notice_comment, + mock_notice_description, + ): + repo = MagicMock() + pr = MagicMock() + pr.head.sha = "abc" + pr.get_files.return_value = [_make_file("unrelated.txt")] + mock_repo_pr.return_value = (repo, pr) + + with patch.object(sys, "argv", ["check_acknowledgements"]): + main() + + mock_notice_comment.assert_called_once_with(pr) + mock_notice_description.assert_called_once_with(pr) + @patch("check_acknowledgements.set_commit_status") @patch("check_acknowledgements.get_repo_and_pr") @patch("check_acknowledgements.get_github_client") diff --git a/actions/review_checklists/scripts/dismiss_and_invalidate.py b/actions/review_checklists/scripts/dismiss_and_invalidate.py index ecec30d0c..f5301626d 100644 --- a/actions/review_checklists/scripts/dismiss_and_invalidate.py +++ b/actions/review_checklists/scripts/dismiss_and_invalidate.py @@ -12,65 +12,76 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -"""Invalidate OK acknowledgements. - -This script handles two scenarios: - -1. **New push (synchronize)**: When new commits are pushed to the PR, we - determine which checklist paths are affected by the *new* changes. For - each affected checklist, all existing OK replies are deleted and the - commit status is set back to pending. Approvals are **not** dismissed - here — branch rulesets handle dismissing stale reviews on new pushes. - -2. **OK modified or deleted**: When a reviewer edits or deletes their OK - comment, the commit status is set back to pending so the checklist - must be re-acknowledged. The reviewer's approval is **not** dismissed. - -The script is invoked with a ``--trigger`` argument: - - python dismiss_and_invalidate.py --trigger synchronize - python dismiss_and_invalidate.py --trigger comment_changed +"""Invalidate OK acknowledgements after a new push. + +When new commits are pushed to the PR, this script determines which +checklist paths are affected by the *new* changes. For each affected +checklist, all existing OK replies are deleted and the commit status is +set back to pending. Approvals are **not** dismissed here — branch +rulesets handle dismissing stale reviews on new pushes. + +(OK-comment edits/deletions no longer need dedicated handling here: the +"check" action already re-scans the current comment state on every +invocation, so an edited/deleted OK comment is naturally no longer +counted the next time acknowledgements are checked.) + +The "files changed in the latest push" are found without needing the +``before``/``after`` SHAs from the original event payload: this script +looks up the previous run of the "Review Checklists (Trigger)" workflow +for the same head branch (ordered by run creation time) and uses that +run's head SHA as the "before" commit. This keeps stage 1 → stage 2 data +transfer minimal while still comparing against exactly the commits +introduced by this push. """ from __future__ import annotations import argparse -import json import os from typing import Any from helpers import ( OK_KEYWORD, - ensure_merge_queue_notice_comment, - ensure_merge_queue_notice_description, find_existing_checklist_comments, get_changed_files, get_github_client, get_repo_and_pr, - is_pr_in_merge_queue, load_checklists, match_checklists, set_commit_status, ) +TRIGGER_WORKFLOW_FILE = "review_checklists_trigger.yml" +TRIGGER_EVENT_NAME = "pull_request_target" + -def _get_files_in_latest_push(pr: Any) -> list[str]: +def _get_files_in_latest_push(pr: Any, repo: Any) -> list[str]: """Return files changed in the most recent push to the PR. - For the ``synchronize`` event we ideally look at only the files changed - in the new commits. The GitHub event payload provides ``before`` and - ``after`` SHAs which can be compared. As a practical fallback we use - the full PR changed-file list. + Finds the "before" SHA by locating the trigger-workflow run that + immediately preceded the current one for this head branch, and + compares it against the current head. Falls back to the full PR + changed-file list if the previous run cannot be found. """ - before_sha = os.environ.get("BEFORE_SHA", "") - after_sha = os.environ.get("AFTER_SHA", "") - - if before_sha and after_sha: - repo_name = os.environ["GITHUB_REPOSITORY"] - gh = get_github_client() - repo = gh.get_repo(repo_name) - comparison = repo.compare(before_sha, after_sha) - return [f.filename for f in comparison.files] + run_id_raw = os.environ.get("CURRENT_RUN_ID", "") + head_branch = os.environ.get("HEAD_BRANCH", "") + + if run_id_raw and head_branch: + try: + run_id = int(run_id_raw) + workflow = repo.get_workflow(TRIGGER_WORKFLOW_FILE) + runs = list( + workflow.get_runs(branch=head_branch, event=TRIGGER_EVENT_NAME) + ) + run_ids = [r.id for r in runs] + idx = run_ids.index(run_id) + before_sha = runs[idx + 1].head_sha + comparison = repo.compare(before_sha, pr.head.sha) + return [f.filename for f in comparison.files] + except (ValueError, IndexError) as e: + print(f"Could not resolve previous push via run history: {e}") + except Exception as e: + print(f"Warning: could not resolve latest-push diff: {e}") # Fallback: treat all PR files as potentially changed. return get_changed_files(pr) @@ -97,7 +108,7 @@ def _find_ok_comments_for_checklist(pr: Any, checklist_comment_id: int) -> list[ return ok_comments -def handle_synchronize(pr: Any, config_path: str) -> None: +def handle_synchronize(pr: Any, repo: Any, config_path: str) -> None: """Handle new commits pushed to the PR. For each checklist whose covered paths were touched by the new push, @@ -105,7 +116,7 @@ def handle_synchronize(pr: Any, config_path: str) -> None: Approvals are not dismissed — branch rulesets handle that. """ checklists = load_checklists(config_path) - new_files = _get_files_in_latest_push(pr) + new_files = _get_files_in_latest_push(pr, repo) affected = match_checklists(checklists, new_files) if not affected: @@ -137,9 +148,6 @@ def handle_synchronize(pr: Any, config_path: str) -> None: print(f"Warning: could not delete comment {ok_comment.id}: {e}") if any_invalidated: - repo_name = os.environ["GITHUB_REPOSITORY"] - gh = get_github_client() - repo = gh.get_repo(repo_name) set_commit_status( repo, pr.head.sha, @@ -147,70 +155,10 @@ def handle_synchronize(pr: Any, config_path: str) -> None: "Checklist acknowledgements invalidated due to new changes", ) - if is_pr_in_merge_queue(pr): - ensure_merge_queue_notice_comment(pr) - ensure_merge_queue_notice_description(pr) - - -def handle_comment_changed(pr: Any) -> None: - """Handle an OK comment being edited or deleted. - - When a reviewer modifies or removes their OK, set the commit status - back to pending. The reviewer's approval is preserved. - """ - event_path = os.environ.get("GITHUB_EVENT_PATH", "") - if not event_path: - print("No event payload available.") - return - - with open(event_path) as f: - event = json.load(f) - - comment_body = event.get("comment", {}).get("body", "") - comment_user = event.get("comment", {}).get("user", {}).get("login", "") - action = event.get("action", "") - - if not comment_user: - print("Could not determine comment author.") - return - - # Check if this was an OK-related comment. - was_ok = False - if action == "deleted": - # For deleted comments the body is the content at time of deletion. - was_ok = comment_body.strip().upper() == OK_KEYWORD - elif action == "edited": - old_body = event.get("changes", {}).get("body", {}).get("from", "") - # If old body was OK but new body is not, this is a retraction. - old_is_ok = old_body.strip().upper() == OK_KEYWORD - new_is_ok = comment_body.strip().upper() == OK_KEYWORD - was_ok = old_is_ok and not new_is_ok - - if was_ok: - print(f"Checklist OK retracted by {comment_user} (approval preserved)") - - repo_name = os.environ["GITHUB_REPOSITORY"] - gh = get_github_client() - repo = gh.get_repo(repo_name) - set_commit_status( - repo, - pr.head.sha, - "pending", - f"Checklist OK retracted by {comment_user}", - ) - - if is_pr_in_merge_queue(pr): - ensure_merge_queue_notice_comment(pr) - ensure_merge_queue_notice_description(pr) - def main() -> None: - parser = argparse.ArgumentParser(description="Invalidate OK acknowledgements.") - parser.add_argument( - "--trigger", - required=True, - choices=["synchronize", "comment_changed"], - help="The event trigger type.", + parser = argparse.ArgumentParser( + description="Invalidate OK acknowledgements after a new push." ) parser.add_argument( "--config-path", @@ -220,12 +168,8 @@ def main() -> None: args = parser.parse_args() gh = get_github_client() - _, pr = get_repo_and_pr(gh) - - if args.trigger == "synchronize": - handle_synchronize(pr, args.config_path) - elif args.trigger == "comment_changed": - handle_comment_changed(pr) + repo, pr = get_repo_and_pr(gh) + handle_synchronize(pr, repo, args.config_path) if __name__ == "__main__": diff --git a/actions/review_checklists/scripts/dismiss_and_invalidate_test.py b/actions/review_checklists/scripts/dismiss_and_invalidate_test.py index b54986f5c..8b439062c 100644 --- a/actions/review_checklists/scripts/dismiss_and_invalidate_test.py +++ b/actions/review_checklists/scripts/dismiss_and_invalidate_test.py @@ -15,8 +15,6 @@ from __future__ import annotations -import json -import os from datetime import datetime, timezone from unittest.mock import MagicMock, patch @@ -25,7 +23,7 @@ from dismiss_and_invalidate import ( _find_ok_comments_for_checklist, - handle_comment_changed, + _get_files_in_latest_push, handle_synchronize, ) @@ -53,6 +51,13 @@ def _make_review(user_login, state, review_id=1): return r +def _make_run(run_id, head_sha): + r = MagicMock() + r.id = run_id + r.head_sha = head_sha + return r + + SAMPLE_CHECKLISTS = [ { "id": "api-review", @@ -127,63 +132,129 @@ def test_ignores_unrelated_reply(self): assert len(result) == 0 +# --------------------------------------------------------------------------- +# _get_files_in_latest_push +# --------------------------------------------------------------------------- + + +class TestGetFilesInLatestPush: + def test_falls_back_when_no_run_context(self, monkeypatch): + monkeypatch.delenv("CURRENT_RUN_ID", raising=False) + monkeypatch.delenv("HEAD_BRANCH", raising=False) + + pr = MagicMock() + pr.get_files.return_value = [_make_file("fallback.txt")] + repo = MagicMock() + + result = _get_files_in_latest_push(pr, repo) + + assert result == ["fallback.txt"] + repo.get_workflow.assert_not_called() + + def test_uses_previous_run_head_sha_for_comparison(self, monkeypatch): + monkeypatch.setenv("CURRENT_RUN_ID", "20") + monkeypatch.setenv("HEAD_BRANCH", "feature") + + pr = MagicMock() + pr.head.sha = "newsha" + + repo = MagicMock() + workflow = MagicMock() + # Newest-first, as returned by the GitHub API. + workflow.get_runs.return_value = [ + _make_run(20, "newsha"), + _make_run(10, "oldsha"), + ] + repo.get_workflow.return_value = workflow + comparison = MagicMock() + comparison.files = [_make_file("src/api/handler.py")] + repo.compare.return_value = comparison + + result = _get_files_in_latest_push(pr, repo) + + repo.get_workflow.assert_called_once_with("review_checklists_trigger.yml") + workflow.get_runs.assert_called_once_with( + branch="feature", event="pull_request_target" + ) + repo.compare.assert_called_once_with("oldsha", "newsha") + assert result == ["src/api/handler.py"] + + def test_falls_back_when_previous_run_not_found(self, monkeypatch): + monkeypatch.setenv("CURRENT_RUN_ID", "999") + monkeypatch.setenv("HEAD_BRANCH", "feature") + + pr = MagicMock() + pr.get_files.return_value = [_make_file("fallback.txt")] + + repo = MagicMock() + workflow = MagicMock() + workflow.get_runs.return_value = [_make_run(20, "newsha")] + repo.get_workflow.return_value = workflow + + result = _get_files_in_latest_push(pr, repo) + + assert result == ["fallback.txt"] + repo.compare.assert_not_called() + + def test_falls_back_when_current_run_is_first(self, monkeypatch): + # Current run has no predecessor (e.g. only one run exists). + monkeypatch.setenv("CURRENT_RUN_ID", "20") + monkeypatch.setenv("HEAD_BRANCH", "feature") + + pr = MagicMock() + pr.get_files.return_value = [_make_file("fallback.txt")] + + repo = MagicMock() + workflow = MagicMock() + workflow.get_runs.return_value = [_make_run(20, "newsha")] + repo.get_workflow.return_value = workflow + + result = _get_files_in_latest_push(pr, repo) + + assert result == ["fallback.txt"] + repo.compare.assert_not_called() + + # --------------------------------------------------------------------------- # handle_synchronize # --------------------------------------------------------------------------- class TestHandleSynchronize: - @patch("dismiss_and_invalidate.ensure_merge_queue_notice_description") - @patch("dismiss_and_invalidate.ensure_merge_queue_notice_comment") - @patch("dismiss_and_invalidate.is_pr_in_merge_queue", return_value=False) @patch("dismiss_and_invalidate.set_commit_status") - @patch("dismiss_and_invalidate.get_github_client") @patch("dismiss_and_invalidate.load_checklists", return_value=SAMPLE_CHECKLISTS) def test_no_affected_checklists( self, mock_load, - mock_gh, mock_status, - mock_in_queue, - mock_notice_comment, - mock_notice_description, monkeypatch, ): - monkeypatch.delenv("BEFORE_SHA", raising=False) - monkeypatch.delenv("AFTER_SHA", raising=False) + monkeypatch.delenv("CURRENT_RUN_ID", raising=False) + monkeypatch.delenv("HEAD_BRANCH", raising=False) pr = MagicMock() pr.get_files.return_value = [_make_file("unrelated.txt")] + repo = MagicMock() - handle_synchronize(pr, ".github/review_checklists.yml") + handle_synchronize(pr, repo, ".github/review_checklists.yml") mock_status.assert_not_called() - mock_notice_comment.assert_not_called() - mock_notice_description.assert_not_called() - @patch("dismiss_and_invalidate.ensure_merge_queue_notice_description") - @patch("dismiss_and_invalidate.ensure_merge_queue_notice_comment") - @patch("dismiss_and_invalidate.is_pr_in_merge_queue", return_value=True) @patch("dismiss_and_invalidate.set_commit_status") - @patch("dismiss_and_invalidate.get_github_client") @patch("dismiss_and_invalidate.load_checklists", return_value=SAMPLE_CHECKLISTS) def test_deletes_ok_without_dismissing( self, mock_load, - mock_gh, mock_status, - mock_in_queue, - mock_notice_comment, - mock_notice_description, monkeypatch, ): - monkeypatch.delenv("BEFORE_SHA", raising=False) - monkeypatch.delenv("AFTER_SHA", raising=False) - monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") + monkeypatch.delenv("CURRENT_RUN_ID", raising=False) + monkeypatch.delenv("HEAD_BRANCH", raising=False) pr = MagicMock() pr.head.sha = "newsha" pr.get_files.return_value = [_make_file("src/api/handler.py")] + repo = MagicMock() cl_comment = MagicMock() cl_comment.id = 100 @@ -205,158 +276,16 @@ def test_deletes_ok_without_dismissing( "dismiss_and_invalidate.find_existing_checklist_comments", return_value={"api-review": cl_comment}, ): - handle_synchronize(pr, ".github/review_checklists.yml") + handle_synchronize(pr, repo, ".github/review_checklists.yml") ok_reply.delete.assert_called_once() review.dismiss.assert_not_called() - mock_status.assert_called_once() - mock_notice_comment.assert_called_once_with(pr) - mock_notice_description.assert_called_once_with(pr) - - -# --------------------------------------------------------------------------- -# handle_comment_changed -# --------------------------------------------------------------------------- - - -class TestHandleCommentChanged: - def test_no_event_path(self, capsys): - pr = MagicMock() - with patch.dict(os.environ, {"GITHUB_EVENT_PATH": ""}): - handle_comment_changed(pr) - assert "No event payload" in capsys.readouterr().out - - @patch("dismiss_and_invalidate.set_commit_status") - @patch("dismiss_and_invalidate.get_github_client") - @patch("dismiss_and_invalidate.ensure_merge_queue_notice_description") - @patch("dismiss_and_invalidate.ensure_merge_queue_notice_comment") - @patch("dismiss_and_invalidate.is_pr_in_merge_queue", return_value=True) - def test_deleted_ok_sets_pending_without_dismissing( - self, - mock_in_queue, - mock_notice_comment, - mock_notice_description, - mock_gh, - mock_status, - tmp_path, - monkeypatch, - ): - monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") - - event = { - "action": "deleted", - "comment": { - "body": "OK", - "user": {"login": "alice"}, - }, - } - event_file = tmp_path / "event.json" - event_file.write_text(json.dumps(event)) - - pr = MagicMock() - pr.head.sha = "sha123" - review = _make_review("alice", "APPROVED", 10) - pr.get_reviews.return_value = [review] - - with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(event_file)}): - handle_comment_changed(pr) - - review.dismiss.assert_not_called() - mock_status.assert_called_once() - mock_notice_comment.assert_called_once_with(pr) - mock_notice_description.assert_called_once_with(pr) - - @patch("dismiss_and_invalidate.ensure_merge_queue_notice_description") - @patch("dismiss_and_invalidate.ensure_merge_queue_notice_comment") - @patch("dismiss_and_invalidate.is_pr_in_merge_queue", return_value=False) - @patch("dismiss_and_invalidate.set_commit_status") - @patch("dismiss_and_invalidate.get_github_client") - def test_edited_ok_retraction_sets_pending_without_dismissing( - self, - mock_gh, - mock_status, - mock_in_queue, - mock_notice_comment, - mock_notice_description, - tmp_path, - monkeypatch, - ): - monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") - - event = { - "action": "edited", - "comment": { - "body": "never mind", - "user": {"login": "bob"}, - }, - "changes": { - "body": {"from": "OK"}, - }, - } - event_file = tmp_path / "event.json" - event_file.write_text(json.dumps(event)) - - pr = MagicMock() - pr.head.sha = "sha456" - review = _make_review("bob", "APPROVED", 20) - pr.get_reviews.return_value = [review] - - with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(event_file)}): - handle_comment_changed(pr) - - review.dismiss.assert_not_called() - mock_status.assert_called_once() - mock_notice_comment.assert_not_called() - mock_notice_description.assert_not_called() - - @patch("dismiss_and_invalidate.set_commit_status") - @patch("dismiss_and_invalidate.get_github_client") - def test_edited_non_ok_does_not_dismiss( - self, mock_gh, mock_status, tmp_path, monkeypatch - ): - monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") - - event = { - "action": "edited", - "comment": { - "body": "updated comment", - "user": {"login": "bob"}, - }, - "changes": { - "body": {"from": "original non-ok comment"}, - }, - } - event_file = tmp_path / "event.json" - event_file.write_text(json.dumps(event)) - - pr = MagicMock() - pr.head.sha = "sha789" - pr.get_reviews.return_value = [] - - with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(event_file)}): - handle_comment_changed(pr) - - mock_status.assert_not_called() - - def test_deleted_non_ok_does_not_dismiss(self, tmp_path): - event = { - "action": "deleted", - "comment": { - "body": "just a regular comment", - "user": {"login": "bob"}, - }, - } - event_file = tmp_path / "event.json" - event_file.write_text(json.dumps(event)) - - pr = MagicMock() - pr.get_reviews.return_value = [] - - with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(event_file)}): - handle_comment_changed(pr) - - # No dismiss should have been called (no reviews to dismiss anyway). - # The key assertion is that no exception was raised. + mock_status.assert_called_once_with( + repo, + "newsha", + "pending", + "Checklist acknowledgements invalidated due to new changes", + ) if __name__ == "__main__": From 816c9dd35e08f7052065940acd13eeafde76d2bc Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Mon, 10 Aug 2026 10:52:24 +0200 Subject: [PATCH 04/34] Fix PR-number resolution: gh api --jq does not accept --arg gh api forwards its --jq value straight to jq as the filter program, but does not support passing extra jq CLI flags like --arg alongside it (they were being parsed as additional gh api positional arguments, causing "accepts 1 arg(s), received 4"). Use jq's built-in `env.VAR` accessor instead, which reads the already-exported HEAD_SHA environment variable directly inside the filter without needing --arg. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/review_checklists_apply.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/review_checklists_apply.yml b/.github/workflows/review_checklists_apply.yml index ff72c1863..cd8646af3 100644 --- a/.github/workflows/review_checklists_apply.yml +++ b/.github/workflows/review_checklists_apply.yml @@ -116,7 +116,7 @@ jobs: run: | pr_number="$(gh api "repos/${{ github.repository }}/pulls" \ -X GET -f state=open --paginate \ - --jq --arg sha "$HEAD_SHA" '.[] | select(.head.sha == $sha) | .number' \ + --jq '.[] | select(.head.sha == env.HEAD_SHA) | .number' \ | head -n1)" echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT" From 0572ce5c0c2be1b0de8e93b903dae3adeec83670 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Mon, 10 Aug 2026 11:08:34 +0200 Subject: [PATCH 05/34] Drop stage1->stage2 action transfer; dismiss_sync self-determines no-op The trigger stage's artifact only ever carried one field: the original event's `action` (opened/reopened/synchronize/edited), used solely to gate the apply stage's `dismiss_sync` step to `action == 'synchronize'`. dismiss_sync already resolves its own 'before' SHA by looking up the previous trigger-workflow run for the same branch and diffing against the current head. When the head SHA hasn't actually moved since that previous run (e.g. on 'edited'/'reopened' without a new push), the diff is empty, so no checklists are affected and the step is naturally a no-op. This means dismiss_sync can safely run unconditionally on every pull_request_target event without needing to know which sub-action fired. With that last use of the artifact gone, drop the entire artifact transfer: the trigger workflow no longer captures/uploads any event data, and the apply workflow no longer downloads/parses it. The trigger job now exists solely to complete and fire the workflow_run trigger. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/review_checklists_apply.yml | 56 +++++++------------ .../workflows/review_checklists_trigger.yml | 41 ++++++-------- actions/review_checklists/action.yml | 2 +- .../scripts/dismiss_and_invalidate.py | 31 ++++++---- 4 files changed, 57 insertions(+), 73 deletions(-) diff --git a/.github/workflows/review_checklists_apply.yml b/.github/workflows/review_checklists_apply.yml index cd8646af3..104668784 100644 --- a/.github/workflows/review_checklists_apply.yml +++ b/.github/workflows/review_checklists_apply.yml @@ -22,11 +22,14 @@ # would otherwise force a read-only GITHUB_TOKEN. # # This workflow does all the actual work: it derives the original event -# name/head SHA/PR number from the `workflow_run` context and the GitHub -# API, downloads a minimal artifact captured by the trigger stage (just the -# original event's `action` field), and re-runs the checklist logic that -# the previous single-stage workflow used to run directly on the untrusted -# events. +# name/head SHA/PR number entirely from the `workflow_run` context and the +# GitHub API and re-runs the checklist logic that the previous +# single-stage workflow used to run directly on the untrusted events. No +# artifact or other data is transferred from the trigger stage at all — +# not even the original event's `action` (opened/reopened/synchronize/ +# edited): `dismiss_sync` is simply run on every pull_request_target event +# and self-determines whether anything actually changed (see below), so +# there is nothing left that stage 2 needs stage 1 to hand it. # # Keep review-checklists up to date on pull requests and report whether all # checklists have been acknowledged by every approving reviewer. @@ -34,8 +37,11 @@ # Original events handled: # pull_request_target (opened/reopened/synchronize/edited) # -> Post or update checklist findings on the PR. -# -> On synchronize: invalidate stale OKs for checklists affected by -# the new push (dismiss_sync resolves the "before" SHA itself). +# -> Invalidate OKs for checklists affected by commits since the +# previous trigger run (dismiss_sync resolves the "before" SHA +# itself via run history and diffs against the current head; if +# the head SHA hasn't moved — e.g. on "edited"/"reopened" without +# a new push — the diff is empty and this is a no-op). # -> On edited: refresh checklist evidence/notices after PR description changes. # -> Re-check acknowledgements. # pull_request_review_comment (created/edited/deleted) @@ -79,29 +85,6 @@ jobs: - name: Checkout repository uses: actions/checkout@v6.0.2 - # The event artifact is attacker-influenced (it originates from the - # forked PR's webhook payload), so it is downloaded outside of the - # checked-out workspace and only ever read as data (via jq/JSON - # parsing), never interpolated into a shell command or executed. It - # now only ever contains a single `action` field — everything else - # needed below is derived natively from the `workflow_run` context - # or resolved via the GitHub API. - - name: Download event artifact - uses: actions/download-artifact@v4 - with: - name: event - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - path: ${{ runner.temp }}/event - - - name: Parse original event action - id: parse - env: - EVENT_DIR: ${{ runner.temp }}/event - run: | - action="$(jq -r '.action // empty' "$EVENT_DIR/event.json")" - echo "action=${action}" >> "$GITHUB_OUTPUT" - # Resolves the PR number authoritatively via the API instead of # trusting an artifact-supplied value: matches the head SHA that this # run actually executed against (github.event.workflow_run.head_sha, @@ -134,14 +117,15 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} pr-number: ${{ steps.resolve_pr.outputs.pr_number }} - # Step 2: Invalidate stale OKs when new commits are pushed. + # Step 2: Invalidate stale OKs for commits since the previous push. # dismiss_sync resolves the "before" SHA itself (via the previous - # Trigger-workflow run for this branch), so no push-specific data - # needs to travel through the artifact. + # Trigger-workflow run for this branch) and diffs it against the + # current head, so it doesn't need to know which pull_request_target + # sub-action fired: if the head SHA hasn't moved since the previous + # trigger run (e.g. "edited"/"reopened" without a new push), the + # diff is empty and this step is a no-op. - name: Invalidate stale acknowledgements - if: >- - github.event.workflow_run.event == 'pull_request_target' && - steps.parse.outputs.action == 'synchronize' + if: github.event.workflow_run.event == 'pull_request_target' uses: ./actions/review_checklists with: action: dismiss_sync diff --git a/.github/workflows/review_checklists_trigger.yml b/.github/workflows/review_checklists_trigger.yml index ca1230b0d..36c2d8d4e 100644 --- a/.github/workflows/review_checklists_trigger.yml +++ b/.github/workflows/review_checklists_trigger.yml @@ -20,17 +20,16 @@ # `permissions:` block requests, so this workflow must not (and does not) # attempt to write anything. # -# It performs no checkout and executes no repository/PR-supplied code: it -# only extracts the single field it needs from the event GitHub already -# delivered (the `action` field, e.g. "opened"/"synchronize"/"edited") and -# hands it off, as a small build artifact, to the privileged -# `review_checklists_apply.yml` workflow, which runs via `workflow_run` and -# therefore always executes with the base repository's permissions -# regardless of where the triggering event originated. Everything else the -# apply stage needs (event name, head SHA, PR number) is derived natively -# from the `workflow_run` context / the GitHub API rather than transferred -# here, keeping the artifact minimal and avoiding trust in most of the -# forked-repo-supplied payload. +# It performs no checkout and executes no repository/PR-supplied code, and +# transfers nothing to the privileged `review_checklists_apply.yml` +# workflow: everything the apply stage needs (event name, head SHA, PR +# number, and even whether anything actually needs invalidating) is derived +# natively from the `workflow_run` context / the GitHub API / a live diff +# against run history, so no event-payload data has to be trusted or +# carried across the trust boundary at all. This job's sole purpose is to +# exist and complete, so that `workflow_run` fires the apply stage, which +# always executes with the base repository's permissions regardless of +# where the triggering event originated. # # See "Preventing pwn requests" for background on this pattern: # https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ @@ -52,18 +51,12 @@ on: permissions: {} jobs: - capture-event: - name: Capture event + trigger-apply: + name: Trigger apply stage runs-on: ubuntu-24.04 steps: - - name: Save minimal event data - run: | - mkdir -p ./event - jq -c '{action: (.action // null)}' "$GITHUB_EVENT_PATH" > ./event/event.json - - - name: Upload event artifact - uses: actions/upload-artifact@v4 - with: - name: event - path: event/ - retention-days: 1 + # No-op: this job exists only so that its completion fires the + # `workflow_run`-triggered apply stage. Nothing needs to be computed + # or transferred here. + - name: Signal apply stage + run: echo "Triggering privileged apply stage via workflow_run." diff --git a/actions/review_checklists/action.yml b/actions/review_checklists/action.yml index d7c387022..86c076167 100644 --- a/actions/review_checklists/action.yml +++ b/actions/review_checklists/action.yml @@ -95,7 +95,7 @@ runs: run: python3 "$GITHUB_ACTION_PATH/scripts/check_acknowledgements.py" --config-path "${{ inputs.config-path }}" - - name: Run dismiss_and_invalidate (synchronize) + - name: Run dismiss_and_invalidate if: inputs.action == 'dismiss_sync' shell: bash env: diff --git a/actions/review_checklists/scripts/dismiss_and_invalidate.py b/actions/review_checklists/scripts/dismiss_and_invalidate.py index f5301626d..98a3f4a0b 100644 --- a/actions/review_checklists/scripts/dismiss_and_invalidate.py +++ b/actions/review_checklists/scripts/dismiss_and_invalidate.py @@ -14,24 +14,31 @@ """Invalidate OK acknowledgements after a new push. -When new commits are pushed to the PR, this script determines which -checklist paths are affected by the *new* changes. For each affected -checklist, all existing OK replies are deleted and the commit status is -set back to pending. Approvals are **not** dismissed here — branch -rulesets handle dismissing stale reviews on new pushes. +This script runs on every ``pull_request_target`` sub-event (opened, +reopened, synchronize, edited) — the apply workflow does not need to know +which one fired. It determines which checklist paths are affected by +commits introduced since the previous trigger run, and for each affected +checklist deletes all existing OK replies and sets the commit status back +to pending. Approvals are **not** dismissed here — branch rulesets handle +dismissing stale reviews on new pushes. + +When the head SHA hasn't actually moved since the previous trigger run +(e.g. on "edited"/"reopened" without a new push), the diff against that +previous run's head SHA is empty, so no checklists are affected and this +is a no-op — no separate signal for "was this actually a push" is needed. (OK-comment edits/deletions no longer need dedicated handling here: the "check" action already re-scans the current comment state on every invocation, so an edited/deleted OK comment is naturally no longer counted the next time acknowledgements are checked.) -The "files changed in the latest push" are found without needing the -``before``/``after`` SHAs from the original event payload: this script -looks up the previous run of the "Review Checklists (Trigger)" workflow -for the same head branch (ordered by run creation time) and uses that -run's head SHA as the "before" commit. This keeps stage 1 → stage 2 data -transfer minimal while still comparing against exactly the commits -introduced by this push. +The "files changed since the previous trigger run" are found without +needing the ``before``/``after`` SHAs from the original event payload: +this script looks up the previous run of the "Review Checklists +(Trigger)" workflow for the same head branch (ordered by run creation +time) and uses that run's head SHA as the "before" commit. This keeps +stage 1 → stage 2 data transfer at zero while still comparing against +exactly the commits introduced since the last time this ran. """ from __future__ import annotations From 32189314d2ca8729579f9e2dbbb1313380d7b7d3 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Mon, 10 Aug 2026 11:18:59 +0200 Subject: [PATCH 06/34] Resolve PR number by head branch, not head SHA, to avoid a fast-iteration race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Resolve PR number' step previously matched open PRs by head.sha == workflow_run.head_sha. If the PR receives another push while the apply run is queued or executing, no open PR will still have that exact SHA as its head by the time this step runs, so pr_number resolves empty and the whole apply run silently no-ops for that (now-superseded) trigger. Match on the head branch reference instead (workflow_run.head_repository.owner.login + head_branch via 'pulls?head=owner:branch'), which stays valid across such races as long as the PR is still open — the exact commit no longer matters for identifying which PR this run belongs to. Downstream steps already re-fetch the PR object and use its live pr.head.sha for everything except the (SHA-independent) merge_group status-setting path, so no script changes were needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/review_checklists_apply.yml | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/review_checklists_apply.yml b/.github/workflows/review_checklists_apply.yml index 104668784..89ba682bd 100644 --- a/.github/workflows/review_checklists_apply.yml +++ b/.github/workflows/review_checklists_apply.yml @@ -86,21 +86,26 @@ jobs: uses: actions/checkout@v6.0.2 # Resolves the PR number authoritatively via the API instead of - # trusting an artifact-supplied value: matches the head SHA that this - # run actually executed against (github.event.workflow_run.head_sha, - # verified reliable even for fork PRs) against currently open PRs. - # Not needed for merge_group, which has no associated PR. + # trusting an artifact-supplied value: matches on the head branch + # reference (github.event.workflow_run.head_repository.owner.login + + # head_branch), not the head SHA. Matching by SHA would break under + # fast iteration: if the PR receives another push while this run is + # queued/running, no open PR would still have that exact SHA as its + # head by the time this step executes, and pr_number would resolve + # empty. The branch reference stays valid across such races as long + # as the PR is still open. Not needed for merge_group, which has no + # associated PR. - name: Resolve PR number id: resolve_pr if: github.event.workflow_run.event != 'merge_group' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + HEAD_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} run: | pr_number="$(gh api "repos/${{ github.repository }}/pulls" \ - -X GET -f state=open --paginate \ - --jq '.[] | select(.head.sha == env.HEAD_SHA) | .number' \ - | head -n1)" + -X GET -f state=open -f "head=${HEAD_OWNER}:${HEAD_BRANCH}" --paginate \ + --jq '.[0].number // empty')" echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT" # Step 1: Post or update checklist findings. From cb82f95f13a0cfbae1e6618b759248e7c48a1d80 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Mon, 10 Aug 2026 12:48:42 +0200 Subject: [PATCH 07/34] Disable the example checklist Disable the example checklist to not spam users when the infrastructure is merged. Actual production checklists to be merged later. --- .github/review_checklists.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/review_checklists.yml b/.github/review_checklists.yml index e9305bb80..e5124cd29 100644 --- a/.github/review_checklists.yml +++ b/.github/review_checklists.yml @@ -19,14 +19,14 @@ # exclude: (optional) List of glob patterns; a file matching any of these is excluded # checklist: Markdown checklist body shown to reviewers -checklists: - - id: example-review - name: "Example checklist" - include: - - "**" - exclude: - - ".github/**" - checklist: | - - This is an example checklist item - - Avoid checkmarks in the items since this makes it easy for users to accidentally modify the checklist - - Modifying the checklist will reset all previous acknowledgments +checklists: [] +# - id: example-review +# name: "Example checklist" +# include: +# - "**" +# exclude: +# - ".github/**" +# checklist: | +# - This is an example checklist item +# - Avoid checkmarks in the items since this makes it easy for users to accidentally modify the checklist +# - Modifying the checklist will reset all previous acknowledgments From 714236bc30c50fe95cdd6a63ec287fcd22e276ae Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Mon, 10 Aug 2026 13:11:29 +0200 Subject: [PATCH 08/34] Move review-checklists action to tools/review-checklists; add README Relocates the composite action and its scripts from actions/review_checklists/ to tools/review-checklists/, updating all path references: MODULE.bazel's pip.parse requirements_lock, the Bazel BUILD files' visibility/label, requirements.txt(.in)'s embedded paths, and the apply workflow's three 'uses: ./...' steps. Adds tools/review-checklists/README.md documenting: - What the action/workflows do and the acknowledgement model (threaded 'OK' replies to file-level review comment findings). - Why the logic is split into an unprivileged trigger workflow and a privileged workflow_run-triggered apply workflow (pwn-request prevention), and that the apply workflow always reads .github/review_checklists.yml from the base branch, never the PR branch. - A step-by-step list of what a consuming repository must additionally configure: vendoring/referencing the action, creating the checklist config, adjusting the base-branch filter, requiring the 'review-checklists' commit status (not the workflow job) plus an approving-review rule in branch protection, the merge-queue notice's assumption that the queue's merge method is a real merge commit, the 'reply OK in-thread' acknowledgement UX, the diff-position-1 anchor constraint on checklist include patterns, runner/network requirements, and keeping (or updating) the trigger workflow's filename since it's looked up by name for run-history diffing. - The checklist YAML schema and the composite action's inputs. All 5 existing Bazel tests (including the requirements lock check) pass unchanged at the new location. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/review_checklists_apply.yml | 6 +- MODULE.bazel | 2 +- .../review-checklists}/BUILD | 0 tools/review-checklists/README.md | 225 ++++++++++++++++++ .../review-checklists}/action.yml | 0 .../review-checklists}/requirements.txt | 12 +- .../review-checklists}/requirements.txt.in | 0 .../review-checklists}/scripts/BUILD | 2 +- .../review-checklists}/scripts/__init__.py | 0 .../scripts/check_acknowledgements.py | 0 .../scripts/check_acknowledgements_test.py | 0 .../scripts/dismiss_and_invalidate.py | 0 .../scripts/dismiss_and_invalidate_test.py | 0 .../review-checklists}/scripts/helpers.py | 0 .../scripts/helpers_test.py | 0 .../scripts/post_checklists.py | 0 .../scripts/post_checklists_test.py | 0 17 files changed, 236 insertions(+), 11 deletions(-) rename {actions/review_checklists => tools/review-checklists}/BUILD (100%) create mode 100644 tools/review-checklists/README.md rename {actions/review_checklists => tools/review-checklists}/action.yml (100%) rename {actions/review_checklists => tools/review-checklists}/requirements.txt (99%) rename {actions/review_checklists => tools/review-checklists}/requirements.txt.in (100%) rename {actions/review_checklists => tools/review-checklists}/scripts/BUILD (97%) rename {actions/review_checklists => tools/review-checklists}/scripts/__init__.py (100%) rename {actions/review_checklists => tools/review-checklists}/scripts/check_acknowledgements.py (100%) rename {actions/review_checklists => tools/review-checklists}/scripts/check_acknowledgements_test.py (100%) rename {actions/review_checklists => tools/review-checklists}/scripts/dismiss_and_invalidate.py (100%) rename {actions/review_checklists => tools/review-checklists}/scripts/dismiss_and_invalidate_test.py (100%) rename {actions/review_checklists => tools/review-checklists}/scripts/helpers.py (100%) rename {actions/review_checklists => tools/review-checklists}/scripts/helpers_test.py (100%) rename {actions/review_checklists => tools/review-checklists}/scripts/post_checklists.py (100%) rename {actions/review_checklists => tools/review-checklists}/scripts/post_checklists_test.py (100%) diff --git a/.github/workflows/review_checklists_apply.yml b/.github/workflows/review_checklists_apply.yml index 89ba682bd..d7aa37874 100644 --- a/.github/workflows/review_checklists_apply.yml +++ b/.github/workflows/review_checklists_apply.yml @@ -116,7 +116,7 @@ jobs: if: >- github.event.workflow_run.event == 'pull_request_target' || github.event.workflow_run.event == 'pull_request_review_comment' - uses: ./actions/review_checklists + uses: ./tools/review-checklists with: action: post github-token: ${{ secrets.GITHUB_TOKEN }} @@ -131,7 +131,7 @@ jobs: # diff is empty and this step is a no-op. - name: Invalidate stale acknowledgements if: github.event.workflow_run.event == 'pull_request_target' - uses: ./actions/review_checklists + uses: ./tools/review-checklists with: action: dismiss_sync github-token: ${{ secrets.GITHUB_TOKEN }} @@ -153,7 +153,7 @@ jobs: # ever runs via workflow_run) — branch protection must require the # commit status context (not the workflow check run) to gate merges. - name: Check acknowledgements - uses: ./actions/review_checklists + uses: ./tools/review-checklists with: action: check github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/MODULE.bazel b/MODULE.bazel index 5db1fc336..3416ce8da 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -416,7 +416,7 @@ use_repo(pip, "codeql_coding_standards_pip_hub") pip.parse( hub_name = "review_checklists_dependencies", python_version = "3.12", - requirements_lock = "//actions/review_checklists:requirements.txt", + requirements_lock = "//tools/review-checklists:requirements.txt", ) use_repo(pip, "review_checklists_dependencies") diff --git a/actions/review_checklists/BUILD b/tools/review-checklists/BUILD similarity index 100% rename from actions/review_checklists/BUILD rename to tools/review-checklists/BUILD diff --git a/tools/review-checklists/README.md b/tools/review-checklists/README.md new file mode 100644 index 000000000..48d044dc8 --- /dev/null +++ b/tools/review-checklists/README.md @@ -0,0 +1,225 @@ + + +# Review Checklists + +A GitHub Actions composite action (plus a pair of workflows) that posts +per-path review checklists on pull requests, tracks reviewer +acknowledgements as threaded "OK" replies, and gates merging on every +approving reviewer having acknowledged every checklist relevant to the +files they're approving. + +It also understands GitHub's merge queue: while a PR is enqueued it posts +a notice explaining that the evidence already recorded is what will be +carried into the merge commit. + +## How it works + +For every path pattern group ("checklist") whose `include`/`exclude` glob +patterns match at least one changed file in the PR, the action: + +1. Posts a file-level PR review comment (a "finding") containing the + checklist body, anchored to one of the matched files. Reviewers + acknowledge a checklist by replying to that specific conversation + thread with exactly `OK` (case-insensitive). +2. Tracks, for each checklist, which of the PR's current approving + reviewers have replied `OK` in that checklist's thread. +3. Sets a commit status (context `review-checklists`) to `success` only + once every current approving reviewer has acknowledged every relevant + checklist; otherwise `pending` (or `failure`). +4. Invalidates (deletes) all existing `OK` replies for a checklist when + new commits touching its paths are pushed, so approvals must be + re-acknowledged against the new code. +5. Maintains a Markdown "evidence" block in the PR description that + records the checklist/acknowledgement state, and a merge-queue notice + (comment + PR description block) while the PR is enqueued. + +### Two-stage workflow (trigger / apply) + +This logic needs to run — and be able to write PR comments and commit +statuses — for events that can be raised by a fork PR +(`pull_request_review`, `pull_request_review_comment`). GitHub always +downgrades `GITHUB_TOKEN` to read-only for those events, no matter what a +workflow's `permissions:` block requests. To get a writable token safely, +the logic is split into two workflows following GitHub's +["preventing pwn requests"](https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/) +pattern: + +- **`review_checklists_trigger.yml`** (unprivileged, `permissions: {}`): + reacts to every event that can affect checklist state + (`pull_request_target`, `pull_request_review_comment`, + `pull_request_review`, `merge_group`). It performs no checkout, executes + no repository/PR-supplied code, and does no privileged work — it exists + solely so that its completion fires the second workflow. No data is + transferred between the two workflows: everything the second workflow + needs is derived natively from the `workflow_run` context, the GitHub + API, or a live diff against prior run history. +- **`review_checklists_apply.yml`** (privileged, triggered via + `workflow_run` once the trigger workflow completes): `workflow_run` + always executes using the *base* repository's workflow file and + permissions, regardless of which repository (including forks) raised + the original event. This is what lets it safely hold a + `pull-requests: write` / `statuses: write` token. It resolves the PR + number (by matching the head branch reference, which stays valid even + if the PR is pushed again while this run is queued), derives the + original event name and head SHA from `github.event.workflow_run`, and + invokes this composite action with the appropriate `action` input. + +Because the apply workflow checks out the **base branch** (`main`), not +the PR branch, the checklist logic itself (and `.github/review_checklists.yml`) +always runs as defined on the base branch — a PR cannot alter or disable +its own checklist enforcement by editing files on its own branch. + +## Layout + +``` +tools/review-checklists/ +├── action.yml # Composite action: action=post|check|dismiss_sync +├── requirements.txt(.in) # Locked Python dependencies (pip-compile via Bazel) +├── BUILD # Bazel target to (re-)generate requirements.txt +└── scripts/ + ├── helpers.py # Shared GitHub API / checklist-matching helpers + ├── post_checklists.py # action=post – post/update checklist findings + ├── check_acknowledgements.py # action=check – verify acks, set commit status + ├── dismiss_and_invalidate.py # action=dismiss_sync – invalidate stale OKs + └── *_test.py # pytest unit tests for the above (run via Bazel) +``` + +The two workflow files that drive this action live in +`.github/workflows/review_checklists_trigger.yml` and +`.github/workflows/review_checklists_apply.yml`. + +## Setting this up in a repository + +**This is not a turnkey drop-in — the following must be configured for +each repository that wants to use it.** + +1. **Vendor the code.** Copy `tools/review-checklists/` and both + `.github/workflows/review_checklists_trigger.yml` / + `review_checklists_apply.yml` into the target repository (or point the + `uses:` steps in the apply workflow at this repository/ref instead of + the local `./tools/review-checklists` path, e.g. + `eclipse-score/communication/tools/review-checklists@`, if you'd + rather reference it remotely than vendor it). +2. **Create `.github/review_checklists.yml`** in the target repository + with at least one checklist entry (see [Configuration](#configuration) + below). An empty `checklists: []` is valid and simply means no + checklist ever applies — the workflows still run but are a no-op. +3. **Adjust the base branch filter.** `review_checklists_trigger.yml` + restricts `pull_request_target` to `branches: [main]`. Change this if + your default branch has a different name. +4. **Require the commit status, not a workflow check run, in branch + protection.** Because the apply workflow only ever runs via + `workflow_run`, GitHub cannot bind it as a "required workflow" check + directly on the PR. Instead, configure your branch protection rule / + ruleset to require the commit status context **`review-checklists`** + (the one this action sets via `set_commit_status`). Requiring the + workflow job itself will not gate merges correctly. +5. **Require at least one approving review** in branch protection. The + check only evaluates *approving* reviewers against acknowledgements — + with zero approvals, the commit status stays `pending` forever by + design (see `check_acknowledgements.py`), but you still need a real PR + review requirement configured for that to have teeth. +6. **If you use GitHub's merge queue**, be aware the merge-queue evidence + notice assumes the queue's merge method is `MERGE` (a real merge + commit) — the evidence block accumulated in the PR description is only + preserved for reviewers/auditors if it ends up in a merge commit's + message/history. If your merge queue is configured for `SQUASH` or + `REBASE`, the notice's premise ("post-queue changes do not alter + evidence recorded in git history by the merge commit") does not hold, + and this should be reconsidered. You can check your repository's + configured merge method under the ruleset's `merge_queue` rule + (`merge_method`). +7. **Reviewers must acknowledge by replying `OK`** (exact text, + case-insensitive) directly in the threaded conversation under the + bot-posted checklist comment — a general PR approval alone does not + count as an acknowledgement of any checklist. +8. **Checklist `include` patterns should match files that appear as + normal text diffs.** Findings are posted as inline review comments + anchored at diff position 1 of the first matched file; if every + matched file is binary or otherwise has no diff line at that position, + posting the finding will fail. Prefer patterns that also match at + least one text file whenever binary files are included. +9. **Runner requirements.** The workflows run on `ubuntu-24.04` GitHub-hosted + runners, use the pre-installed `gh` CLI and `python3`/`pip`, and install + this action's Python dependencies from PyPI on every run (outbound + network access is required; nothing is vendored/cached at runtime). If + you use self-hosted runners, ensure `gh`, `python3`, and PyPI access are + available. +10. **Keep the trigger workflow's filename as `review_checklists_trigger.yml`**, + or update `TRIGGER_WORKFLOW_FILE` in `scripts/dismiss_and_invalidate.py` + to match — it's looked up by filename via the GitHub API to find the + previous trigger run for a branch (used to compute which files changed + since the last push without needing any before/after SHA handed across + the trust boundary). + +## Configuration + +`.github/review_checklists.yml` (path is also overridable via the +composite action's `config-path` input, but is read from the **base** +branch, not the PR branch — see above): + +```yaml +checklists: + - id: example-review # Unique identifier (used in markers/tracking) + name: "Example checklist" # Human-readable name shown in the PR conversation + include: # Glob patterns; a changed file must match at least one + - "**" + exclude: # Optional: glob patterns; matching files are excluded + - ".github/**" + checklist: | # Markdown checklist body shown to reviewers + - This is an example checklist item + - Avoid checkmarks in the items — makes it easy to accidentally + modify the checklist + - Modifying the checklist resets all previous acknowledgements +``` + +Notes: +- The file must exist and contain a `checklists` key; `checklists: []` + means no checklist ever applies (the workflows still run but do nothing). +- A file matching multiple checklists triggers all of them. +- Changing a checklist's `checklist:` body text does not retroactively + invalidate prior acknowledgements of that checklist — only new pushes + touching its `include` paths do (see `dismiss_and_invalidate.py`). + +## Composite action inputs (`action.yml`) + +| Input | Required | Used by | Purpose | +|---|---|---|---| +| `action` | yes | all | One of `post`, `check`, `dismiss_sync` | +| `github-token` | yes | all | Token with `pull-requests: write` / `statuses: write` | +| `pr-number` | no | all except merge_group | Pull request number | +| `run-id` | no | `dismiss_sync` | Current trigger-workflow run id (for run-history lookup) | +| `head-branch` | no | `dismiss_sync` | Head branch name (scopes the run-history lookup) | +| `head-sha` | no | `check` | Commit SHA to set status on for `merge_group` events | +| `event-name` | no | `check` | Overrides ambient event name when invoked from `workflow_run` | +| `config-path` | no | all | Path to the checklist YAML (default `.github/review_checklists.yml`) | + +## Development + +Python dependencies are locked with Bazel/`rules_python` pip integration: + +```sh +# Run all tests +bazel test //tools/review-checklists/... + +# Regenerate requirements.txt after editing requirements.txt.in +bazel run //tools/review-checklists:requirements.update +``` + +At workflow runtime, dependencies are installed directly via +`pip install -r requirements.txt` — Bazel is only used for local +development/testing and lock-file generation, not by the GitHub Actions +runner. diff --git a/actions/review_checklists/action.yml b/tools/review-checklists/action.yml similarity index 100% rename from actions/review_checklists/action.yml rename to tools/review-checklists/action.yml diff --git a/actions/review_checklists/requirements.txt b/tools/review-checklists/requirements.txt similarity index 99% rename from actions/review_checklists/requirements.txt rename to tools/review-checklists/requirements.txt index 5981e7a00..cf090338f 100644 --- a/actions/review_checklists/requirements.txt +++ b/tools/review-checklists/requirements.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# bazel run //actions/review_checklists:requirements.update +# bazel run //tools/review-checklists:requirements.update # anyio==4.12.1 \ --hash=sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703 \ @@ -14,7 +14,7 @@ backoff==2.2.1 \ # via gql bazel-runfiles==1.8.4 \ --hash=sha256:2c9c91d9f20f89642f00d533525f179586f8892af8aa451d71265824a6569582 - # via -r actions/review_checklists/requirements.txt.in + # via -r tools/review-checklists/requirements.txt.in certifi==2026.2.25 \ --hash=sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa \ --hash=sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7 @@ -276,7 +276,7 @@ cryptography==46.0.5 \ gql[requests]==4.0.0 \ --hash=sha256:f22980844eb6a7c0266ffc70f111b9c7e7c7c13da38c3b439afc7eab3d7c9c8e \ --hash=sha256:f3beed7c531218eb24d97cb7df031b4a84fdb462f4a2beb86e2633d395937479 - # via -r actions/review_checklists/requirements.txt.in + # via -r tools/review-checklists/requirements.txt.in graphql-core==3.2.8 \ --hash=sha256:015457da5d996c924ddf57a43f4e959b0b94fb695b85ed4c29446e508ed65cf3 \ --hash=sha256:cbee07bee1b3ed5e531723685369039f32ff815ef60166686e0162f540f1520c @@ -579,7 +579,7 @@ pycparser==3.0 \ pygithub==2.8.1 \ --hash=sha256:23a0a5bca93baef082e03411bf0ce27204c32be8bfa7abc92fe4a3e132936df0 \ --hash=sha256:341b7c78521cb07324ff670afd1baa2bf5c286f8d9fd302c1798ba594a5400c9 - # via -r actions/review_checklists/requirements.txt.in + # via -r tools/review-checklists/requirements.txt.in pygments==2.19.2 \ --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b @@ -618,7 +618,7 @@ pynacl==1.6.2 \ pytest==8.4.1 \ --hash=sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7 \ --hash=sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c - # via -r actions/review_checklists/requirements.txt.in + # via -r tools/review-checklists/requirements.txt.in pyyaml==6.0.3 \ --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ @@ -693,7 +693,7 @@ pyyaml==6.0.3 \ --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 - # via -r actions/review_checklists/requirements.txt.in + # via -r tools/review-checklists/requirements.txt.in requests==2.32.5 \ --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf diff --git a/actions/review_checklists/requirements.txt.in b/tools/review-checklists/requirements.txt.in similarity index 100% rename from actions/review_checklists/requirements.txt.in rename to tools/review-checklists/requirements.txt.in diff --git a/actions/review_checklists/scripts/BUILD b/tools/review-checklists/scripts/BUILD similarity index 97% rename from actions/review_checklists/scripts/BUILD rename to tools/review-checklists/scripts/BUILD index 534d8c155..317a2c3fb 100644 --- a/actions/review_checklists/scripts/BUILD +++ b/tools/review-checklists/scripts/BUILD @@ -18,7 +18,7 @@ py_library( srcs = ["helpers.py"], data = ["//.github:review_checklists_config"], imports = ["."], - visibility = ["//actions/review_checklists:__subpackages__"], + visibility = ["//tools/review-checklists:__subpackages__"], deps = [ requirement("bazel-runfiles"), requirement("gql"), diff --git a/actions/review_checklists/scripts/__init__.py b/tools/review-checklists/scripts/__init__.py similarity index 100% rename from actions/review_checklists/scripts/__init__.py rename to tools/review-checklists/scripts/__init__.py diff --git a/actions/review_checklists/scripts/check_acknowledgements.py b/tools/review-checklists/scripts/check_acknowledgements.py similarity index 100% rename from actions/review_checklists/scripts/check_acknowledgements.py rename to tools/review-checklists/scripts/check_acknowledgements.py diff --git a/actions/review_checklists/scripts/check_acknowledgements_test.py b/tools/review-checklists/scripts/check_acknowledgements_test.py similarity index 100% rename from actions/review_checklists/scripts/check_acknowledgements_test.py rename to tools/review-checklists/scripts/check_acknowledgements_test.py diff --git a/actions/review_checklists/scripts/dismiss_and_invalidate.py b/tools/review-checklists/scripts/dismiss_and_invalidate.py similarity index 100% rename from actions/review_checklists/scripts/dismiss_and_invalidate.py rename to tools/review-checklists/scripts/dismiss_and_invalidate.py diff --git a/actions/review_checklists/scripts/dismiss_and_invalidate_test.py b/tools/review-checklists/scripts/dismiss_and_invalidate_test.py similarity index 100% rename from actions/review_checklists/scripts/dismiss_and_invalidate_test.py rename to tools/review-checklists/scripts/dismiss_and_invalidate_test.py diff --git a/actions/review_checklists/scripts/helpers.py b/tools/review-checklists/scripts/helpers.py similarity index 100% rename from actions/review_checklists/scripts/helpers.py rename to tools/review-checklists/scripts/helpers.py diff --git a/actions/review_checklists/scripts/helpers_test.py b/tools/review-checklists/scripts/helpers_test.py similarity index 100% rename from actions/review_checklists/scripts/helpers_test.py rename to tools/review-checklists/scripts/helpers_test.py diff --git a/actions/review_checklists/scripts/post_checklists.py b/tools/review-checklists/scripts/post_checklists.py similarity index 100% rename from actions/review_checklists/scripts/post_checklists.py rename to tools/review-checklists/scripts/post_checklists.py diff --git a/actions/review_checklists/scripts/post_checklists_test.py b/tools/review-checklists/scripts/post_checklists_test.py similarity index 100% rename from actions/review_checklists/scripts/post_checklists_test.py rename to tools/review-checklists/scripts/post_checklists_test.py From f8281cb7a3a450fc296a5ae01d31d0a508dd946e Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Mon, 10 Aug 2026 13:58:21 +0200 Subject: [PATCH 09/34] Post checklist findings as file-level comments to support binary files Switch from create_review(comments=[{path, position: 1, body}]) to the single-comment create_review_comment(..., subject_type="file") API. File-level review comments are anchored to a file as a whole rather than a specific diff position, so checklist include patterns can now match binary files (images, archives, etc.) without failing to post. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/review-checklists/README.md | 12 +++++----- .../scripts/post_checklists.py | 22 +++++++++---------- .../scripts/post_checklists_test.py | 14 +++++------- 3 files changed, 22 insertions(+), 26 deletions(-) diff --git a/tools/review-checklists/README.md b/tools/review-checklists/README.md index 48d044dc8..cda9e3319 100644 --- a/tools/review-checklists/README.md +++ b/tools/review-checklists/README.md @@ -146,12 +146,12 @@ each repository that wants to use it.** case-insensitive) directly in the threaded conversation under the bot-posted checklist comment — a general PR approval alone does not count as an acknowledgement of any checklist. -8. **Checklist `include` patterns should match files that appear as - normal text diffs.** Findings are posted as inline review comments - anchored at diff position 1 of the first matched file; if every - matched file is binary or otherwise has no diff line at that position, - posting the finding will fail. Prefer patterns that also match at - least one text file whenever binary files are included. +8. **Findings are posted as file-level review comments** (`subject_type: + "file"`) anchored to the first matched file, not to a specific diff + line/position. This means `include` patterns may freely match binary + files (images, archives, etc.) or any other file GitHub does not + render a text diff for — posting the finding does not depend on the + file having a diff hunk. 9. **Runner requirements.** The workflows run on `ubuntu-24.04` GitHub-hosted runners, use the pre-installed `gh` CLI and `python3`/`pip`, and install this action's Python dependencies from PyPI on every run (outbound diff --git a/tools/review-checklists/scripts/post_checklists.py b/tools/review-checklists/scripts/post_checklists.py index 307fbc9b4..5df6ee55e 100644 --- a/tools/review-checklists/scripts/post_checklists.py +++ b/tools/review-checklists/scripts/post_checklists.py @@ -85,21 +85,19 @@ def main() -> None: else: print(f"Checklist finding for '{cl['id']}' is already up to date") else: - # Post as a review with an inline comment (finding) on the first - # matched file. Using create_review with comments creates a + # Post a file-level review comment (subject_type="file") anchored + # to the first matched file. Unlike diff-position-anchored + # comments, file-level comments don't require the file to appear + # as a text diff hunk, so this also works for binary files and + # files GitHub doesn't render a diff for. It still creates a # PullRequestComment that supports threaded replies where # reviewers can acknowledge with OK. anchor_file = cl["matched_files"][0] - pr.create_review( - body="", - event="COMMENT", - comments=[ - { - "path": anchor_file, - "position": 1, - "body": body, - } - ], + pr.create_review_comment( + body=body, + commit=pr.head.sha, + path=anchor_file, + subject_type="file", ) print(f"Created checklist finding for '{cl['id']}'") diff --git a/tools/review-checklists/scripts/post_checklists_test.py b/tools/review-checklists/scripts/post_checklists_test.py index 347c1b273..efefff7df 100644 --- a/tools/review-checklists/scripts/post_checklists_test.py +++ b/tools/review-checklists/scripts/post_checklists_test.py @@ -77,14 +77,12 @@ def test_creates_new_review_with_inline_comment( main() - pr.create_review.assert_called_once() - call_kwargs = pr.create_review.call_args[1] - assert call_kwargs["event"] == "COMMENT" - comments = call_kwargs["comments"] - assert len(comments) == 1 - assert comments[0]["path"] == "src/api/handler.py" - assert comments[0]["position"] == 1 - assert "api-review" in comments[0]["body"] + pr.create_review_comment.assert_called_once() + call_kwargs = pr.create_review_comment.call_args[1] + assert call_kwargs["commit"] == "abc123" + assert call_kwargs["path"] == "src/api/handler.py" + assert call_kwargs["subject_type"] == "file" + assert "api-review" in call_kwargs["body"] mock_status.assert_called_with( repo, "abc123", From 881c16358684e58f5922962302d0f753a0bfbee4 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Mon, 10 Aug 2026 14:32:16 +0200 Subject: [PATCH 10/34] Restrict compile_pip_requirements to linux This makes glob builds more reliable on different platforms. --- tools/review-checklists/BUILD | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/review-checklists/BUILD b/tools/review-checklists/BUILD index d6da037ab..b8862fff9 100644 --- a/tools/review-checklists/BUILD +++ b/tools/review-checklists/BUILD @@ -15,5 +15,7 @@ load("@rules_python//python:pip.bzl", "compile_pip_requirements") compile_pip_requirements( name = "requirements", src = "requirements.txt.in", + exec_compatible_with = ["@platforms//os:linux"], requirements_txt = "requirements.txt", + target_compatible_with = ["@platforms//os:linux"], ) From 155b003ec282c978b6b87c3aa51a5be5de7d2e94 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Mon, 10 Aug 2026 16:02:48 +0200 Subject: [PATCH 11/34] Validate checklist evidence in merge queue instead of assuming it Previously, the merge_group handler in check_acknowledgements.py just set the commit status to success unconditionally ("checklists assumed OK"), trusting that the required status check had already validated the evidence before the PR entered the queue. This left a window where stale evidence (e.g. dismissed reviews, invalidated acks after the initial check went green) would not be caught at merge-queue time. Now the merge_group handler resolves the originating PR (parsed from the merge-queue's synthetic gh-readonly-queue//pr-- head ref) and re-runs the same acknowledgement validation against its current live state (changed files, checklist findings, OK replies, approving reviewers), setting success/pending/failure on the merge commit SHA accordingly. It performs no PR writes (no evidence/notice updates), only a read-only re-validation. Refactored the shared decision logic (approvers vs. acks -> state) into _acknowledgement_status() so both the live-PR flow and the merge_group validation path use identical logic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/review_checklists_apply.yml | 58 +++++--- tools/review-checklists/README.md | 11 +- .../scripts/check_acknowledgements.py | 135 ++++++++++++------ .../scripts/check_acknowledgements_test.py | 131 ++++++++++++++++- 4 files changed, 266 insertions(+), 69 deletions(-) diff --git a/.github/workflows/review_checklists_apply.yml b/.github/workflows/review_checklists_apply.yml index d7aa37874..48cecfdb7 100644 --- a/.github/workflows/review_checklists_apply.yml +++ b/.github/workflows/review_checklists_apply.yml @@ -51,7 +51,11 @@ # pull_request_review (submitted/dismissed) # -> Re-check acknowledgements (approver set may have changed). # merge_group (checks_requested) -# -> Re-check acknowledgements (assumed OK; see check_acknowledgements.py). +# -> Resolve the originating PR (parsed from the merge-queue's synthetic +# head ref) and re-validate its checklist evidence from scratch +# against its current live state — not merely assumed OK because a +# required status check passed at some earlier point (see +# check_acknowledgements.py). # # When a PR is in merge queue, the "Check acknowledgements" step also # ensures: @@ -86,26 +90,39 @@ jobs: uses: actions/checkout@v6.0.2 # Resolves the PR number authoritatively via the API instead of - # trusting an artifact-supplied value: matches on the head branch + # trusting an artifact-supplied value. + # + # For every event except merge_group, matches on the head branch # reference (github.event.workflow_run.head_repository.owner.login + # head_branch), not the head SHA. Matching by SHA would break under # fast iteration: if the PR receives another push while this run is # queued/running, no open PR would still have that exact SHA as its # head by the time this step executes, and pr_number would resolve # empty. The branch reference stays valid across such races as long - # as the PR is still open. Not needed for merge_group, which has no - # associated PR. + # as the PR is still open. + # + # For merge_group, there is no "open PR with this head" to match on + # (the head is the synthetic merge-queue ref), so the PR number is + # instead parsed directly out of that ref's name, which GitHub + # generates as `gh-readonly-queue//pr--`. + # This is needed so the "Check acknowledgements" step can resolve + # and re-validate the originating PR's checklist evidence rather + # than assuming it is still valid. - name: Resolve PR number id: resolve_pr - if: github.event.workflow_run.event != 'merge_group' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT_NAME: ${{ github.event.workflow_run.event }} HEAD_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }} HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} run: | - pr_number="$(gh api "repos/${{ github.repository }}/pulls" \ - -X GET -f state=open -f "head=${HEAD_OWNER}:${HEAD_BRANCH}" --paginate \ - --jq '.[0].number // empty')" + if [ "${EVENT_NAME}" = "merge_group" ]; then + pr_number="$(echo "${HEAD_BRANCH}" | grep -oP '(?<=/pr-)[0-9]+' || true)" + else + pr_number="$(gh api "repos/${{ github.repository }}/pulls" \ + -X GET -f state=open -f "head=${HEAD_OWNER}:${HEAD_BRANCH}" --paginate \ + --jq '.[0].number // empty')" + fi echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT" # Step 1: Post or update checklist findings. @@ -141,17 +158,20 @@ jobs: # Step 3: Always re-check acknowledgement status. # The script sets the commit status context "review-checklists" - # to "pending" or "success", and (as of this stateless redesign) - # also refreshes the merge-queue notice. It always re-scans the - # live comment state, so it naturally also picks up OK-comment - # edits/deletions (there's no dedicated "dismiss_edit" action - # anymore) and merge_group's "assumed OK" case, needing only the - # original event name and head SHA — both read directly from the - # workflow_run context. Commit statuses with the same context on - # the same SHA are deduplicated, so the latest result from any - # trigger always wins. This job always has write access (it only - # ever runs via workflow_run) — branch protection must require the - # commit status context (not the workflow check run) to gate merges. + # to "pending"/"success"/"failure", and (as of this stateless + # redesign) also refreshes the merge-queue notice. It always + # re-scans the live comment state, so it naturally also picks up + # OK-comment edits/deletions (there's no dedicated "dismiss_edit" + # action anymore). For merge_group it resolves the originating PR + # (via pr-number, parsed above from the synthetic queue ref) and + # re-validates that PR's checklist evidence from scratch — needing + # only the original event name, head SHA, and PR number, all + # derived directly from the workflow_run context. Commit statuses + # with the same context on the same SHA are deduplicated, so the + # latest result from any trigger always wins. This job always has + # write access (it only ever runs via workflow_run) — branch + # protection must require the commit status context (not the + # workflow check run) to gate merges. - name: Check acknowledgements uses: ./tools/review-checklists with: diff --git a/tools/review-checklists/README.md b/tools/review-checklists/README.md index cda9e3319..f1cc9bc57 100644 --- a/tools/review-checklists/README.md +++ b/tools/review-checklists/README.md @@ -21,9 +21,12 @@ acknowledgements as threaded "OK" replies, and gates merging on every approving reviewer having acknowledged every checklist relevant to the files they're approving. -It also understands GitHub's merge queue: while a PR is enqueued it posts -a notice explaining that the evidence already recorded is what will be -carried into the merge commit. +It also understands GitHub's merge queue: when the queue runs its checks +(`merge_group`) it re-validates the originating PR's checklist evidence +from scratch rather than assuming it is still valid, and while a PR is +enqueued and subsequently changed (reviewed, commented on, edited) it +posts a notice explaining that the evidence already recorded is what will +be carried into the merge commit. ## How it works @@ -200,7 +203,7 @@ Notes: |---|---|---|---| | `action` | yes | all | One of `post`, `check`, `dismiss_sync` | | `github-token` | yes | all | Token with `pull-requests: write` / `statuses: write` | -| `pr-number` | no | all except merge_group | Pull request number | +| `pr-number` | no | all | Pull request number (for `merge_group` events, the originating PR whose evidence is validated) | | `run-id` | no | `dismiss_sync` | Current trigger-workflow run id (for run-history lookup) | | `head-branch` | no | `dismiss_sync` | Head branch name (scopes the run-history lookup) | | `head-sha` | no | `check` | Commit SHA to set status on for `merge_group` events | diff --git a/tools/review-checklists/scripts/check_acknowledgements.py b/tools/review-checklists/scripts/check_acknowledgements.py index b3c4bd76a..7af3ec1ac 100644 --- a/tools/review-checklists/scripts/check_acknowledgements.py +++ b/tools/review-checklists/scripts/check_acknowledgements.py @@ -32,6 +32,14 @@ last step run), so it doubles as the single, fully stateless source of truth for acknowledgement status: it always re-scans the current live comment state rather than relying on which specific event triggered it. + +For ``merge_group`` events it does not merely assume the evidence is still +valid because a required status check passed at some earlier point: it +resolves the underlying pull request and re-runs the same acknowledgement +validation against its current live state, setting the commit status on +the merge-queue's merge-commit SHA. This closes the window between when +the PR's own check last went green and when it actually entered the queue +(and rejects entries where that PR can no longer be resolved at all). """ from __future__ import annotations @@ -97,6 +105,63 @@ def _collect_ok_acknowledgements( return acks +def _acknowledgement_status( + approvers: list[str], + relevant_ids: list[str], + acks: dict[str, set[str]], +) -> tuple[str, str]: + """Compute the (state, description) commit-status pair from ack data. + + Pure decision logic with no side effects, so it can be reused both for + the live PR flow (which also refreshes evidence/comments) and for + merge_group evidence validation (which must not write anything). + """ + if not approvers: + return "pending", "Awaiting at least one approving review" + + missing: dict[str, list[str]] = {} + for cid in relevant_ids: + not_acked = [u for u in approvers if u not in acks[cid]] + if not_acked: + missing[cid] = not_acked + + if missing: + summary_parts = [ + f"{cid}: awaiting {', '.join(users)}" for cid, users in missing.items() + ] + return "pending", "; ".join(summary_parts) + + return "success", "All checklists acknowledged by all approving reviewers" + + +def _validate_checklist_evidence(pr: Any, checklists: list[dict]) -> tuple[str, str]: + """Re-derive and validate checklist acknowledgement evidence for a PR. + + Reads the PR's current live state (changed files, checklist findings, + threaded OK replies, approving reviews) and returns the same + (state, description) pair that would be used to set the + "review-checklists" commit status, without any side effects. Used to + validate the evidence at merge_group time instead of assuming it is + still valid because a required status check passed at some earlier + point. + """ + changed_files = get_changed_files(pr) + relevant = match_checklists(checklists, changed_files) + + if not relevant: + return "success", "No checklists applicable" + + existing = find_existing_checklist_comments(pr) + relevant_ids = [cl["id"] for cl in relevant if cl["id"] in existing] + + if not relevant_ids: + return "pending", "Checklist comments not yet posted" + + acks = _collect_ok_acknowledgements(pr, existing, relevant_ids) + approvers = get_approving_reviewers(pr) + return _acknowledgement_status(approvers, relevant_ids, acks) + + def main() -> None: parser = argparse.ArgumentParser( description="Verify review-checklist acknowledgements on a PR." @@ -117,12 +182,29 @@ def main() -> None: sys.exit(1) repo_name = os.environ["GITHUB_REPOSITORY"] repo = gh.get_repo(repo_name) - set_commit_status( - repo, - head_sha, - "success", - "Merge queue: checklists assumed OK", - ) + + pr_number_raw = os.environ.get("PR_NUMBER", "") + try: + pr_number = int(pr_number_raw) + except ValueError: + pr_number = 0 + + if not pr_number: + description = ( + "Could not resolve pull request to validate checklist evidence" + ) + print(description) + set_commit_status(repo, head_sha, "failure", description) + sys.exit(1) + + pr = repo.get_pull(pr_number) + checklists = load_checklists(args.config_path) + state, description = _validate_checklist_evidence(pr, checklists) + set_commit_status(repo, head_sha, state, f"Merge queue: {description}") + if state != "success": + print(f"Merge-queue checklist evidence validation failed: {description}") + sys.exit(1) + print("Merge-queue checklist evidence validated ✅") return repo, pr = get_repo_and_pr(gh) @@ -160,44 +242,9 @@ def main() -> None: update_pr_description_with_evidence(pr, evidence_block) approvers = get_approving_reviewers(pr) - - if not approvers: - set_commit_status( - repo, - pr.head.sha, - "pending", - "Awaiting at least one approving review", - ) - print("No approving reviewers yet.") - return - - # Check: every approver must have acknowledged every relevant checklist. - missing: dict[str, list[str]] = {} - for cid in relevant_ids: - not_acked = [u for u in approvers if u not in acks[cid]] - if not_acked: - missing[cid] = not_acked - - if missing: - summary_parts = [] - for cid, users in missing.items(): - summary_parts.append(f"{cid}: awaiting {', '.join(users)}") - summary = "; ".join(summary_parts) - set_commit_status( - repo, - pr.head.sha, - "pending", - summary, - ) - print(f"Missing acknowledgements: {summary}") - else: - set_commit_status( - repo, - pr.head.sha, - "success", - "All checklists acknowledged by all approving reviewers", - ) - print("All checklists acknowledged ✅") + state, description = _acknowledgement_status(approvers, relevant_ids, acks) + set_commit_status(repo, pr.head.sha, state, description) + print(f"Acknowledgement status: {state} — {description}") # Write acknowledgement data for downstream use (merge evidence). ack_data = {cid: sorted(users) for cid, users in acks.items()} diff --git a/tools/review-checklists/scripts/check_acknowledgements_test.py b/tools/review-checklists/scripts/check_acknowledgements_test.py index 7938d712a..619053624 100644 --- a/tools/review-checklists/scripts/check_acknowledgements_test.py +++ b/tools/review-checklists/scripts/check_acknowledgements_test.py @@ -412,7 +412,7 @@ def test_merge_queue_notice_refreshed_when_pr_enqueued( @patch("check_acknowledgements.set_commit_status") @patch("check_acknowledgements.get_repo_and_pr") @patch("check_acknowledgements.get_github_client") - def test_merge_group_sets_success_without_pr_lookup( + def test_merge_group_without_pr_number_sets_failure( self, mock_gh, mock_repo_pr, mock_status ): repo = MagicMock() @@ -431,15 +431,142 @@ def test_merge_group_sets_success_without_pr_lookup( ), # Ensure the argument parser doesn't see pytest's CLI arguments. patch.object(sys, "argv", ["check_acknowledgements"]), + ): + with pytest.raises(SystemExit): + main() + + mock_repo_pr.assert_not_called() + mock_status.assert_called_once_with( + repo, + "merge123", + "failure", + "Could not resolve pull request to validate checklist evidence", + ) + + @patch("check_acknowledgements.set_commit_status") + @patch( + "check_acknowledgements.get_approving_reviewers", + return_value=["alice"], + ) + @patch( + "check_acknowledgements.load_checklists", + return_value=SAMPLE_CHECKLISTS, + ) + @patch("check_acknowledgements.get_repo_and_pr") + @patch("check_acknowledgements.get_github_client") + def test_merge_group_validates_evidence_and_sets_success( + self, mock_gh, mock_repo_pr, mock_load, mock_approvers, mock_status + ): + repo = MagicMock() + pr = MagicMock() + pr.get_files.return_value = [_make_file("src/api/foo.py")] + + cl_review = MagicMock() + cl_review.id = 100 + cl_review.body = "" + + ok_reply = _make_comment( + 101, + "OK", + "alice", + datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), + ) + ok_reply.in_reply_to_id = 100 + pr.get_review_comments.return_value = [ok_reply] + + gh = MagicMock() + gh.get_repo.return_value = repo + mock_gh.return_value = gh + repo.get_pull.return_value = pr + + with ( + patch( + "check_acknowledgements.find_existing_checklist_comments", + return_value={"api-review": cl_review}, + ), + patch.dict( + os.environ, + { + "GITHUB_EVENT_NAME": "merge_group", + "HEAD_SHA": "merge123", + "GITHUB_REPOSITORY": "acme/widgets", + "PR_NUMBER": "75", + }, + ), + patch.object(sys, "argv", ["check_acknowledgements"]), ): main() + repo.get_pull.assert_called_once_with(75) mock_repo_pr.assert_not_called() mock_status.assert_called_once_with( repo, "merge123", "success", - "Merge queue: checklists assumed OK", + "Merge queue: All checklists acknowledged by all approving reviewers", + ) + + @patch("check_acknowledgements.set_commit_status") + @patch( + "check_acknowledgements.get_approving_reviewers", + return_value=["alice", "bob"], + ) + @patch( + "check_acknowledgements.load_checklists", + return_value=SAMPLE_CHECKLISTS, + ) + @patch("check_acknowledgements.get_repo_and_pr") + @patch("check_acknowledgements.get_github_client") + def test_merge_group_validates_evidence_and_sets_pending_on_missing_ack( + self, mock_gh, mock_repo_pr, mock_load, mock_approvers, mock_status + ): + repo = MagicMock() + pr = MagicMock() + pr.get_files.return_value = [_make_file("src/api/foo.py")] + + cl_review = MagicMock() + cl_review.id = 100 + cl_review.body = "" + + # Only alice acked, bob (also an approver) did not. + ok_reply = _make_comment( + 101, + "OK", + "alice", + datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), + ) + ok_reply.in_reply_to_id = 100 + pr.get_review_comments.return_value = [ok_reply] + + gh = MagicMock() + gh.get_repo.return_value = repo + mock_gh.return_value = gh + repo.get_pull.return_value = pr + + with ( + patch( + "check_acknowledgements.find_existing_checklist_comments", + return_value={"api-review": cl_review}, + ), + patch.dict( + os.environ, + { + "GITHUB_EVENT_NAME": "merge_group", + "HEAD_SHA": "merge123", + "GITHUB_REPOSITORY": "acme/widgets", + "PR_NUMBER": "75", + }, + ), + patch.object(sys, "argv", ["check_acknowledgements"]), + ): + with pytest.raises(SystemExit): + main() + + mock_status.assert_called_once_with( + repo, + "merge123", + "pending", + "Merge queue: api-review: awaiting bob", ) From 8339f6e9cc6bbe1fe4e4bf56f4f7b6185051b085 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Mon, 10 Aug 2026 17:06:06 +0200 Subject: [PATCH 12/34] Transfer PR number from trigger to apply stage via artifact Replace the apply stage's own PR-number resolution (matching the workflow_run head branch, or a fragile commit-SHA search for pull_request_review*/fork events) with a value read directly from the trusted event payload in the trigger stage and forwarded as a build artifact. This fixes a real bug: for pull_request_review and pull_request_review_comment events, github.event.workflow_run's head_repository/head_branch always reflect the base repository, never the fork the review was actually submitted on, so the previous owner:branch lookup could never resolve a PR number for fork PRs on those events. The PR number is not sensitive/attacker-steerable data (worst case a wrong number just points at the wrong already-public, same-repo PR), so carrying it across the trust boundary this way does not reintroduce the pwn-request risk the two-stage split exists to avoid. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/review_checklists_apply.yml | 79 ++++++++----------- .../workflows/review_checklists_trigger.yml | 62 ++++++++++++--- tools/review-checklists/README.md | 26 +++--- 3 files changed, 99 insertions(+), 68 deletions(-) diff --git a/.github/workflows/review_checklists_apply.yml b/.github/workflows/review_checklists_apply.yml index 48cecfdb7..dd1c0cae3 100644 --- a/.github/workflows/review_checklists_apply.yml +++ b/.github/workflows/review_checklists_apply.yml @@ -22,14 +22,16 @@ # would otherwise force a read-only GITHUB_TOKEN. # # This workflow does all the actual work: it derives the original event -# name/head SHA/PR number entirely from the `workflow_run` context and the -# GitHub API and re-runs the checklist logic that the previous -# single-stage workflow used to run directly on the untrusted events. No -# artifact or other data is transferred from the trigger stage at all — -# not even the original event's `action` (opened/reopened/synchronize/ +# name/head SHA entirely from the `workflow_run` context and the GitHub +# API, and re-runs the checklist logic that the previous single-stage +# workflow used to run directly on the untrusted events. The only value +# carried over from the trigger stage is the PR number, downloaded from +# the artifact the trigger stage uploaded (see review_checklists_trigger.yml +# for why that value is safe to trust as-is). Nothing else is transferred +# — not even the original event's `action` (opened/reopened/synchronize/ # edited): `dismiss_sync` is simply run on every pull_request_target event # and self-determines whether anything actually changed (see below), so -# there is nothing left that stage 2 needs stage 1 to hand it. +# there is nothing else stage 2 needs stage 1 to hand it. # # Keep review-checklists up to date on pull requests and report whether all # checklists have been acknowledged by every approving reviewer. @@ -51,10 +53,11 @@ # pull_request_review (submitted/dismissed) # -> Re-check acknowledgements (approver set may have changed). # merge_group (checks_requested) -# -> Resolve the originating PR (parsed from the merge-queue's synthetic -# head ref) and re-validate its checklist evidence from scratch -# against its current live state — not merely assumed OK because a -# required status check passed at some earlier point (see +# -> Resolve the originating PR (its number transferred from the +# trigger stage, parsed there from the merge-queue's synthetic head +# ref) and re-validate its checklist evidence from scratch against +# its current live state — not merely assumed OK because a required +# status check passed at some earlier point (see # check_acknowledgements.py). # # When a PR is in merge queue, the "Check acknowledgements" step also @@ -89,40 +92,29 @@ jobs: - name: Checkout repository uses: actions/checkout@v6.0.2 - # Resolves the PR number authoritatively via the API instead of - # trusting an artifact-supplied value. + # Downloads the PR number from the artifact the trigger stage + # uploaded (see review_checklists_trigger.yml for why this value is + # safe to trust as-is). Requires actions: read to fetch artifacts + # from another workflow run via run-id. # - # For every event except merge_group, matches on the head branch - # reference (github.event.workflow_run.head_repository.owner.login + - # head_branch), not the head SHA. Matching by SHA would break under - # fast iteration: if the PR receives another push while this run is - # queued/running, no open PR would still have that exact SHA as its - # head by the time this step executes, and pr_number would resolve - # empty. The branch reference stays valid across such races as long - # as the PR is still open. - # - # For merge_group, there is no "open PR with this head" to match on - # (the head is the synthetic merge-queue ref), so the PR number is - # instead parsed directly out of that ref's name, which GitHub - # generates as `gh-readonly-queue//pr--`. - # This is needed so the "Check acknowledgements" step can resolve - # and re-validate the originating PR's checklist evidence rather - # than assuming it is still valid. - - name: Resolve PR number + # A missing/empty artifact (e.g. the trigger job failed before + # uploading, or ran before this artifact step existed) resolves to + # an empty pr_number, and downstream steps already tolerate that + # (they simply have nothing to act on). + - name: Download PR number + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: pr-number + path: pr-number + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Read PR number id: resolve_pr - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - EVENT_NAME: ${{ github.event.workflow_run.event }} - HEAD_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }} - HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} run: | - if [ "${EVENT_NAME}" = "merge_group" ]; then - pr_number="$(echo "${HEAD_BRANCH}" | grep -oP '(?<=/pr-)[0-9]+' || true)" - else - pr_number="$(gh api "repos/${{ github.repository }}/pulls" \ - -X GET -f state=open -f "head=${HEAD_OWNER}:${HEAD_BRANCH}" --paginate \ - --jq '.[0].number // empty')" - fi + pr_number="$(cat pr-number/pr-number.txt 2>/dev/null || true)" + echo "Resolved PR number: ${pr_number}" echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT" # Step 1: Post or update checklist findings. @@ -163,10 +155,9 @@ jobs: # re-scans the live comment state, so it naturally also picks up # OK-comment edits/deletions (there's no dedicated "dismiss_edit" # action anymore). For merge_group it resolves the originating PR - # (via pr-number, parsed above from the synthetic queue ref) and + # (via pr-number, transferred from the trigger stage above) and # re-validates that PR's checklist evidence from scratch — needing - # only the original event name, head SHA, and PR number, all - # derived directly from the workflow_run context. Commit statuses + # only the original event name, head SHA, and PR number. Commit statuses # with the same context on the same SHA are deduplicated, so the # latest result from any trigger always wins. This job always has # write access (it only ever runs via workflow_run) — branch diff --git a/.github/workflows/review_checklists_trigger.yml b/.github/workflows/review_checklists_trigger.yml index 36c2d8d4e..349eb7575 100644 --- a/.github/workflows/review_checklists_trigger.yml +++ b/.github/workflows/review_checklists_trigger.yml @@ -20,16 +20,22 @@ # `permissions:` block requests, so this workflow must not (and does not) # attempt to write anything. # -# It performs no checkout and executes no repository/PR-supplied code, and -# transfers nothing to the privileged `review_checklists_apply.yml` -# workflow: everything the apply stage needs (event name, head SHA, PR -# number, and even whether anything actually needs invalidating) is derived -# natively from the `workflow_run` context / the GitHub API / a live diff -# against run history, so no event-payload data has to be trusted or -# carried across the trust boundary at all. This job's sole purpose is to -# exist and complete, so that `workflow_run` fires the apply stage, which -# always executes with the base repository's permissions regardless of -# where the triggering event originated. +# It performs no checkout and executes no repository/PR-supplied code. The +# only thing handed across the trust boundary to the privileged +# `review_checklists_apply.yml` workflow is the PR number, uploaded as a +# build artifact for the apply stage to download by run ID. This is safe +# to trust as-is: it comes straight from the trusted event payload +# (github.event.pull_request.number, or parsed from +# github.event.merge_group.head_ref for merge_group), is never +# attacker-influenced code or a value that steers which logic runs, and at +# worst a wrong number would just point the apply stage at the wrong +# already-public, same-repository PR. Everything else the apply stage +# needs (event name, head SHA, and even whether anything actually needs +# invalidating) is still derived natively from the `workflow_run` context / +# the GitHub API / a live diff against run history. This job's sole +# purpose beyond that is to exist and complete, so that `workflow_run` +# fires the apply stage, which always executes with the base repository's +# permissions regardless of where the triggering event originated. # # See "Preventing pwn requests" for background on this pattern: # https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ @@ -55,8 +61,38 @@ jobs: name: Trigger apply stage runs-on: ubuntu-24.04 steps: - # No-op: this job exists only so that its completion fires the - # `workflow_run`-triggered apply stage. Nothing needs to be computed - # or transferred here. + # Resolve the PR number directly from the trusted event payload. + # + # For merge_group there is no github.event.pull_request; the number + # is instead parsed out of the synthetic queue ref name, which + # GitHub generates as `gh-readonly-queue//pr--`. + - name: Resolve PR number + id: resolve_pr + env: + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + MERGE_GROUP_REF: ${{ github.event.merge_group.head_ref }} + run: | + if [ "${EVENT_NAME}" = "merge_group" ]; then + pr_number="$(echo "${MERGE_GROUP_REF}" | grep -oP '(?<=/pr-)[0-9]+' || true)" + else + pr_number="${PR_NUMBER}" + fi + echo "Resolved PR number: ${pr_number}" + mkdir -p pr-number + echo -n "${pr_number}" > pr-number/pr-number.txt + + # Upload it for the privileged apply stage to pick up by run ID. + # This is the only piece of data transferred across the trust + # boundary; see the file-level comment above for why it's safe. + - name: Upload PR number + uses: actions/upload-artifact@v4 + with: + name: pr-number + path: pr-number/pr-number.txt + retention-days: 1 + + # No-op beyond the above: this job's completion is what fires the + # `workflow_run`-triggered apply stage. - name: Signal apply stage run: echo "Triggering privileged apply stage via workflow_run." diff --git a/tools/review-checklists/README.md b/tools/review-checklists/README.md index f1cc9bc57..74306cc8e 100644 --- a/tools/review-checklists/README.md +++ b/tools/review-checklists/README.md @@ -63,22 +63,26 @@ pattern: - **`review_checklists_trigger.yml`** (unprivileged, `permissions: {}`): reacts to every event that can affect checklist state (`pull_request_target`, `pull_request_review_comment`, - `pull_request_review`, `merge_group`). It performs no checkout, executes - no repository/PR-supplied code, and does no privileged work — it exists - solely so that its completion fires the second workflow. No data is - transferred between the two workflows: everything the second workflow - needs is derived natively from the `workflow_run` context, the GitHub - API, or a live diff against prior run history. + `pull_request_review`, `merge_group`). It performs no checkout and + executes no repository/PR-supplied code. The only data transferred to + the second workflow is the PR number, read directly from the trusted + event payload (`github.event.pull_request.number`, or parsed from the + merge queue's synthetic ref for `merge_group`) and uploaded as a build + artifact. This is safe to trust as-is — it's never attacker-influenced + code or a value that steers which logic runs, and at worst a wrong + number would just point the second workflow at the wrong already-public, + same-repository PR. Everything else the second workflow needs is derived + natively from the `workflow_run` context, the GitHub API, or a live diff + against prior run history. - **`review_checklists_apply.yml`** (privileged, triggered via `workflow_run` once the trigger workflow completes): `workflow_run` always executes using the *base* repository's workflow file and permissions, regardless of which repository (including forks) raised the original event. This is what lets it safely hold a - `pull-requests: write` / `statuses: write` token. It resolves the PR - number (by matching the head branch reference, which stays valid even - if the PR is pushed again while this run is queued), derives the - original event name and head SHA from `github.event.workflow_run`, and - invokes this composite action with the appropriate `action` input. + `pull-requests: write` / `statuses: write` token. It downloads the PR + number artifact uploaded by the trigger stage, derives the original + event name and head SHA from `github.event.workflow_run`, and invokes + this composite action with the appropriate `action` input. Because the apply workflow checks out the **base branch** (`main`), not the PR branch, the checklist logic itself (and `.github/review_checklists.yml`) From c901cb552da20ff87bf988052227513e2a4af81d Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Tue, 11 Aug 2026 09:58:00 +0200 Subject: [PATCH 13/34] Fix merge_group evidence validation never actually running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GITHUB_EVENT_NAME is a GitHub Actions reserved variable name: setting it via a step's env: block is silently ignored for the subprocess, which always sees the runner's real ambient value instead (here, "workflow_run", since that's what actually triggers review_checklists_apply.yml). This meant check_acknowledgements.py's `if event_name == "merge_group"` branch could never be reached when invoked from the apply workflow — confirmed live: for merge_group runs the script instead executed the normal PR-head-sha flow, posting the merge-queue notice and setting the commit status on pr.head.sha instead of the merge queue's synthetic HEAD_SHA, and never actually re-running _validate_checklist_evidence(). Fix: use a non-reserved variable name (CHECKLISTS_EVENT_NAME) to pass the original triggering event name into the script, so a step's own env: can actually override it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/review-checklists/action.yml | 14 ++++++++------ .../scripts/check_acknowledgements.py | 2 +- .../scripts/check_acknowledgements_test.py | 6 +++--- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/tools/review-checklists/action.yml b/tools/review-checklists/action.yml index 86c076167..08fcec99c 100644 --- a/tools/review-checklists/action.yml +++ b/tools/review-checklists/action.yml @@ -53,11 +53,13 @@ inputs: event-name: description: > Name of the original triggering event (e.g. 'pull_request_target', - 'pull_request_review_comment', 'merge_group'). Used to override the - ambient GITHUB_EVENT_NAME when this action is invoked from a - workflow_run-triggered privileged workflow, where the ambient event - differs from the event that should drive the checklist logic. - Defaults to the ambient github.event_name when not set. + 'pull_request_review_comment', 'merge_group'). Required when this + action is invoked from a workflow_run-triggered privileged workflow, + since the ambient GITHUB_EVENT_NAME there is always 'workflow_run' + and — being a reserved GitHub Actions variable name — cannot be + overridden for the subprocess by a step's own `env:` block. Defaults + to the ambient github.event_name when not set (only correct for + direct, non-workflow_run invocations). required: false default: "" config-path: @@ -91,7 +93,7 @@ runs: GITHUB_REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ inputs.pr-number }} HEAD_SHA: ${{ inputs.head-sha }} - GITHUB_EVENT_NAME: ${{ inputs.event-name || github.event_name }} + CHECKLISTS_EVENT_NAME: ${{ inputs.event-name || github.event_name }} run: python3 "$GITHUB_ACTION_PATH/scripts/check_acknowledgements.py" --config-path "${{ inputs.config-path }}" diff --git a/tools/review-checklists/scripts/check_acknowledgements.py b/tools/review-checklists/scripts/check_acknowledgements.py index 7af3ec1ac..1ba65c9e7 100644 --- a/tools/review-checklists/scripts/check_acknowledgements.py +++ b/tools/review-checklists/scripts/check_acknowledgements.py @@ -174,7 +174,7 @@ def main() -> None: args = parser.parse_args() gh = get_github_client() - event_name = os.environ.get("GITHUB_EVENT_NAME", "") + event_name = os.environ.get("CHECKLISTS_EVENT_NAME", "") if event_name == "merge_group": head_sha = os.environ.get("HEAD_SHA") if not head_sha: diff --git a/tools/review-checklists/scripts/check_acknowledgements_test.py b/tools/review-checklists/scripts/check_acknowledgements_test.py index 619053624..f131681fc 100644 --- a/tools/review-checklists/scripts/check_acknowledgements_test.py +++ b/tools/review-checklists/scripts/check_acknowledgements_test.py @@ -424,7 +424,7 @@ def test_merge_group_without_pr_number_sets_failure( patch.dict( os.environ, { - "GITHUB_EVENT_NAME": "merge_group", + "CHECKLISTS_EVENT_NAME": "merge_group", "HEAD_SHA": "merge123", "GITHUB_REPOSITORY": "acme/widgets", }, @@ -487,7 +487,7 @@ def test_merge_group_validates_evidence_and_sets_success( patch.dict( os.environ, { - "GITHUB_EVENT_NAME": "merge_group", + "CHECKLISTS_EVENT_NAME": "merge_group", "HEAD_SHA": "merge123", "GITHUB_REPOSITORY": "acme/widgets", "PR_NUMBER": "75", @@ -551,7 +551,7 @@ def test_merge_group_validates_evidence_and_sets_pending_on_missing_ack( patch.dict( os.environ, { - "GITHUB_EVENT_NAME": "merge_group", + "CHECKLISTS_EVENT_NAME": "merge_group", "HEAD_SHA": "merge123", "GITHUB_REPOSITORY": "acme/widgets", "PR_NUMBER": "75", From fc5ab6643b8ed5452ab96e4bf904f4fb3f3ffdf3 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Tue, 11 Aug 2026 11:31:06 +0200 Subject: [PATCH 14/34] Switch checklist path matching from fnmatch to pathspec (gitignore-style) Python's fnmatch treats '*' as matching across '/' with no distinction between recursive and single-segment wildcards, so patterns that look like standard glob syntax behave unintuitively: - "**/*.md" requires a literal '/' in the matched path, so it silently fails to match root-level files (discovered while live-testing a multi-checklist merge-queue scenario). - There was no way to anchor a pattern to the repo root at all. Switch include/exclude matching to the pathspec library's gitwildmatch style, i.e. the same pattern semantics as .gitignore: - Unanchored patterns ("*.md") match at any depth. - A leading '/' anchors a pattern to the repo root ("/*.md"). - "**" explicitly matches zero or more path segments, and correctly matches at the root too ("**/BUILD" now matches a root-level BUILD file, unlike the previous fnmatch-based implementation). Added pathspec as a new dependency (requirements.txt.in/BUILD), and added tests covering root-anchored and unanchored matching. Documented the new gitignore-style syntax in the README. All 5/5 existing tests continue to pass unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/review-checklists/README.md | 12 +++++++ tools/review-checklists/requirements.txt | 4 +++ tools/review-checklists/requirements.txt.in | 1 + tools/review-checklists/scripts/BUILD | 1 + tools/review-checklists/scripts/helpers.py | 20 +++++++++-- .../review-checklists/scripts/helpers_test.py | 36 +++++++++++++++++++ 6 files changed, 71 insertions(+), 3 deletions(-) diff --git a/tools/review-checklists/README.md b/tools/review-checklists/README.md index 74306cc8e..4e6b70504 100644 --- a/tools/review-checklists/README.md +++ b/tools/review-checklists/README.md @@ -200,6 +200,18 @@ Notes: - Changing a checklist's `checklist:` body text does not retroactively invalidate prior acknowledgements of that checklist — only new pushes touching its `include` paths do (see `dismiss_and_invalidate.py`). +- **`include`/`exclude` patterns use `.gitignore`-style syntax** (via the + [`pathspec`](https://pypi.org/project/pathspec/) library's `gitwildmatch` + pattern style), the same syntax used in `.gitignore` files: + - A pattern without a leading `/` matches at any depth: `"*.md"` matches + both `NOTE.md` and `docs/deep/NOTE.md`. + - A pattern with a leading `/` is anchored to the repo root: `"/*.md"` + matches only root-level `.md` files, not `docs/NOTE.md`. + - `**` explicitly matches zero or more path segments: `"docs/**"` matches + everything under `docs/`; `"**/BUILD"` matches a `BUILD` file at any + depth, including the repo root. + - `"**"` on its own matches everything (used by the example checklist + above). ## Composite action inputs (`action.yml`) diff --git a/tools/review-checklists/requirements.txt b/tools/review-checklists/requirements.txt index cf090338f..8a8af288e 100644 --- a/tools/review-checklists/requirements.txt +++ b/tools/review-checklists/requirements.txt @@ -444,6 +444,10 @@ packaging==26.0 \ --hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \ --hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529 # via pytest +pathspec==1.1.1 \ + --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \ + --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 + # via -r tools/review-checklists/requirements.txt.in pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 diff --git a/tools/review-checklists/requirements.txt.in b/tools/review-checklists/requirements.txt.in index e7c5322d0..777a58c27 100644 --- a/tools/review-checklists/requirements.txt.in +++ b/tools/review-checklists/requirements.txt.in @@ -13,6 +13,7 @@ bazel-runfiles==1.8.4 gql[requests]>=3.5.0 +pathspec>=0.12.1 PyGithub>=2.1.1 PyYAML>=6.0.1 pytest==8.4.1 diff --git a/tools/review-checklists/scripts/BUILD b/tools/review-checklists/scripts/BUILD index 317a2c3fb..e6e941109 100644 --- a/tools/review-checklists/scripts/BUILD +++ b/tools/review-checklists/scripts/BUILD @@ -22,6 +22,7 @@ py_library( deps = [ requirement("bazel-runfiles"), requirement("gql"), + requirement("pathspec"), requirement("pygithub"), requirement("pyyaml"), ], diff --git a/tools/review-checklists/scripts/helpers.py b/tools/review-checklists/scripts/helpers.py index 2cab94cbc..d5a68611e 100644 --- a/tools/review-checklists/scripts/helpers.py +++ b/tools/review-checklists/scripts/helpers.py @@ -15,10 +15,10 @@ from __future__ import annotations -import fnmatch import os from typing import Any +import pathspec import yaml from github import Github from github.PullRequest import PullRequest @@ -108,8 +108,22 @@ def get_changed_files(pr: PullRequest) -> list[str]: def _file_matches_patterns(filepath: str, patterns: list[str]) -> bool: - """Return True if filepath matches any of the given glob patterns.""" - return any(fnmatch.fnmatch(filepath, p) for p in patterns) + """Return True if filepath matches any of the given glob patterns. + + Patterns use ``.gitignore``-style ("gitwildmatch") syntax via the + ``pathspec`` library, not plain ``fnmatch``: + - A pattern without a leading ``/`` matches at any depth + (``"*.md"`` matches both ``NOTE.md`` and ``docs/NOTE.md``). + - A pattern with a leading ``/`` is anchored to the repo root + (``"/*.md"`` matches only root-level ``.md`` files). + - ``**`` explicitly matches zero or more path segments + (``"docs/**"`` matches everything under ``docs/``; + ``"**/BUILD"`` matches ``BUILD`` at any depth, including the root). + """ + if not patterns: + return False + spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns) + return spec.match_file(filepath) def match_checklists(checklists: list[dict], changed_files: list[str]) -> list[dict]: diff --git a/tools/review-checklists/scripts/helpers_test.py b/tools/review-checklists/scripts/helpers_test.py index 9878e78ab..a43b066a5 100644 --- a/tools/review-checklists/scripts/helpers_test.py +++ b/tools/review-checklists/scripts/helpers_test.py @@ -216,6 +216,42 @@ def test_build_glob(self): assert len(result) == 1 assert result[0]["id"] == "build-review" + def test_build_glob_matches_root_level_too(self): + # "**/BUILD" (gitignore semantics) also matches a root-level file, + # unlike a plain fnmatch translation of "**/BUILD". + files = ["BUILD"] + result = match_checklists(SAMPLE_CHECKLISTS, files) + assert len(result) == 1 + assert result[0]["id"] == "build-review" + + def test_unanchored_pattern_matches_root_and_nested(self): + cl = [ + { + "id": "md-anywhere", + "name": "Markdown anywhere", + "include": ["*.md"], + "checklist": "- [ ] Reviewed", + } + ] + files = ["NOTE.md", "docs/NOTE.md", "docs/deep/NOTE.md"] + result = match_checklists(cl, files) + assert len(result) == 1 + assert set(result[0]["matched_files"]) == set(files) + + def test_anchored_pattern_matches_root_only(self): + cl = [ + { + "id": "md-root-only", + "name": "Markdown at root", + "include": ["/*.md"], + "checklist": "- [ ] Reviewed", + } + ] + files = ["NOTE.md", "docs/NOTE.md"] + result = match_checklists(cl, files) + assert len(result) == 1 + assert result[0]["matched_files"] == ["NOTE.md"] + def test_multiple_files_same_checklist(self): files = ["src/api/a.py", "src/api/b.h"] result = match_checklists(SAMPLE_CHECKLISTS, files) From eb7fa1d7a535484715cfda007db03b1e08b0752f Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Tue, 11 Aug 2026 15:26:03 +0200 Subject: [PATCH 15/34] Reduce visibility Public visibility is actually not required for these tools. Recude it to necessary levels. --- .github/BUILD | 2 +- tools/review-checklists/scripts/BUILD | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/BUILD b/.github/BUILD index c5809021d..c82a32d87 100644 --- a/.github/BUILD +++ b/.github/BUILD @@ -14,5 +14,5 @@ filegroup( name = "review_checklists_config", srcs = ["review_checklists.yml"], - visibility = ["//visibility:public"], + visibility = ["//tools/review-checklists:__subpackages__"], ) diff --git a/tools/review-checklists/scripts/BUILD b/tools/review-checklists/scripts/BUILD index e6e941109..2c2b17eb6 100644 --- a/tools/review-checklists/scripts/BUILD +++ b/tools/review-checklists/scripts/BUILD @@ -53,7 +53,6 @@ py_binary( name = "post_checklists", srcs = ["post_checklists.py"], main = "post_checklists.py", - visibility = ["//visibility:public"], deps = [":helpers"], ) @@ -61,7 +60,6 @@ py_binary( name = "check_acknowledgements", srcs = ["check_acknowledgements.py"], main = "check_acknowledgements.py", - visibility = ["//visibility:public"], deps = [":helpers"], ) @@ -69,7 +67,6 @@ py_binary( name = "dismiss_and_invalidate", srcs = ["dismiss_and_invalidate.py"], main = "dismiss_and_invalidate.py", - visibility = ["//visibility:public"], deps = [":helpers"], ) From e0039ac985edef71a4af1b19c4fe6ea31d323ce5 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Thu, 13 Aug 2026 16:14:24 +0200 Subject: [PATCH 16/34] Fix stale copyright-checker srcs entry after actions/ -> tools/ move The composite action was later moved from actions/review_checklists to tools/review-checklists, but the root BUILD file's copyright_checker srcs list still referenced the now-nonexistent "actions" directory (a no-op glob) and never gained a "tools" entry, so the new tools/review-checklists sources were not covered by the copyright check at all. Addresses review comment: https://github.com/eclipse-score/communication/pull/899#discussion_r3764155316 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- BUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUILD b/BUILD index 2b804f50f..fbffa7e1b 100644 --- a/BUILD +++ b/BUILD @@ -44,10 +44,10 @@ copyright_checker( name = "copyright", srcs = [ ".github", - "actions", "quality", "score", "third_party", + "tools", "//:BUILD", "//:MODULE.bazel", ], From 68344107aae7d513b52e3f7311f5f576bb395f95 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Thu, 13 Aug 2026 16:14:50 +0200 Subject: [PATCH 17/34] Add cancel-in-progress to review-checklists apply workflow concurrency Multiple non-merge_group events (e.g. several review comments posted in quick succession) can each trigger a completed 'Review Checklists (Trigger)' run for the same head SHA, spawning overlapping apply-stage runs in the same concurrency group. Let a newer run cancel an older, now-superseded one instead of leaving it queued/running: the job only recomputes idempotent PR state (comments/statuses) from scratch each time, so cancelling a stale run is safe and matches this repo's existing convention of setting cancel-in-progress explicitly. Addresses review comment: https://github.com/eclipse-score/communication/pull/899#discussion_r3764411811 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/review_checklists_apply.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/review_checklists_apply.yml b/.github/workflows/review_checklists_apply.yml index dd1c0cae3..92d301c3d 100644 --- a/.github/workflows/review_checklists_apply.yml +++ b/.github/workflows/review_checklists_apply.yml @@ -82,6 +82,11 @@ permissions: concurrency: group: >- review-checklists-apply-${{ github.event.workflow_run.head_sha || github.event.workflow_run.id }} + # Multiple trigger-stage runs for the same head SHA can complete in quick + # succession (e.g. several review comments posted back-to-back), so let a + # newer run cancel an older, now-superseded one. Safe because this job only + # recomputes idempotent PR state (comments/statuses) from scratch each time. + cancel-in-progress: true jobs: review-checklists: From 0c12cba0735290fee3ba365dfefac9da3900acdf Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Thu, 13 Aug 2026 16:15:07 +0200 Subject: [PATCH 18/34] Document why review-checklists action installs deps with pip, not Bazel Addresses review comment: https://github.com/eclipse-score/communication/pull/899#discussion_r3764851279 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/review-checklists/action.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tools/review-checklists/action.yml b/tools/review-checklists/action.yml index 08fcec99c..eb93681f1 100644 --- a/tools/review-checklists/action.yml +++ b/tools/review-checklists/action.yml @@ -72,6 +72,13 @@ inputs: runs: using: composite steps: + # Deliberately installed with plain pip rather than invoked via Bazel: + # this action only needs to run a couple of standalone Python scripts + # against a locked requirements.txt, and doing that with pip is fast and + # has no dependency on the runner having a full Bazel setup or checkout + # of this monorepo's build graph. requirements.txt itself is still + # generated/locked via Bazel (see BUILD and README.md) so the pinned + # versions stay consistent with the rest of the repo's tooling. - name: Install review_checklists requirements shell: bash run: python3 -m pip install -r "$GITHUB_ACTION_PATH/requirements.txt" From 1865c83ec23acd5530ed460d5f43ccae36862d7e Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Thu, 13 Aug 2026 16:15:20 +0200 Subject: [PATCH 19/34] Clarify Otterdog status-check syntax for the review-checklists status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Otterdog's status-check reference distinguishes plain commit statuses (which need the 'any:' prefix) from GitHub Actions workflow-job or GitHub App statuses. The 'review-checklists' status this action sets via create_status is a plain commit status, so an Otterdog-managed branch protection rule/ruleset must reference it as 'any:review-checklists' — the bare name would not be recognized. Addresses review comment: https://github.com/eclipse-score/communication/pull/899#discussion_r3764990545 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/review-checklists/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/review-checklists/README.md b/tools/review-checklists/README.md index 4e6b70504..971ad4568 100644 --- a/tools/review-checklists/README.md +++ b/tools/review-checklists/README.md @@ -133,7 +133,12 @@ each repository that wants to use it.** directly on the PR. Instead, configure your branch protection rule / ruleset to require the commit status context **`review-checklists`** (the one this action sets via `set_commit_status`). Requiring the - workflow job itself will not gate merges correctly. + workflow job itself will not gate merges correctly. If you manage + branch protection with [Otterdog](https://otterdog.readthedocs.io/en/latest/reference/organization/repository/status-check/), + a plain commit status (as opposed to a status reported by a GitHub + Actions workflow job or a GitHub App) must be referenced with the + `any:` prefix, i.e. `any:review-checklists` — plain `review-checklists` + will not be recognized. 5. **Require at least one approving review** in branch protection. The check only evaluates *approving* reviewers against acknowledgements — with zero approvals, the commit status stays `pending` forever by From bbe339e1eac6572edf60c27f19602719d8555987 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Thu, 13 Aug 2026 16:17:46 +0200 Subject: [PATCH 20/34] Deduplicate OK-reply lookup across review-checklists scripts helpers.find_ok_replies (unused in production), helpers.collect_ acknowledgement_details, check_acknowledgements._collect_ok_ acknowledgements, and dismiss_and_invalidate._find_ok_comments_for_ checklist each independently re-implemented the same scan for OK-reply comments to a checklist finding. Extract a single shared helpers.find_ok_replies_for_checklists(pr, existing_comments, checklist_ids) that returns the 'superset' (full comment objects, in one pass over pr.get_review_comments()) and have each caller project it down to the narrower shape it needs. This also removes an O(n*k) re-scan in dismiss_and_invalidate.handle_synchronize (one scan per affected checklist) in favor of a single O(n) pass. Also renamed the now-shared collect_acknowledgement_details parameter 'relevant_ids' to 'checklist_ids' for clarity, since it isn't obvious from the name alone what the ids are relevant to. Addresses review comments: https://github.com/eclipse-score/communication/pull/899#discussion_r3772817979 https://github.com/eclipse-score/communication/pull/899#discussion_r3772590463 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/check_acknowledgements.py | 36 ++----- .../scripts/dismiss_and_invalidate.py | 34 +------ .../scripts/dismiss_and_invalidate_test.py | 65 ------------- tools/review-checklists/scripts/helpers.py | 93 ++++++++++--------- .../review-checklists/scripts/helpers_test.py | 47 +++++----- .../scripts/post_checklists.py | 4 +- 6 files changed, 92 insertions(+), 187 deletions(-) diff --git a/tools/review-checklists/scripts/check_acknowledgements.py b/tools/review-checklists/scripts/check_acknowledgements.py index 1ba65c9e7..66b63699d 100644 --- a/tools/review-checklists/scripts/check_acknowledgements.py +++ b/tools/review-checklists/scripts/check_acknowledgements.py @@ -51,12 +51,12 @@ from typing import Any from helpers import ( - _collect_acknowledgement_details, - OK_KEYWORD, build_evidence_block, + collect_acknowledgement_details, ensure_merge_queue_notice_comment, ensure_merge_queue_notice_description, find_existing_checklist_comments, + find_ok_replies_for_checklists, get_approving_reviewers, get_changed_files, get_github_client, @@ -74,35 +74,17 @@ def _collect_ok_acknowledgements( ) -> dict[str, set[str]]: """Return a mapping of checklist_id → set of usernames who acknowledged. - We inspect PR review comment replies (threaded conversations). A reply - counts as an OK for a checklist if: + A reply counts as an OK for a checklist if: - Its ``in_reply_to_id`` matches the checklist finding comment id, AND - Its body (stripped, case-insensitive) equals the ``OK`` keyword. The conversation thread itself associates the reply with the checklist. """ - acks: dict[str, set[str]] = {cid: set() for cid in relevant_ids} - - # Build a mapping of checklist comment id → checklist id. - cl_comment_ids: dict[int, str] = {} - for cid, comment in existing_comments.items(): - if cid in relevant_ids: - cl_comment_ids[comment.id] = cid - - # Walk all review comments looking for replies to checklist findings. - for comment in pr.get_review_comments(): - reply_to = getattr(comment, "in_reply_to_id", None) - if reply_to is None or reply_to not in cl_comment_ids: - continue - - cid = cl_comment_ids[reply_to] # type: ignore[index] - body = (comment.body or "").strip() - user = comment.user.login - - if body.upper() == OK_KEYWORD: - acks[cid].add(user) - - return acks + replies = find_ok_replies_for_checklists(pr, existing_comments, relevant_ids) + return { + cid: {comment.user.login for comment in comments} + for cid, comments in replies.items() + } def _acknowledgement_status( @@ -237,7 +219,7 @@ def main() -> None: acks = _collect_ok_acknowledgements(pr, existing, relevant_ids) # Refresh evidence block in PR description based on current acknowledgements. - ack_details = _collect_acknowledgement_details(pr, existing, relevant_ids) + ack_details = collect_acknowledgement_details(pr, existing, relevant_ids) evidence_block = build_evidence_block(relevant, ack_details) update_pr_description_with_evidence(pr, evidence_block) diff --git a/tools/review-checklists/scripts/dismiss_and_invalidate.py b/tools/review-checklists/scripts/dismiss_and_invalidate.py index 98a3f4a0b..520f4a07d 100644 --- a/tools/review-checklists/scripts/dismiss_and_invalidate.py +++ b/tools/review-checklists/scripts/dismiss_and_invalidate.py @@ -48,8 +48,8 @@ from typing import Any from helpers import ( - OK_KEYWORD, find_existing_checklist_comments, + find_ok_replies_for_checklists, get_changed_files, get_github_client, get_repo_and_pr, @@ -94,27 +94,6 @@ def _get_files_in_latest_push(pr: Any, repo: Any) -> list[str]: return get_changed_files(pr) -def _find_ok_comments_for_checklist(pr: Any, checklist_comment_id: int) -> list[Any]: - """Find all OK reply comments for a given checklist. - - Checklist findings are posted as file-level PR review comments; OK - replies are threaded review comment replies whose ``in_reply_to_id`` - matches the checklist comment id and whose body equals the OK keyword. - """ - ok_comments = [] - - for comment in pr.get_review_comments(): - reply_to = getattr(comment, "in_reply_to_id", None) - if reply_to != checklist_comment_id: - continue - body = (comment.body or "").strip() - - if body.upper() == OK_KEYWORD: - ok_comments.append(comment) - - return ok_comments - - def handle_synchronize(pr: Any, repo: Any, config_path: str) -> None: """Handle new commits pushed to the PR. @@ -131,18 +110,15 @@ def handle_synchronize(pr: Any, repo: Any, config_path: str) -> None: return existing = find_existing_checklist_comments(pr) + affected_ids = [cl["id"] for cl in affected if cl["id"] in existing] + ok_replies = find_ok_replies_for_checklists(pr, existing, affected_ids) any_invalidated = False - for cl in affected: - cid = cl["id"] - if cid not in existing: - continue - + for cid in affected_ids: review = existing[cid] - ok_comments = _find_ok_comments_for_checklist(pr, review.id) - for ok_comment in ok_comments: + for ok_comment in ok_replies[cid]: user = ok_comment.user.login any_invalidated = True try: diff --git a/tools/review-checklists/scripts/dismiss_and_invalidate_test.py b/tools/review-checklists/scripts/dismiss_and_invalidate_test.py index 8b439062c..6a767eebf 100644 --- a/tools/review-checklists/scripts/dismiss_and_invalidate_test.py +++ b/tools/review-checklists/scripts/dismiss_and_invalidate_test.py @@ -22,7 +22,6 @@ import sys from dismiss_and_invalidate import ( - _find_ok_comments_for_checklist, _get_files_in_latest_push, handle_synchronize, ) @@ -68,70 +67,6 @@ def _make_run(run_id, head_sha): ] -# --------------------------------------------------------------------------- -# _find_ok_comments_for_checklist -# --------------------------------------------------------------------------- - - -class TestFindOkCommentsForChecklist: - def test_finds_marker_ok_reply(self): - ok = _make_comment( - 101, - "OK", - "alice", - datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), - ) - ok.in_reply_to_id = 100 - pr = MagicMock() - pr.get_review_comments.return_value = [ok] - - result = _find_ok_comments_for_checklist(pr, 100) - assert len(result) == 1 - assert result[0].id == 101 - - def test_finds_bare_ok_reply(self): - ok = _make_comment( - 101, - "OK", - "alice", - datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), - ) - ok.in_reply_to_id = 100 - pr = MagicMock() - pr.get_review_comments.return_value = [ok] - - result = _find_ok_comments_for_checklist(pr, 100) - assert len(result) == 1 - - def test_ignores_reply_to_different_comment(self): - ok = _make_comment( - 101, - "OK", - "alice", - datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), - ) - ok.in_reply_to_id = 999 # different checklist - pr = MagicMock() - pr.get_review_comments.return_value = [ok] - - result = _find_ok_comments_for_checklist(pr, 100) - assert len(result) == 0 - - def test_ignores_unrelated_reply(self): - normal = _make_comment( - 101, - "looks good", - "alice", - datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), - ) - normal.in_reply_to_id = 100 - pr = MagicMock() - pr.get_review_comments.return_value = [normal] - - result = _find_ok_comments_for_checklist(pr, 100) - assert len(result) == 0 - - # --------------------------------------------------------------------------- # _get_files_in_latest_push # --------------------------------------------------------------------------- diff --git a/tools/review-checklists/scripts/helpers.py b/tools/review-checklists/scripts/helpers.py index d5a68611e..b22a415a8 100644 --- a/tools/review-checklists/scripts/helpers.py +++ b/tools/review-checklists/scripts/helpers.py @@ -196,7 +196,12 @@ def find_existing_checklist_comments(pr: PullRequest) -> dict[str, Any]: prefix = "", start) + end = body.find(" -->", start) + if end == -1: + # Marker was opened but never closed (e.g. comment edited/ + # truncated by a user) — skip rather than crash on the + # malformed comment. + continue cid = body[start:end] # Only keep top-level checklist comments (not replies). if not getattr(comment, "in_reply_to_id", None): @@ -204,56 +209,58 @@ def find_existing_checklist_comments(pr: PullRequest) -> dict[str, Any]: return result -def find_ok_replies( - pr: PullRequest, checklist_comment_id: int, checklist_id: str -) -> list[Any]: - """Find valid OK reply comments for a given checklist review comment. - - We look at PR review comment replies (threaded conversation) where - ``in_reply_to_id`` matches the checklist comment id. A reply counts - as an OK if its body (stripped, case-insensitive) equals the OK keyword. - The conversation thread itself associates the reply with the checklist. +def find_ok_replies_for_checklists( + pr: PullRequest, + existing_comments: dict[str, Any], + checklist_ids: list[str], +) -> dict[str, list[Any]]: + """Return a mapping of checklist_id -> its OK-reply comment objects. + + Single-pass helper over ``pr.get_review_comments()`` that consolidates + what used to be three separate re-implementations of the same OK-reply + lookup (``collect_acknowledgement_details`` here, + ``check_acknowledgements._collect_ok_acknowledgements``, and + ``dismiss_and_invalidate._find_ok_comments_for_checklist``): a reply + counts as an OK for a checklist if its ``in_reply_to_id`` matches that + checklist's finding comment id and its body (stripped, + case-insensitive) equals the OK keyword. Callers project this + "superset" of full comment objects down to whatever narrower shape + they actually need (usernames, (reviewer, timestamp) pairs, raw + comment objects to delete, ...). """ - ok_replies = [] - for comment in pr.get_review_comments(): - reply_to = getattr(comment, "in_reply_to_id", None) - if reply_to != checklist_comment_id: - continue - body = (comment.body or "").strip() - if body.upper() == OK_KEYWORD: - ok_replies.append(comment) - return ok_replies - - -def _collect_acknowledgement_details( - pr: PullRequest, existing_comments: dict[str, Any], relevant_ids: list[str] -) -> dict[str, list[dict[str, str]]]: - """Return acknowledgement details for relevant checklist review threads.""" - details: dict[str, list[dict[str, str]]] = {cid: [] for cid in relevant_ids} - - cl_comment_ids: dict[int, str] = {} - for cid, comment in existing_comments.items(): - if cid in relevant_ids: - cl_comment_ids[comment.id] = cid + replies: dict[str, list[Any]] = {cid: [] for cid in checklist_ids} + comment_id_to_cid: dict[int, str] = { + comment.id: cid + for cid, comment in existing_comments.items() + if cid in checklist_ids + } for comment in pr.get_review_comments(): reply_to = getattr(comment, "in_reply_to_id", None) - if not isinstance(reply_to, int) or reply_to not in cl_comment_ids: + if not isinstance(reply_to, int) or reply_to not in comment_id_to_cid: continue + if (comment.body or "").strip().upper() != OK_KEYWORD: + continue + replies[comment_id_to_cid[reply_to]].append(comment) - cid = cl_comment_ids[reply_to] - body = (comment.body or "").strip() - user = comment.user.login + return replies - if body.upper() == OK_KEYWORD: - details[cid].append( - { - "reviewer": user, - "acknowledged_at": comment.created_at.isoformat(), - } - ) - return details +def collect_acknowledgement_details( + pr: PullRequest, existing_comments: dict[str, Any], checklist_ids: list[str] +) -> dict[str, list[dict[str, str]]]: + """Return acknowledgement details for relevant checklist review threads.""" + replies = find_ok_replies_for_checklists(pr, existing_comments, checklist_ids) + return { + cid: [ + { + "reviewer": comment.user.login, + "acknowledged_at": comment.created_at.isoformat(), + } + for comment in comments + ] + for cid, comments in replies.items() + } def get_approving_reviewers(pr: PullRequest) -> list[str]: diff --git a/tools/review-checklists/scripts/helpers_test.py b/tools/review-checklists/scripts/helpers_test.py index a43b066a5..7219a9673 100644 --- a/tools/review-checklists/scripts/helpers_test.py +++ b/tools/review-checklists/scripts/helpers_test.py @@ -31,12 +31,12 @@ MERGE_QUEUE_NOTICE_END, MERGE_QUEUE_NOTICE_START, OK_KEYWORD, - _collect_acknowledgement_details, _find_checklists_config, + collect_acknowledgement_details, ensure_merge_queue_notice_comment, ensure_merge_queue_notice_description, find_existing_checklist_comments, - find_ok_replies, + find_ok_replies_for_checklists, get_approving_reviewers, get_changed_files, get_github_client, @@ -380,11 +380,11 @@ def test_returns_empty_when_none(self): # --------------------------------------------------------------------------- -# find_ok_replies +# find_ok_replies_for_checklists # --------------------------------------------------------------------------- -class TestFindOkReplies: +class TestFindOkRepliesForChecklists: def test_finds_ok_reply(self): c1 = _make_comment(10, "checklist body", "bot") c1.in_reply_to_id = None @@ -393,9 +393,8 @@ def test_finds_ok_reply(self): pr = MagicMock() pr.get_review_comments.return_value = [c1, c2] - result = find_ok_replies(pr, 10, "api-review") - assert len(result) == 1 - assert result[0].id == 11 + result = find_ok_replies_for_checklists(pr, {"api-review": c1}, ["api-review"]) + assert [c.id for c in result["api-review"]] == [11] def test_finds_case_insensitive_ok(self): for ok in ["OK", "ok", "oK", "Ok"]: @@ -406,30 +405,36 @@ def test_finds_case_insensitive_ok(self): pr = MagicMock() pr.get_review_comments.return_value = [c1, c2] - result = find_ok_replies(pr, 10, "api-review") - assert len(result) == 1 + result = find_ok_replies_for_checklists( + pr, {"api-review": c1}, ["api-review"] + ) + assert len(result["api-review"]) == 1 def test_ignores_reply_to_different_comment(self): - c1 = _make_comment(11, "OK", "reviewer1") - c1.in_reply_to_id = 99 # different checklist + c1 = _make_comment(10, "checklist body", "bot") + c1.in_reply_to_id = None + c2 = _make_comment(11, "OK", "reviewer1") + c2.in_reply_to_id = 99 # different checklist pr = MagicMock() - pr.get_review_comments.return_value = [c1] + pr.get_review_comments.return_value = [c1, c2] - result = find_ok_replies(pr, 10, "api-review") - assert len(result) == 0 + result = find_ok_replies_for_checklists(pr, {"api-review": c1}, ["api-review"]) + assert result["api-review"] == [] def test_ignores_unrelated_reply(self): - c1 = _make_comment(11, "looks good but not OK keyword", "reviewer1") - c1.in_reply_to_id = 10 + c1 = _make_comment(10, "checklist body", "bot") + c1.in_reply_to_id = None + c2 = _make_comment(11, "looks good but not OK keyword", "reviewer1") + c2.in_reply_to_id = 10 pr = MagicMock() - pr.get_review_comments.return_value = [c1] + pr.get_review_comments.return_value = [c1, c2] - result = find_ok_replies(pr, 10, "api-review") - assert len(result) == 0 + result = find_ok_replies_for_checklists(pr, {"api-review": c1}, ["api-review"]) + assert result["api-review"] == [] # --------------------------------------------------------------------------- -# _collect_acknowledgement_details +# collect_acknowledgement_details # --------------------------------------------------------------------------- @@ -445,7 +450,7 @@ def test_collects_ok_reply_details_for_relevant_checklists(self): pr = MagicMock() pr.get_review_comments.return_value = [ok_reply, other_reply, unrelated_reply] - result = _collect_acknowledgement_details( + result = collect_acknowledgement_details( pr, {"api-review": checklist_comment}, ["api-review", "docs-review"], diff --git a/tools/review-checklists/scripts/post_checklists.py b/tools/review-checklists/scripts/post_checklists.py index 5df6ee55e..8eb858972 100644 --- a/tools/review-checklists/scripts/post_checklists.py +++ b/tools/review-checklists/scripts/post_checklists.py @@ -27,8 +27,8 @@ import argparse from helpers import ( - _collect_acknowledgement_details, build_evidence_block, + collect_acknowledgement_details, ensure_merge_queue_notice_comment, ensure_merge_queue_notice_description, find_existing_checklist_comments, @@ -104,7 +104,7 @@ def main() -> None: # Collect current acknowledgements and update evidence in PR description relevant_ids = [cl["id"] for cl in relevant if cl["id"] in existing] if relevant_ids: - ack_details = _collect_acknowledgement_details(pr, existing, relevant_ids) + ack_details = collect_acknowledgement_details(pr, existing, relevant_ids) evidence_block = build_evidence_block(relevant, ack_details) update_pr_description_with_evidence(pr, evidence_block) From 05b053e97e14315205593237de0a4f70eaae40f9 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Thu, 13 Aug 2026 16:18:54 +0200 Subject: [PATCH 21/34] Use Bzlmod module name instead of hardcoded '_main' for Rlocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '_main' is the current Bzlmod-internal runfiles-directory name for the root module. Rlocation() accepts the module name directly and resolves it through the runfiles repo mapping to whichever directory the root module is actually materialized under. Use 'score_communication' (this repo's declared module name in MODULE.bazel) instead — this is the portable, correct API usage, and keeps working even if Bazel's internal main-repo directory naming convention changes, instead of depending on that implementation detail directly. Addresses review comment: https://github.com/eclipse-score/communication/pull/899#discussion_r3765104136 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/review-checklists/scripts/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/review-checklists/scripts/helpers.py b/tools/review-checklists/scripts/helpers.py index b22a415a8..463915141 100644 --- a/tools/review-checklists/scripts/helpers.py +++ b/tools/review-checklists/scripts/helpers.py @@ -71,7 +71,7 @@ def _find_checklists_config( r = Runfiles.Create() if r: - candidate = r.Rlocation(f"_main/{config_relpath}") + candidate = r.Rlocation(f"score_communication/{config_relpath}") if candidate and os.path.isfile(candidate): return candidate except (ImportError, Exception): From 075c29fa0c01c91f2ec47daf6940053d696b47ac Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Thu, 13 Aug 2026 16:20:57 +0200 Subject: [PATCH 22/34] Document why get_approving_reviewers' discard call is not redundant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_reviews() returns every review a user has ever submitted on the PR (in submission order), not just their latest state, so a user can approve and later request changes (or have an approval dismissed) in a subsequent review. The approvers.discard() call is what removes such a since-superseded approval recorded earlier in the same loop — it is not a no-op, even though 'approvers' starts as an empty set. Addresses review comment: https://github.com/eclipse-score/communication/pull/899#discussion_r3772552971 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/review-checklists/scripts/helpers.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tools/review-checklists/scripts/helpers.py b/tools/review-checklists/scripts/helpers.py index 463915141..4372a2ab0 100644 --- a/tools/review-checklists/scripts/helpers.py +++ b/tools/review-checklists/scripts/helpers.py @@ -270,10 +270,21 @@ def get_approving_reviewers(pr: PullRequest) -> list[str]: if review.state == "APPROVED": approvers.add(review.user.login) elif review.state in ("CHANGES_REQUESTED", "DISMISSED"): + # get_reviews() returns every review a user has ever submitted on + # this PR, in submission order — not just their latest state. A + # user can approve and later request changes (or have an + # approval dismissed) in a subsequent review, so this discard is + # what removes a since-superseded approval from an earlier + # iteration of this loop; it is not a no-op. approvers.discard(review.user.login) return sorted(approvers) +# GitHub's commit-status API silently truncates/rejects descriptions longer +# than this; keep our own text within the limit explicitly. +COMMIT_STATUS_DESCRIPTION_MAX_LENGTH = 140 + + def set_commit_status( repo: Any, sha: str, From 3d3caa70dc3bb6e9f5965cdd14afd6dd9d863014 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Thu, 13 Aug 2026 16:21:44 +0200 Subject: [PATCH 23/34] Name the commit-status description length magic number Addresses review comment: https://github.com/eclipse-score/communication/pull/899#discussion_r3772597204 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/review-checklists/scripts/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/review-checklists/scripts/helpers.py b/tools/review-checklists/scripts/helpers.py index 4372a2ab0..9245a27d4 100644 --- a/tools/review-checklists/scripts/helpers.py +++ b/tools/review-checklists/scripts/helpers.py @@ -293,7 +293,7 @@ def set_commit_status( context: str = "review-checklists", ) -> None: """Set a commit status on the given SHA.""" - desc = description[:140] + desc = description[:COMMIT_STATUS_DESCRIPTION_MAX_LENGTH] print( f"Setting commit status: context='{context}', state='{state}', " f"sha='{sha}', description='{desc}'" From b66b6b237751cd194af1ded2163d5d4986968dbf Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Thu, 13 Aug 2026 16:22:25 +0200 Subject: [PATCH 24/34] Document why is_pr_in_merge_queue fails closed to False on error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fail-closed to False on any lookup error is intentional here: the only callers use the result to decide whether to (re-)post an advisory merge-queue notice on the PR, so a false negative merely delays that notice — it never affects merge gating. The actual gating decision for merge_group events is made independently in check_acknowledgements.py, which resolves and validates the underlying PR itself and fails the commit status (rather than defaulting to success) if that resolution or validation fails. Addresses review comment: https://github.com/eclipse-score/communication/pull/899#discussion_r3772630059 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/review-checklists/scripts/helpers.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tools/review-checklists/scripts/helpers.py b/tools/review-checklists/scripts/helpers.py index 9245a27d4..2c18ac2c8 100644 --- a/tools/review-checklists/scripts/helpers.py +++ b/tools/review-checklists/scripts/helpers.py @@ -417,7 +417,18 @@ def update_pr_description_with_evidence( def is_pr_in_merge_queue(pr: Any) -> bool: - """Return whether the PR is currently in GitHub merge queue via GraphQL.""" + """Return whether the PR is currently in GitHub merge queue via GraphQL. + + This is intentionally fail-closed (returns False) on any lookup error + rather than raising: its only callers use the result to decide whether + to post an advisory merge-queue notice on the PR, and a false negative + here at worst delays that notice until the next checklist-relevant + event — it never affects merge gating. The actual merge-queue gating + decision is made independently in check_acknowledgements.py's + ``merge_group`` handling, which resolves and validates the underlying + PR itself and fails the commit status (does not default to success) + if that resolution or validation fails. + """ repo_name = getattr( getattr(getattr(pr, "base", None), "repo", None), "full_name", "" ) From 5f11541d3807ce689eceb494555845c54a613fb4 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Thu, 13 Aug 2026 16:23:59 +0200 Subject: [PATCH 25/34] Stay pending until every relevant checklist has been posted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _validate_checklist_evidence (used for merge_group evidence validation) and main()'s live-PR flow both derived 'relevant_ids' as only the relevant checklists that already have a posted finding comment, and then treated an empty relevant_ids as the only 'not all posted yet' case. If some but not all relevant checklists had been posted (e.g. a partial post_checklists run, or a checklist added to the config after others were already posted), the unposted ones were silently dropped from consideration entirely, and the status could go to 'success' once the posted subset was fully acknowledged — without a checklist that was never even posted ever being acknowledged. Fixed to require every relevant checklist to have a posted comment before proceeding past 'pending'. Addresses review comment: https://github.com/eclipse-score/communication/pull/899#discussion_r3772791430 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/check_acknowledgements.py | 12 +- .../scripts/check_acknowledgements_test.py | 140 +++++++++++++++++- 2 files changed, 148 insertions(+), 4 deletions(-) diff --git a/tools/review-checklists/scripts/check_acknowledgements.py b/tools/review-checklists/scripts/check_acknowledgements.py index 66b63699d..847d40d6f 100644 --- a/tools/review-checklists/scripts/check_acknowledgements.py +++ b/tools/review-checklists/scripts/check_acknowledgements.py @@ -136,7 +136,11 @@ def _validate_checklist_evidence(pr: Any, checklists: list[dict]) -> tuple[str, existing = find_existing_checklist_comments(pr) relevant_ids = [cl["id"] for cl in relevant if cl["id"] in existing] - if not relevant_ids: + if len(relevant_ids) < len(relevant): + # At least one relevant checklist has no posted comment yet — do not + # silently drop it from consideration (that could let the status go + # to "success" while a checklist was never even posted); stay + # pending until every relevant checklist has been posted. return "pending", "Checklist comments not yet posted" acks = _collect_ok_acknowledgements(pr, existing, relevant_ids) @@ -206,8 +210,10 @@ def main() -> None: existing = find_existing_checklist_comments(pr) relevant_ids = [cl["id"] for cl in relevant if cl["id"] in existing] - if not relevant_ids: - # Checklists haven't been posted yet — keep pending. + if len(relevant_ids) < len(relevant): + # At least one relevant checklist has no posted comment yet — keep + # pending rather than silently dropping it from consideration (see + # _validate_checklist_evidence for the same rationale). set_commit_status( repo, pr.head.sha, diff --git a/tools/review-checklists/scripts/check_acknowledgements_test.py b/tools/review-checklists/scripts/check_acknowledgements_test.py index f131681fc..064a9d677 100644 --- a/tools/review-checklists/scripts/check_acknowledgements_test.py +++ b/tools/review-checklists/scripts/check_acknowledgements_test.py @@ -23,7 +23,11 @@ import pytest import sys -from check_acknowledgements import _collect_ok_acknowledgements, main +from check_acknowledgements import ( + _collect_ok_acknowledgements, + _validate_checklist_evidence, + main, +) def _make_comment(comment_id, body, user_login="reviewer", created_at=None): @@ -57,6 +61,21 @@ def _make_review(user_login, state): }, ] +TWO_SAMPLE_CHECKLISTS = [ + { + "id": "api-review", + "name": "API Review", + "include": ["src/api/*.py"], + "checklist": "- [ ] Reviewed", + }, + { + "id": "doc-review", + "name": "Doc Review", + "include": ["docs/*.md"], + "checklist": "- [ ] Reviewed", + }, +] + # --------------------------------------------------------------------------- # _collect_ok_acknowledgements @@ -157,6 +176,79 @@ def test_unrelated_reply_ignored(self): assert acks["api-review"] == set() +# --------------------------------------------------------------------------- +# _validate_checklist_evidence +# --------------------------------------------------------------------------- + + +class TestValidateChecklistEvidence: + def test_pending_when_not_all_relevant_checklists_posted(self): + """Regression test for 631b5ff6. + + With two relevant checklists but only one posted comment, the old + code derived 'relevant_ids' as only the posted subset and treated + a *non-empty* relevant_ids as "all posted", so it went on to + evaluate acknowledgements for just that posted subset and could + reach "success" while doc-review was never even posted. It must + stay pending until every relevant checklist has a posted comment. + """ + pr = MagicMock() + pr.get_files.return_value = [ + _make_file("src/api/foo.py"), + _make_file("docs/readme.md"), + ] + + api_review_comment = MagicMock(id=100) + api_review_comment.body = "" + + with patch( + "check_acknowledgements.find_existing_checklist_comments", + # Only api-review has been posted; doc-review has not. + return_value={"api-review": api_review_comment}, + ): + state, description = _validate_checklist_evidence( + pr, TWO_SAMPLE_CHECKLISTS + ) + + assert state == "pending" + assert description == "Checklist comments not yet posted" + + def test_success_when_all_relevant_checklists_posted(self): + """Sanity check: once every relevant checklist has been posted and + acknowledged, evidence validation is not blocked.""" + pr = MagicMock() + pr.get_files.return_value = [_make_file("src/api/foo.py")] + + api_review_comment = MagicMock(id=100) + api_review_comment.body = "" + + ok_reply = _make_comment( + 101, + "OK", + "alice", + datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc), + ) + ok_reply.in_reply_to_id = 100 + pr.get_review_comments.return_value = [ok_reply] + + with ( + patch( + "check_acknowledgements.find_existing_checklist_comments", + return_value={"api-review": api_review_comment}, + ), + patch( + "check_acknowledgements.get_approving_reviewers", + return_value=["alice"], + ), + ): + state, description = _validate_checklist_evidence( + pr, SAMPLE_CHECKLISTS + ) + + assert state == "success" + assert description == "All checklists acknowledged by all approving reviewers" + + # --------------------------------------------------------------------------- # main() # --------------------------------------------------------------------------- @@ -215,6 +307,52 @@ def test_no_existing_comments_sets_pending( repo, "abc", "pending", "Checklist comments not yet posted" ) + @patch("check_acknowledgements.is_pr_in_merge_queue", return_value=False) + @patch("check_acknowledgements.set_commit_status") + @patch( + "check_acknowledgements.load_checklists", + return_value=TWO_SAMPLE_CHECKLISTS, + ) + @patch("check_acknowledgements.get_repo_and_pr") + @patch("check_acknowledgements.get_github_client") + def test_some_but_not_all_relevant_checklists_posted_sets_pending( + self, mock_gh, mock_repo_pr, mock_load, mock_status, mock_in_queue + ): + """Regression test for 631b5ff6 (main()'s live-PR flow). + + Both api-review and doc-review are relevant, but only api-review + has a posted finding comment. The old code derived 'relevant_ids' + as just the posted subset and treated it as non-empty, silently + dropping doc-review from consideration entirely instead of + staying pending until it too is posted. + """ + repo = MagicMock() + pr = MagicMock() + pr.head.sha = "abc" + pr.get_files.return_value = [ + _make_file("src/api/foo.py"), + _make_file("docs/readme.md"), + ] + mock_repo_pr.return_value = (repo, pr) + + api_review_comment = MagicMock(id=100) + api_review_comment.body = "" + + with ( + patch( + "check_acknowledgements.find_existing_checklist_comments", + # Only api-review has been posted; doc-review has not. + return_value={"api-review": api_review_comment}, + ), + # Ensure the argument parser doesn't see pytest's CLI arguments. + patch.object(sys, "argv", ["check_acknowledgements"]), + ): + main() + + mock_status.assert_called_once_with( + repo, "abc", "pending", "Checklist comments not yet posted" + ) + @patch("check_acknowledgements.is_pr_in_merge_queue", return_value=False) @patch("check_acknowledgements.set_commit_status") @patch("check_acknowledgements.get_approving_reviewers", return_value=[]) From 1908c6688ed22e702c4c00c65cfb29bc5157e66e Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Thu, 13 Aug 2026 16:25:32 +0200 Subject: [PATCH 26/34] Clarify why the merge-queue notice check is separate from merge_group handling Explain that this is_pr_in_merge_queue() check in main()'s regular (non-merge_group) flow is intentionally distinct from the merge_group handling above it: it covers events fired for a PR that is still enqueued but is not itself a merge_group event (e.g. a review comment posted while the PR is queued), so the advisory merge-queue notice can be (re-)posted for it. Addresses review comment: https://github.com/eclipse-score/communication/pull/899#discussion_r3772704218 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/review-checklists/scripts/check_acknowledgements.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tools/review-checklists/scripts/check_acknowledgements.py b/tools/review-checklists/scripts/check_acknowledgements.py index 847d40d6f..12a55a918 100644 --- a/tools/review-checklists/scripts/check_acknowledgements.py +++ b/tools/review-checklists/scripts/check_acknowledgements.py @@ -195,6 +195,13 @@ def main() -> None: repo, pr = get_repo_and_pr(gh) + # Note this is intentionally separate from the `merge_group` branch + # above (which already implies the PR is in the queue and instead + # re-validates evidence and returns/exits). This check instead covers + # the case where a *different*, non-merge_group event (e.g. a review + # comment posted on a PR that happens to still be enqueued) fires while + # the PR is enqueued, so we can (re-)post the advisory merge-queue + # notice for it. if is_pr_in_merge_queue(pr): ensure_merge_queue_notice_comment(pr) ensure_merge_queue_notice_description(pr) From b329e1dfa696782211478fbb887ca1fee07c3e2b Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Thu, 13 Aug 2026 17:08:46 +0200 Subject: [PATCH 27/34] Avoid single/two-letter abbreviations for checklist variables Rename 'cl' -> 'checklist' and 'cid' -> 'checklist_id' (and 'cl_copy' -> 'checklist_copy', 'comment_id_to_cid' -> 'comment_id_to_checklist_id') throughout helpers.py, check_acknowledgements.py, dismiss_and_invalidate.py, post_checklists.py, and helpers_test.py. These abbreviations made call sites and comprehensions harder to scan at a glance, especially where 'cl' (checklist) could be misread as a different kind of list/class abbreviation. Wrapped the handful of lines that exceeded 88 columns after the rename. Addresses review comments (previously deferred by the reviewer to a future PR): https://github.com/eclipse-score/communication/pull/899#discussion_r3772613352 https://github.com/eclipse-score/communication/pull/899#discussion_r3772609417 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/check_acknowledgements.py | 23 ++++++---- .../scripts/dismiss_and_invalidate.py | 10 +++-- tools/review-checklists/scripts/helpers.py | 42 +++++++++---------- .../review-checklists/scripts/helpers_test.py | 42 +++++++++---------- .../scripts/post_checklists.py | 20 +++++---- 5 files changed, 73 insertions(+), 64 deletions(-) diff --git a/tools/review-checklists/scripts/check_acknowledgements.py b/tools/review-checklists/scripts/check_acknowledgements.py index 12a55a918..a1bcb0b06 100644 --- a/tools/review-checklists/scripts/check_acknowledgements.py +++ b/tools/review-checklists/scripts/check_acknowledgements.py @@ -82,8 +82,8 @@ def _collect_ok_acknowledgements( """ replies = find_ok_replies_for_checklists(pr, existing_comments, relevant_ids) return { - cid: {comment.user.login for comment in comments} - for cid, comments in replies.items() + checklist_id: {comment.user.login for comment in comments} + for checklist_id, comments in replies.items() } @@ -102,14 +102,15 @@ def _acknowledgement_status( return "pending", "Awaiting at least one approving review" missing: dict[str, list[str]] = {} - for cid in relevant_ids: - not_acked = [u for u in approvers if u not in acks[cid]] + for checklist_id in relevant_ids: + not_acked = [u for u in approvers if u not in acks[checklist_id]] if not_acked: - missing[cid] = not_acked + missing[checklist_id] = not_acked if missing: summary_parts = [ - f"{cid}: awaiting {', '.join(users)}" for cid, users in missing.items() + f"{checklist_id}: awaiting {', '.join(users)}" + for checklist_id, users in missing.items() ] return "pending", "; ".join(summary_parts) @@ -134,7 +135,9 @@ def _validate_checklist_evidence(pr: Any, checklists: list[dict]) -> tuple[str, return "success", "No checklists applicable" existing = find_existing_checklist_comments(pr) - relevant_ids = [cl["id"] for cl in relevant if cl["id"] in existing] + relevant_ids = [ + checklist["id"] for checklist in relevant if checklist["id"] in existing + ] if len(relevant_ids) < len(relevant): # At least one relevant checklist has no posted comment yet — do not @@ -215,7 +218,9 @@ def main() -> None: return existing = find_existing_checklist_comments(pr) - relevant_ids = [cl["id"] for cl in relevant if cl["id"] in existing] + relevant_ids = [ + checklist["id"] for checklist in relevant if checklist["id"] in existing + ] if len(relevant_ids) < len(relevant): # At least one relevant checklist has no posted comment yet — keep @@ -242,7 +247,7 @@ def main() -> None: print(f"Acknowledgement status: {state} — {description}") # Write acknowledgement data for downstream use (merge evidence). - ack_data = {cid: sorted(users) for cid, users in acks.items()} + ack_data = {checklist_id: sorted(users) for checklist_id, users in acks.items()} runner_temp = os.environ.get("RUNNER_TEMP", "./") output_path = os.environ.get( "ACK_OUTPUT_PATH", runner_temp + "/checklist_acks.json" diff --git a/tools/review-checklists/scripts/dismiss_and_invalidate.py b/tools/review-checklists/scripts/dismiss_and_invalidate.py index 520f4a07d..29da9e4c8 100644 --- a/tools/review-checklists/scripts/dismiss_and_invalidate.py +++ b/tools/review-checklists/scripts/dismiss_and_invalidate.py @@ -110,15 +110,17 @@ def handle_synchronize(pr: Any, repo: Any, config_path: str) -> None: return existing = find_existing_checklist_comments(pr) - affected_ids = [cl["id"] for cl in affected if cl["id"] in existing] + affected_ids = [ + checklist["id"] for checklist in affected if checklist["id"] in existing + ] ok_replies = find_ok_replies_for_checklists(pr, existing, affected_ids) any_invalidated = False - for cid in affected_ids: - review = existing[cid] + for checklist_id in affected_ids: + review = existing[checklist_id] - for ok_comment in ok_replies[cid]: + for ok_comment in ok_replies[checklist_id]: user = ok_comment.user.login any_invalidated = True try: diff --git a/tools/review-checklists/scripts/helpers.py b/tools/review-checklists/scripts/helpers.py index 2c18ac2c8..13c59f3f8 100644 --- a/tools/review-checklists/scripts/helpers.py +++ b/tools/review-checklists/scripts/helpers.py @@ -137,9 +137,9 @@ def match_checklists(checklists: list[dict], changed_files: list[str]) -> list[d containing the list of changed files that triggered the match. """ relevant = [] - for cl in checklists: - include_patterns: list[str] = cl.get("include", []) - exclude_patterns: list[str] = cl.get("exclude", []) + for checklist in checklists: + include_patterns: list[str] = checklist.get("include", []) + exclude_patterns: list[str] = checklist.get("exclude", []) matched = set() for filepath in changed_files: @@ -148,9 +148,9 @@ def match_checklists(checklists: list[dict], changed_files: list[str]) -> list[d matched.add(filepath) if matched: - cl_copy = dict(cl) - cl_copy["matched_files"] = sorted(matched) - relevant.append(cl_copy) + checklist_copy = dict(checklist) + checklist_copy["matched_files"] = sorted(matched) + relevant.append(checklist_copy) return relevant @@ -202,10 +202,10 @@ def find_existing_checklist_comments(pr: PullRequest) -> dict[str, Any]: # truncated by a user) — skip rather than crash on the # malformed comment. continue - cid = body[start:end] + checklist_id = body[start:end] # Only keep top-level checklist comments (not replies). if not getattr(comment, "in_reply_to_id", None): - result[cid] = comment + result[checklist_id] = comment return result @@ -228,20 +228,20 @@ def find_ok_replies_for_checklists( they actually need (usernames, (reviewer, timestamp) pairs, raw comment objects to delete, ...). """ - replies: dict[str, list[Any]] = {cid: [] for cid in checklist_ids} - comment_id_to_cid: dict[int, str] = { - comment.id: cid - for cid, comment in existing_comments.items() - if cid in checklist_ids + replies: dict[str, list[Any]] = {checklist_id: [] for checklist_id in checklist_ids} + comment_id_to_checklist_id: dict[int, str] = { + comment.id: checklist_id + for checklist_id, comment in existing_comments.items() + if checklist_id in checklist_ids } for comment in pr.get_review_comments(): reply_to = getattr(comment, "in_reply_to_id", None) - if not isinstance(reply_to, int) or reply_to not in comment_id_to_cid: + if not isinstance(reply_to, int) or reply_to not in comment_id_to_checklist_id: continue if (comment.body or "").strip().upper() != OK_KEYWORD: continue - replies[comment_id_to_cid[reply_to]].append(comment) + replies[comment_id_to_checklist_id[reply_to]].append(comment) return replies @@ -252,14 +252,14 @@ def collect_acknowledgement_details( """Return acknowledgement details for relevant checklist review threads.""" replies = find_ok_replies_for_checklists(pr, existing_comments, checklist_ids) return { - cid: [ + checklist_id: [ { "reviewer": comment.user.login, "acknowledged_at": comment.created_at.isoformat(), } for comment in comments ] - for cid, comments in replies.items() + for checklist_id, comments in replies.items() } @@ -377,12 +377,12 @@ def build_evidence_block( "", ] - for cl in relevant: - cid = cl["id"] - lines.append(f"### {cl['name']} (`{cid}`)") + for checklist in relevant: + checklist_id = checklist["id"] + lines.append(f"### {checklist['name']} (`{checklist_id}`)") lines.append("") - acks = ack_details.get(cid, []) + acks = ack_details.get(checklist_id, []) if acks: lines.append("**Acknowledged by:**") for ack in acks: diff --git a/tools/review-checklists/scripts/helpers_test.py b/tools/review-checklists/scripts/helpers_test.py index 7219a9673..d807b3210 100644 --- a/tools/review-checklists/scripts/helpers_test.py +++ b/tools/review-checklists/scripts/helpers_test.py @@ -225,7 +225,7 @@ def test_build_glob_matches_root_level_too(self): assert result[0]["id"] == "build-review" def test_unanchored_pattern_matches_root_and_nested(self): - cl = [ + checklist = [ { "id": "md-anywhere", "name": "Markdown anywhere", @@ -234,12 +234,12 @@ def test_unanchored_pattern_matches_root_and_nested(self): } ] files = ["NOTE.md", "docs/NOTE.md", "docs/deep/NOTE.md"] - result = match_checklists(cl, files) + result = match_checklists(checklist, files) assert len(result) == 1 assert set(result[0]["matched_files"]) == set(files) def test_anchored_pattern_matches_root_only(self): - cl = [ + checklist = [ { "id": "md-root-only", "name": "Markdown at root", @@ -248,7 +248,7 @@ def test_anchored_pattern_matches_root_only(self): } ] files = ["NOTE.md", "docs/NOTE.md"] - result = match_checklists(cl, files) + result = match_checklists(checklist, files) assert len(result) == 1 assert result[0]["matched_files"] == ["NOTE.md"] @@ -299,41 +299,41 @@ def test_all_files_excluded_means_no_match(self): class TestMakeChecklistCommentBody: def test_contains_marker(self): - cl = SAMPLE_CHECKLISTS[0] - body = make_checklist_comment_body(cl) + checklist = SAMPLE_CHECKLISTS[0] + body = make_checklist_comment_body(checklist) expected_marker = CHECKLIST_MARKER.format(checklist_id="api-review") assert expected_marker in body def test_contains_name(self): - cl = SAMPLE_CHECKLISTS[0] - body = make_checklist_comment_body(cl) - assert cl["name"] in body + checklist = SAMPLE_CHECKLISTS[0] + body = make_checklist_comment_body(checklist) + assert checklist["name"] in body def test_contains_checklist_content(self): - cl = SAMPLE_CHECKLISTS[0] - body = make_checklist_comment_body(cl) + checklist = SAMPLE_CHECKLISTS[0] + body = make_checklist_comment_body(checklist) assert "APIs documented" in body def test_contains_ok_instruction(self): - cl = SAMPLE_CHECKLISTS[0] - body = make_checklist_comment_body(cl) + checklist = SAMPLE_CHECKLISTS[0] + body = make_checklist_comment_body(checklist) assert OK_KEYWORD in body def test_contains_paths(self): - cl = SAMPLE_CHECKLISTS[0] - body = make_checklist_comment_body(cl) - for p in cl["include"]: + checklist = SAMPLE_CHECKLISTS[0] + body = make_checklist_comment_body(checklist) + for p in checklist["include"]: assert p in body def test_contains_exclude_when_present(self): - cl = SAMPLE_CHECKLISTS[3] # com-review, has exclude - body = make_checklist_comment_body(cl) - for p in cl["exclude"]: + checklist = SAMPLE_CHECKLISTS[3] # com-review, has exclude + body = make_checklist_comment_body(checklist) + for p in checklist["exclude"]: assert p in body def test_no_exclude_line_when_absent(self): - cl = SAMPLE_CHECKLISTS[0] # api-review, no exclude - body = make_checklist_comment_body(cl) + checklist = SAMPLE_CHECKLISTS[0] # api-review, no exclude + body = make_checklist_comment_body(checklist) assert "Excluding" not in body diff --git a/tools/review-checklists/scripts/post_checklists.py b/tools/review-checklists/scripts/post_checklists.py index 8eb858972..960e91cf9 100644 --- a/tools/review-checklists/scripts/post_checklists.py +++ b/tools/review-checklists/scripts/post_checklists.py @@ -74,16 +74,16 @@ def main() -> None: existing = find_existing_checklist_comments(pr) - for cl in relevant: - body = make_checklist_comment_body(cl) - if cl["id"] in existing: - comment = existing[cl["id"]] + for checklist in relevant: + body = make_checklist_comment_body(checklist) + if checklist["id"] in existing: + comment = existing[checklist["id"]] # Only update if the body actually changed (avoids notification spam). if (comment.body or "").strip() != body.strip(): comment.edit(body=body) - print(f"Updated checklist finding for '{cl['id']}'") + print(f"Updated checklist finding for '{checklist['id']}'") else: - print(f"Checklist finding for '{cl['id']}' is already up to date") + print(f"Checklist finding for '{checklist['id']}' is already up to date") else: # Post a file-level review comment (subject_type="file") anchored # to the first matched file. Unlike diff-position-anchored @@ -92,17 +92,19 @@ def main() -> None: # files GitHub doesn't render a diff for. It still creates a # PullRequestComment that supports threaded replies where # reviewers can acknowledge with OK. - anchor_file = cl["matched_files"][0] + anchor_file = checklist["matched_files"][0] pr.create_review_comment( body=body, commit=pr.head.sha, path=anchor_file, subject_type="file", ) - print(f"Created checklist finding for '{cl['id']}'") + print(f"Created checklist finding for '{checklist['id']}'") # Collect current acknowledgements and update evidence in PR description - relevant_ids = [cl["id"] for cl in relevant if cl["id"] in existing] + relevant_ids = [ + checklist["id"] for checklist in relevant if checklist["id"] in existing + ] if relevant_ids: ack_details = collect_acknowledgement_details(pr, existing, relevant_ids) evidence_block = build_evidence_block(relevant, ack_details) From 61b1e38992b38da0bd67688fcd825775d9d27586 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Thu, 13 Aug 2026 17:13:44 +0200 Subject: [PATCH 28/34] Improve remaining unclear variable names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename a few names left unclear by a broader review pass: - helpers.py: rename '_find_checklists_config's local 'r' -> 'runfiles' (matches the imported Runfiles type it holds an instance of). - dismiss_and_invalidate.py: rename '_get_files_in_latest_push's list comprehension variable 'r' -> 'run' (workflow run objects, not something needing single-letter treatment); rename 'handle_synchronize's 'review' -> 'finding_comment' — it actually holds the bot-posted checklist finding comment looked up from 'existing', not a PR review object, so the old name was actively misleading, not just terse. - check_acknowledgements.py: rename '_acknowledgement_status's single-letter comprehension variable 'u' -> 'username' for clarity. No behavior change; verified with bazel test //tools/review-checklists/... Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/review-checklists/scripts/check_acknowledgements.py | 4 +++- tools/review-checklists/scripts/dismiss_and_invalidate.py | 6 +++--- tools/review-checklists/scripts/helpers.py | 6 +++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/tools/review-checklists/scripts/check_acknowledgements.py b/tools/review-checklists/scripts/check_acknowledgements.py index a1bcb0b06..cb519c2b8 100644 --- a/tools/review-checklists/scripts/check_acknowledgements.py +++ b/tools/review-checklists/scripts/check_acknowledgements.py @@ -103,7 +103,9 @@ def _acknowledgement_status( missing: dict[str, list[str]] = {} for checklist_id in relevant_ids: - not_acked = [u for u in approvers if u not in acks[checklist_id]] + not_acked = [ + username for username in approvers if username not in acks[checklist_id] + ] if not_acked: missing[checklist_id] = not_acked diff --git a/tools/review-checklists/scripts/dismiss_and_invalidate.py b/tools/review-checklists/scripts/dismiss_and_invalidate.py index 29da9e4c8..2d00c280c 100644 --- a/tools/review-checklists/scripts/dismiss_and_invalidate.py +++ b/tools/review-checklists/scripts/dismiss_and_invalidate.py @@ -80,7 +80,7 @@ def _get_files_in_latest_push(pr: Any, repo: Any) -> list[str]: runs = list( workflow.get_runs(branch=head_branch, event=TRIGGER_EVENT_NAME) ) - run_ids = [r.id for r in runs] + run_ids = [run.id for run in runs] idx = run_ids.index(run_id) before_sha = runs[idx + 1].head_sha comparison = repo.compare(before_sha, pr.head.sha) @@ -118,7 +118,7 @@ def handle_synchronize(pr: Any, repo: Any, config_path: str) -> None: any_invalidated = False for checklist_id in affected_ids: - review = existing[checklist_id] + finding_comment = existing[checklist_id] for ok_comment in ok_replies[checklist_id]: user = ok_comment.user.login @@ -127,7 +127,7 @@ def handle_synchronize(pr: Any, repo: Any, config_path: str) -> None: ok_comment.delete() print( f"Deleted OK comment {ok_comment.id} from {user} " - f"for checklist '{review.id}'" + f"for checklist '{finding_comment.id}'" ) except Exception as e: print(f"Warning: could not delete comment {ok_comment.id}: {e}") diff --git a/tools/review-checklists/scripts/helpers.py b/tools/review-checklists/scripts/helpers.py index 13c59f3f8..ea23b0d2e 100644 --- a/tools/review-checklists/scripts/helpers.py +++ b/tools/review-checklists/scripts/helpers.py @@ -69,9 +69,9 @@ def _find_checklists_config( try: from runfiles import Runfiles # type: ignore[import-untyped] - r = Runfiles.Create() - if r: - candidate = r.Rlocation(f"score_communication/{config_relpath}") + runfiles = Runfiles.Create() + if runfiles: + candidate = runfiles.Rlocation(f"score_communication/{config_relpath}") if candidate and os.path.isfile(candidate): return candidate except (ImportError, Exception): From ea54a076395e1111f9850f630e1d163c163e7e02 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Thu, 13 Aug 2026 17:22:00 +0200 Subject: [PATCH 29/34] Rename relevant_ids/affected_ids to posted_relevant_ids/posted_affected_ids These names looked like they held every relevant/affected checklist id, but they actually only held the subset that already has a posted finding comment (filtered by 'if checklist["id"] in existing'). That ambiguity was directly responsible for a prior bug in this same code (commit 631b5ff6): a checklist missing from this filtered list was silently treated as 'no relevant checklists remain to check' instead of 'a relevant checklist has not been posted yet'. Renaming to make the 'posted' filtering explicit at every read site, not just at the point of construction. No behavior change; verified with bazel test //tools/review-checklists/... Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/check_acknowledgements.py | 26 +++++++++---------- .../scripts/dismiss_and_invalidate.py | 6 ++--- .../scripts/post_checklists.py | 6 ++--- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/tools/review-checklists/scripts/check_acknowledgements.py b/tools/review-checklists/scripts/check_acknowledgements.py index cb519c2b8..4ce09500c 100644 --- a/tools/review-checklists/scripts/check_acknowledgements.py +++ b/tools/review-checklists/scripts/check_acknowledgements.py @@ -70,7 +70,7 @@ def _collect_ok_acknowledgements( - pr: Any, existing_comments: dict[str, Any], relevant_ids: list[str] + pr: Any, existing_comments: dict[str, Any], posted_relevant_ids: list[str] ) -> dict[str, set[str]]: """Return a mapping of checklist_id → set of usernames who acknowledged. @@ -80,7 +80,7 @@ def _collect_ok_acknowledgements( The conversation thread itself associates the reply with the checklist. """ - replies = find_ok_replies_for_checklists(pr, existing_comments, relevant_ids) + replies = find_ok_replies_for_checklists(pr, existing_comments, posted_relevant_ids) return { checklist_id: {comment.user.login for comment in comments} for checklist_id, comments in replies.items() @@ -89,7 +89,7 @@ def _collect_ok_acknowledgements( def _acknowledgement_status( approvers: list[str], - relevant_ids: list[str], + posted_relevant_ids: list[str], acks: dict[str, set[str]], ) -> tuple[str, str]: """Compute the (state, description) commit-status pair from ack data. @@ -102,7 +102,7 @@ def _acknowledgement_status( return "pending", "Awaiting at least one approving review" missing: dict[str, list[str]] = {} - for checklist_id in relevant_ids: + for checklist_id in posted_relevant_ids: not_acked = [ username for username in approvers if username not in acks[checklist_id] ] @@ -137,20 +137,20 @@ def _validate_checklist_evidence(pr: Any, checklists: list[dict]) -> tuple[str, return "success", "No checklists applicable" existing = find_existing_checklist_comments(pr) - relevant_ids = [ + posted_relevant_ids = [ checklist["id"] for checklist in relevant if checklist["id"] in existing ] - if len(relevant_ids) < len(relevant): + if len(posted_relevant_ids) < len(relevant): # At least one relevant checklist has no posted comment yet — do not # silently drop it from consideration (that could let the status go # to "success" while a checklist was never even posted); stay # pending until every relevant checklist has been posted. return "pending", "Checklist comments not yet posted" - acks = _collect_ok_acknowledgements(pr, existing, relevant_ids) + acks = _collect_ok_acknowledgements(pr, existing, posted_relevant_ids) approvers = get_approving_reviewers(pr) - return _acknowledgement_status(approvers, relevant_ids, acks) + return _acknowledgement_status(approvers, posted_relevant_ids, acks) def main() -> None: @@ -220,11 +220,11 @@ def main() -> None: return existing = find_existing_checklist_comments(pr) - relevant_ids = [ + posted_relevant_ids = [ checklist["id"] for checklist in relevant if checklist["id"] in existing ] - if len(relevant_ids) < len(relevant): + if len(posted_relevant_ids) < len(relevant): # At least one relevant checklist has no posted comment yet — keep # pending rather than silently dropping it from consideration (see # _validate_checklist_evidence for the same rationale). @@ -236,15 +236,15 @@ def main() -> None: ) return - acks = _collect_ok_acknowledgements(pr, existing, relevant_ids) + acks = _collect_ok_acknowledgements(pr, existing, posted_relevant_ids) # Refresh evidence block in PR description based on current acknowledgements. - ack_details = collect_acknowledgement_details(pr, existing, relevant_ids) + ack_details = collect_acknowledgement_details(pr, existing, posted_relevant_ids) evidence_block = build_evidence_block(relevant, ack_details) update_pr_description_with_evidence(pr, evidence_block) approvers = get_approving_reviewers(pr) - state, description = _acknowledgement_status(approvers, relevant_ids, acks) + state, description = _acknowledgement_status(approvers, posted_relevant_ids, acks) set_commit_status(repo, pr.head.sha, state, description) print(f"Acknowledgement status: {state} — {description}") diff --git a/tools/review-checklists/scripts/dismiss_and_invalidate.py b/tools/review-checklists/scripts/dismiss_and_invalidate.py index 2d00c280c..f80c22e13 100644 --- a/tools/review-checklists/scripts/dismiss_and_invalidate.py +++ b/tools/review-checklists/scripts/dismiss_and_invalidate.py @@ -110,14 +110,14 @@ def handle_synchronize(pr: Any, repo: Any, config_path: str) -> None: return existing = find_existing_checklist_comments(pr) - affected_ids = [ + posted_affected_ids = [ checklist["id"] for checklist in affected if checklist["id"] in existing ] - ok_replies = find_ok_replies_for_checklists(pr, existing, affected_ids) + ok_replies = find_ok_replies_for_checklists(pr, existing, posted_affected_ids) any_invalidated = False - for checklist_id in affected_ids: + for checklist_id in posted_affected_ids: finding_comment = existing[checklist_id] for ok_comment in ok_replies[checklist_id]: diff --git a/tools/review-checklists/scripts/post_checklists.py b/tools/review-checklists/scripts/post_checklists.py index 960e91cf9..3734e9c8b 100644 --- a/tools/review-checklists/scripts/post_checklists.py +++ b/tools/review-checklists/scripts/post_checklists.py @@ -102,11 +102,11 @@ def main() -> None: print(f"Created checklist finding for '{checklist['id']}'") # Collect current acknowledgements and update evidence in PR description - relevant_ids = [ + posted_relevant_ids = [ checklist["id"] for checklist in relevant if checklist["id"] in existing ] - if relevant_ids: - ack_details = collect_acknowledgement_details(pr, existing, relevant_ids) + if posted_relevant_ids: + ack_details = collect_acknowledgement_details(pr, existing, posted_relevant_ids) evidence_block = build_evidence_block(relevant, ack_details) update_pr_description_with_evidence(pr, evidence_block) From 18220606a5f4fcdb9b8f8e296a792a9461efc339 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Thu, 13 Aug 2026 17:31:48 +0200 Subject: [PATCH 30/34] Rename relevant/affected checklist-list variables for clarity Following up on discussion_r3772590463 ('relevant_ids is a bad naming ... what is it relevant for?'): the same ambiguity existed one level up in the variables holding the matched checklist dicts themselves. 'relevant' and 'affected' read as bare adjectives with no object, which invited exactly this kind of question at every read site. Rename to 'relevant_checklists' (helpers.match_checklists/build_evidence_block, check_acknowledgements.py, post_checklists.py) and 'affected_checklists' (dismiss_and_invalidate.py) to make explicit what they are relevant/affected checklists of. Wrapped two comprehensions that exceeded 88 columns after the rename. No behavior change; verified with bazel test //tools/review-checklists/... Addresses review comment: https://github.com/eclipse-score/communication/pull/899#discussion_r3772590463 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/check_acknowledgements.py | 22 +++++++++++-------- .../scripts/dismiss_and_invalidate.py | 8 ++++--- tools/review-checklists/scripts/helpers.py | 10 ++++----- .../scripts/post_checklists.py | 16 ++++++++------ 4 files changed, 32 insertions(+), 24 deletions(-) diff --git a/tools/review-checklists/scripts/check_acknowledgements.py b/tools/review-checklists/scripts/check_acknowledgements.py index 4ce09500c..c8830da22 100644 --- a/tools/review-checklists/scripts/check_acknowledgements.py +++ b/tools/review-checklists/scripts/check_acknowledgements.py @@ -131,17 +131,19 @@ def _validate_checklist_evidence(pr: Any, checklists: list[dict]) -> tuple[str, point. """ changed_files = get_changed_files(pr) - relevant = match_checklists(checklists, changed_files) + relevant_checklists = match_checklists(checklists, changed_files) - if not relevant: + if not relevant_checklists: return "success", "No checklists applicable" existing = find_existing_checklist_comments(pr) posted_relevant_ids = [ - checklist["id"] for checklist in relevant if checklist["id"] in existing + checklist["id"] + for checklist in relevant_checklists + if checklist["id"] in existing ] - if len(posted_relevant_ids) < len(relevant): + if len(posted_relevant_ids) < len(relevant_checklists): # At least one relevant checklist has no posted comment yet — do not # silently drop it from consideration (that could let the status go # to "success" while a checklist was never even posted); stay @@ -213,18 +215,20 @@ def main() -> None: checklists = load_checklists(args.config_path) changed_files = get_changed_files(pr) - relevant = match_checklists(checklists, changed_files) + relevant_checklists = match_checklists(checklists, changed_files) - if not relevant: + if not relevant_checklists: set_commit_status(repo, pr.head.sha, "success", "No checklists applicable") return existing = find_existing_checklist_comments(pr) posted_relevant_ids = [ - checklist["id"] for checklist in relevant if checklist["id"] in existing + checklist["id"] + for checklist in relevant_checklists + if checklist["id"] in existing ] - if len(posted_relevant_ids) < len(relevant): + if len(posted_relevant_ids) < len(relevant_checklists): # At least one relevant checklist has no posted comment yet — keep # pending rather than silently dropping it from consideration (see # _validate_checklist_evidence for the same rationale). @@ -240,7 +244,7 @@ def main() -> None: # Refresh evidence block in PR description based on current acknowledgements. ack_details = collect_acknowledgement_details(pr, existing, posted_relevant_ids) - evidence_block = build_evidence_block(relevant, ack_details) + evidence_block = build_evidence_block(relevant_checklists, ack_details) update_pr_description_with_evidence(pr, evidence_block) approvers = get_approving_reviewers(pr) diff --git a/tools/review-checklists/scripts/dismiss_and_invalidate.py b/tools/review-checklists/scripts/dismiss_and_invalidate.py index f80c22e13..59d8b1de0 100644 --- a/tools/review-checklists/scripts/dismiss_and_invalidate.py +++ b/tools/review-checklists/scripts/dismiss_and_invalidate.py @@ -103,15 +103,17 @@ def handle_synchronize(pr: Any, repo: Any, config_path: str) -> None: """ checklists = load_checklists(config_path) new_files = _get_files_in_latest_push(pr, repo) - affected = match_checklists(checklists, new_files) + affected_checklists = match_checklists(checklists, new_files) - if not affected: + if not affected_checklists: print("No checklist-relevant files changed in latest push.") return existing = find_existing_checklist_comments(pr) posted_affected_ids = [ - checklist["id"] for checklist in affected if checklist["id"] in existing + checklist["id"] + for checklist in affected_checklists + if checklist["id"] in existing ] ok_replies = find_ok_replies_for_checklists(pr, existing, posted_affected_ids) diff --git a/tools/review-checklists/scripts/helpers.py b/tools/review-checklists/scripts/helpers.py index ea23b0d2e..de32a15f1 100644 --- a/tools/review-checklists/scripts/helpers.py +++ b/tools/review-checklists/scripts/helpers.py @@ -136,7 +136,7 @@ def match_checklists(checklists: list[dict], changed_files: list[str]) -> list[d Each returned checklist dict is augmented with a ``matched_files`` key containing the list of changed files that triggered the match. """ - relevant = [] + relevant_checklists = [] for checklist in checklists: include_patterns: list[str] = checklist.get("include", []) exclude_patterns: list[str] = checklist.get("exclude", []) @@ -150,8 +150,8 @@ def match_checklists(checklists: list[dict], changed_files: list[str]) -> list[d if matched: checklist_copy = dict(checklist) checklist_copy["matched_files"] = sorted(matched) - relevant.append(checklist_copy) - return relevant + relevant_checklists.append(checklist_copy) + return relevant_checklists def make_checklist_comment_body(checklist: dict) -> str: @@ -360,7 +360,7 @@ def remove_evidence_block(description: str) -> str: def build_evidence_block( - relevant: list[dict], + relevant_checklists: list[dict], ack_details: dict[str, list[dict[str, str]]], ) -> str: """Build the evidence block for the PR description.""" @@ -377,7 +377,7 @@ def build_evidence_block( "", ] - for checklist in relevant: + for checklist in relevant_checklists: checklist_id = checklist["id"] lines.append(f"### {checklist['name']} (`{checklist_id}`)") lines.append("") diff --git a/tools/review-checklists/scripts/post_checklists.py b/tools/review-checklists/scripts/post_checklists.py index 3734e9c8b..06560a002 100644 --- a/tools/review-checklists/scripts/post_checklists.py +++ b/tools/review-checklists/scripts/post_checklists.py @@ -60,9 +60,9 @@ def main() -> None: checklists = load_checklists(args.config_path) changed_files = get_changed_files(pr) - relevant = match_checklists(checklists, changed_files) + relevant_checklists = match_checklists(checklists, changed_files) - if not relevant: + if not relevant_checklists: print("No checklists are relevant for this PR.") set_commit_status( repo, @@ -74,7 +74,7 @@ def main() -> None: existing = find_existing_checklist_comments(pr) - for checklist in relevant: + for checklist in relevant_checklists: body = make_checklist_comment_body(checklist) if checklist["id"] in existing: comment = existing[checklist["id"]] @@ -103,11 +103,13 @@ def main() -> None: # Collect current acknowledgements and update evidence in PR description posted_relevant_ids = [ - checklist["id"] for checklist in relevant if checklist["id"] in existing + checklist["id"] + for checklist in relevant_checklists + if checklist["id"] in existing ] if posted_relevant_ids: ack_details = collect_acknowledgement_details(pr, existing, posted_relevant_ids) - evidence_block = build_evidence_block(relevant, ack_details) + evidence_block = build_evidence_block(relevant_checklists, ack_details) update_pr_description_with_evidence(pr, evidence_block) if is_pr_in_merge_queue(pr): @@ -119,10 +121,10 @@ def main() -> None: repo, pr.head.sha, "pending", - f"{len(relevant)} checklist(s) require reviewer acknowledgement", + f"{len(relevant_checklists)} checklist(s) require reviewer acknowledgement", ) - print(f"Posted/updated {len(relevant)} checklist finding(s).") + print(f"Posted/updated {len(relevant_checklists)} checklist finding(s).") if __name__ == "__main__": From 15c6f1d036759ac8eef3f5b7c6bd90fe463a01c0 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Wed, 19 Aug 2026 10:06:11 +0200 Subject: [PATCH 31/34] Add review checklists concept to CI.md --- CI.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CI.md b/CI.md index 0fa7e08c0..9a8b95454 100644 --- a/CI.md +++ b/CI.md @@ -222,6 +222,13 @@ but with the addition of the flag `--runs_per_test=20`. To be able to have fast feedback, all our CI job use Bazel cache. To avoid permanent cache poisoning and cache size exploding, the cache is recreated nightly. More details can be found in the [cache strategy design document](./.github/cache-strategy.md). +#### Review checklists + +Ensures that specific manual actions are performed by the reviewers of a pull request. +Reviewers must acknowledge that they have performed the actions in the checklist before a pull request can be merged. +During merge to the base branch, the acknowledgements are stored in the git history as evidence. +The checklists are defined in the [review checklist configuration document](./.github/review_checklists.yml). + ## Post-mortem analysis and follow-up actions It is acknowledged that this design is not perfect, and experience will teach us that this design will have to be adapted. From 1315cfec8fde7f159a03bcd1790a67fbf94e1231 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Wed, 19 Aug 2026 10:59:23 +0200 Subject: [PATCH 32/34] Fix sync_skills.check false failure by removing .github/BUILD package boundary .github/BUILD created a Bazel package boundary under .github, which caused the root BUILD's glob(".github/skills/score-*/**") to silently return empty (native.glob cannot cross package boundaries). As a result, sync_skills.check always reported the score_tooling skill files (score-architecture, score-requirements, score-safety-analysis, score-testing) as MISSING even though they were present and in sync. Move the review_checklists_config filegroup into the root BUILD file instead, referencing .github/review_checklists.yml directly, and update the one consumer (tools/review-checklists/scripts/BUILD) to depend on //:review_checklists_config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/BUILD | 18 ------------------ BUILD | 11 +++++++++++ tools/review-checklists/scripts/BUILD | 2 +- 3 files changed, 12 insertions(+), 19 deletions(-) delete mode 100644 .github/BUILD diff --git a/.github/BUILD b/.github/BUILD deleted file mode 100644 index c82a32d87..000000000 --- a/.github/BUILD +++ /dev/null @@ -1,18 +0,0 @@ -# ******************************************************************************* -# Copyright (c) 2026 Contributors to the Eclipse Foundation -# -# See the NOTICE file(s) distributed with this work for additional -# information regarding copyright ownership. -# -# This program and the accompanying materials are made available under the -# terms of the Apache License Version 2.0 which is available at -# https://www.apache.org/licenses/LICENSE-2.0 -# -# SPDX-License-Identifier: Apache-2.0 -# ******************************************************************************* - -filegroup( - name = "review_checklists_config", - srcs = ["review_checklists.yml"], - visibility = ["//tools/review-checklists:__subpackages__"], -) diff --git a/BUILD b/BUILD index fbffa7e1b..6385a532d 100644 --- a/BUILD +++ b/BUILD @@ -23,6 +23,17 @@ exports_files(["MODULE.bazel"]) sync_skills() +# Kept in the root package (rather than a .github/BUILD file) so that this +# package doesn't become its own Bazel package: a BUILD file under .github +# would create a package boundary there, causing the sync_skills.check +# glob(".github/skills/score-*/**") above to silently stop matching any +# files and always report the score_tooling skills as missing. +filegroup( + name = "review_checklists_config", + srcs = [".github/review_checklists.yml"], + visibility = ["//tools/review-checklists:__subpackages__"], +) + sphinx_docs_library( name = "contributing_md", srcs = ["CONTRIBUTING.md"], diff --git a/tools/review-checklists/scripts/BUILD b/tools/review-checklists/scripts/BUILD index 2c2b17eb6..928ee3ac7 100644 --- a/tools/review-checklists/scripts/BUILD +++ b/tools/review-checklists/scripts/BUILD @@ -16,7 +16,7 @@ load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test") py_library( name = "helpers", srcs = ["helpers.py"], - data = ["//.github:review_checklists_config"], + data = ["//:review_checklists_config"], imports = ["."], visibility = ["//tools/review-checklists:__subpackages__"], deps = [ From 2a7978653f69a38a97d920082cc05d88a27634b5 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Wed, 19 Aug 2026 12:06:54 +0200 Subject: [PATCH 33/34] Reformat review-checklists scripts with ruff The Address/UB Sanitizer CI job's format_test_Python_with_ruff was failing because tools/review-checklists/scripts/*.py were not formatted per ruff's rules (e.g. a multi-line function signature that ruff collapses onto one line). Run 'bazel run //:format' to apply ruff's formatting; no behavioral changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/check_acknowledgements.py | 38 ++++--------------- .../scripts/check_acknowledgements_test.py | 36 +++++------------- .../scripts/dismiss_and_invalidate.py | 19 ++-------- .../scripts/dismiss_and_invalidate_test.py | 4 +- tools/review-checklists/scripts/helpers.py | 26 +++---------- .../review-checklists/scripts/helpers_test.py | 23 +++-------- .../scripts/post_checklists.py | 10 +---- .../scripts/post_checklists_test.py | 20 +++------- 8 files changed, 39 insertions(+), 137 deletions(-) diff --git a/tools/review-checklists/scripts/check_acknowledgements.py b/tools/review-checklists/scripts/check_acknowledgements.py index c8830da22..d1f53b5fb 100644 --- a/tools/review-checklists/scripts/check_acknowledgements.py +++ b/tools/review-checklists/scripts/check_acknowledgements.py @@ -81,10 +81,7 @@ def _collect_ok_acknowledgements( The conversation thread itself associates the reply with the checklist. """ replies = find_ok_replies_for_checklists(pr, existing_comments, posted_relevant_ids) - return { - checklist_id: {comment.user.login for comment in comments} - for checklist_id, comments in replies.items() - } + return {checklist_id: {comment.user.login for comment in comments} for checklist_id, comments in replies.items()} def _acknowledgement_status( @@ -103,17 +100,12 @@ def _acknowledgement_status( missing: dict[str, list[str]] = {} for checklist_id in posted_relevant_ids: - not_acked = [ - username for username in approvers if username not in acks[checklist_id] - ] + not_acked = [username for username in approvers if username not in acks[checklist_id]] if not_acked: missing[checklist_id] = not_acked if missing: - summary_parts = [ - f"{checklist_id}: awaiting {', '.join(users)}" - for checklist_id, users in missing.items() - ] + summary_parts = [f"{checklist_id}: awaiting {', '.join(users)}" for checklist_id, users in missing.items()] return "pending", "; ".join(summary_parts) return "success", "All checklists acknowledged by all approving reviewers" @@ -137,11 +129,7 @@ def _validate_checklist_evidence(pr: Any, checklists: list[dict]) -> tuple[str, return "success", "No checklists applicable" existing = find_existing_checklist_comments(pr) - posted_relevant_ids = [ - checklist["id"] - for checklist in relevant_checklists - if checklist["id"] in existing - ] + posted_relevant_ids = [checklist["id"] for checklist in relevant_checklists if checklist["id"] in existing] if len(posted_relevant_ids) < len(relevant_checklists): # At least one relevant checklist has no posted comment yet — do not @@ -156,9 +144,7 @@ def _validate_checklist_evidence(pr: Any, checklists: list[dict]) -> tuple[str, def main() -> None: - parser = argparse.ArgumentParser( - description="Verify review-checklist acknowledgements on a PR." - ) + parser = argparse.ArgumentParser(description="Verify review-checklist acknowledgements on a PR.") parser.add_argument( "--config-path", default=".github/review_checklists.yml", @@ -183,9 +169,7 @@ def main() -> None: pr_number = 0 if not pr_number: - description = ( - "Could not resolve pull request to validate checklist evidence" - ) + description = "Could not resolve pull request to validate checklist evidence" print(description) set_commit_status(repo, head_sha, "failure", description) sys.exit(1) @@ -222,11 +206,7 @@ def main() -> None: return existing = find_existing_checklist_comments(pr) - posted_relevant_ids = [ - checklist["id"] - for checklist in relevant_checklists - if checklist["id"] in existing - ] + posted_relevant_ids = [checklist["id"] for checklist in relevant_checklists if checklist["id"] in existing] if len(posted_relevant_ids) < len(relevant_checklists): # At least one relevant checklist has no posted comment yet — keep @@ -255,9 +235,7 @@ def main() -> None: # Write acknowledgement data for downstream use (merge evidence). ack_data = {checklist_id: sorted(users) for checklist_id, users in acks.items()} runner_temp = os.environ.get("RUNNER_TEMP", "./") - output_path = os.environ.get( - "ACK_OUTPUT_PATH", runner_temp + "/checklist_acks.json" - ) + output_path = os.environ.get("ACK_OUTPUT_PATH", runner_temp + "/checklist_acks.json") with open(output_path, "w") as f: json.dump(ack_data, f, indent=2) print(f"Acknowledgement data written to {output_path}") diff --git a/tools/review-checklists/scripts/check_acknowledgements_test.py b/tools/review-checklists/scripts/check_acknowledgements_test.py index 064a9d677..9c48a7a38 100644 --- a/tools/review-checklists/scripts/check_acknowledgements_test.py +++ b/tools/review-checklists/scripts/check_acknowledgements_test.py @@ -206,9 +206,7 @@ def test_pending_when_not_all_relevant_checklists_posted(self): # Only api-review has been posted; doc-review has not. return_value={"api-review": api_review_comment}, ): - state, description = _validate_checklist_evidence( - pr, TWO_SAMPLE_CHECKLISTS - ) + state, description = _validate_checklist_evidence(pr, TWO_SAMPLE_CHECKLISTS) assert state == "pending" assert description == "Checklist comments not yet posted" @@ -241,9 +239,7 @@ def test_success_when_all_relevant_checklists_posted(self): return_value=["alice"], ), ): - state, description = _validate_checklist_evidence( - pr, SAMPLE_CHECKLISTS - ) + state, description = _validate_checklist_evidence(pr, SAMPLE_CHECKLISTS) assert state == "success" assert description == "All checklists acknowledged by all approving reviewers" @@ -263,9 +259,7 @@ class TestCheckAcknowledgementsMain: ) @patch("check_acknowledgements.get_repo_and_pr") @patch("check_acknowledgements.get_github_client") - def test_no_relevant_checklists_sets_success( - self, mock_gh, mock_repo_pr, mock_load, mock_status, mock_in_queue - ): + def test_no_relevant_checklists_sets_success(self, mock_gh, mock_repo_pr, mock_load, mock_status, mock_in_queue): repo = MagicMock() pr = MagicMock() pr.head.sha = "abc" @@ -274,9 +268,7 @@ def test_no_relevant_checklists_sets_success( # Ensure the argument parser doesn't see pytest's CLI arguments. with patch.object(sys, "argv", ["check_acknowledgements"]): main() - mock_status.assert_called_once_with( - repo, "abc", "success", "No checklists applicable" - ) + mock_status.assert_called_once_with(repo, "abc", "success", "No checklists applicable") @patch("check_acknowledgements.is_pr_in_merge_queue", return_value=False) @patch("check_acknowledgements.set_commit_status") @@ -303,9 +295,7 @@ def test_no_existing_comments_sets_pending( with patch.object(sys, "argv", ["check_acknowledgements"]): main() - mock_status.assert_called_once_with( - repo, "abc", "pending", "Checklist comments not yet posted" - ) + mock_status.assert_called_once_with(repo, "abc", "pending", "Checklist comments not yet posted") @patch("check_acknowledgements.is_pr_in_merge_queue", return_value=False) @patch("check_acknowledgements.set_commit_status") @@ -349,9 +339,7 @@ def test_some_but_not_all_relevant_checklists_posted_sets_pending( ): main() - mock_status.assert_called_once_with( - repo, "abc", "pending", "Checklist comments not yet posted" - ) + mock_status.assert_called_once_with(repo, "abc", "pending", "Checklist comments not yet posted") @patch("check_acknowledgements.is_pr_in_merge_queue", return_value=False) @patch("check_acknowledgements.set_commit_status") @@ -386,9 +374,7 @@ def test_no_approvers_sets_pending( ): main() - mock_status.assert_called_with( - repo, "abc", "pending", "Awaiting at least one approving review" - ) + mock_status.assert_called_with(repo, "abc", "pending", "Awaiting at least one approving review") @patch("check_acknowledgements.is_pr_in_merge_queue", return_value=False) @patch("check_acknowledgements.set_commit_status") @@ -511,9 +497,7 @@ def test_missing_ack_sets_pending( ): main() - mock_status.assert_called_with( - repo, "abc", "pending", "api-review: awaiting bob" - ) + mock_status.assert_called_with(repo, "abc", "pending", "api-review: awaiting bob") @patch("check_acknowledgements.ensure_merge_queue_notice_description") @patch("check_acknowledgements.ensure_merge_queue_notice_comment") @@ -550,9 +534,7 @@ def test_merge_queue_notice_refreshed_when_pr_enqueued( @patch("check_acknowledgements.set_commit_status") @patch("check_acknowledgements.get_repo_and_pr") @patch("check_acknowledgements.get_github_client") - def test_merge_group_without_pr_number_sets_failure( - self, mock_gh, mock_repo_pr, mock_status - ): + def test_merge_group_without_pr_number_sets_failure(self, mock_gh, mock_repo_pr, mock_status): repo = MagicMock() gh = MagicMock() gh.get_repo.return_value = repo diff --git a/tools/review-checklists/scripts/dismiss_and_invalidate.py b/tools/review-checklists/scripts/dismiss_and_invalidate.py index 59d8b1de0..d0194a715 100644 --- a/tools/review-checklists/scripts/dismiss_and_invalidate.py +++ b/tools/review-checklists/scripts/dismiss_and_invalidate.py @@ -77,9 +77,7 @@ def _get_files_in_latest_push(pr: Any, repo: Any) -> list[str]: try: run_id = int(run_id_raw) workflow = repo.get_workflow(TRIGGER_WORKFLOW_FILE) - runs = list( - workflow.get_runs(branch=head_branch, event=TRIGGER_EVENT_NAME) - ) + runs = list(workflow.get_runs(branch=head_branch, event=TRIGGER_EVENT_NAME)) run_ids = [run.id for run in runs] idx = run_ids.index(run_id) before_sha = runs[idx + 1].head_sha @@ -110,11 +108,7 @@ def handle_synchronize(pr: Any, repo: Any, config_path: str) -> None: return existing = find_existing_checklist_comments(pr) - posted_affected_ids = [ - checklist["id"] - for checklist in affected_checklists - if checklist["id"] in existing - ] + posted_affected_ids = [checklist["id"] for checklist in affected_checklists if checklist["id"] in existing] ok_replies = find_ok_replies_for_checklists(pr, existing, posted_affected_ids) any_invalidated = False @@ -127,10 +121,7 @@ def handle_synchronize(pr: Any, repo: Any, config_path: str) -> None: any_invalidated = True try: ok_comment.delete() - print( - f"Deleted OK comment {ok_comment.id} from {user} " - f"for checklist '{finding_comment.id}'" - ) + print(f"Deleted OK comment {ok_comment.id} from {user} for checklist '{finding_comment.id}'") except Exception as e: print(f"Warning: could not delete comment {ok_comment.id}: {e}") @@ -144,9 +135,7 @@ def handle_synchronize(pr: Any, repo: Any, config_path: str) -> None: def main() -> None: - parser = argparse.ArgumentParser( - description="Invalidate OK acknowledgements after a new push." - ) + parser = argparse.ArgumentParser(description="Invalidate OK acknowledgements after a new push.") parser.add_argument( "--config-path", default=".github/review_checklists.yml", diff --git a/tools/review-checklists/scripts/dismiss_and_invalidate_test.py b/tools/review-checklists/scripts/dismiss_and_invalidate_test.py index 6a767eebf..610f4149b 100644 --- a/tools/review-checklists/scripts/dismiss_and_invalidate_test.py +++ b/tools/review-checklists/scripts/dismiss_and_invalidate_test.py @@ -108,9 +108,7 @@ def test_uses_previous_run_head_sha_for_comparison(self, monkeypatch): result = _get_files_in_latest_push(pr, repo) repo.get_workflow.assert_called_once_with("review_checklists_trigger.yml") - workflow.get_runs.assert_called_once_with( - branch="feature", event="pull_request_target" - ) + workflow.get_runs.assert_called_once_with(branch="feature", event="pull_request_target") repo.compare.assert_called_once_with("oldsha", "newsha") assert result == ["src/api/handler.py"] diff --git a/tools/review-checklists/scripts/helpers.py b/tools/review-checklists/scripts/helpers.py index de32a15f1..f16d5f231 100644 --- a/tools/review-checklists/scripts/helpers.py +++ b/tools/review-checklists/scripts/helpers.py @@ -159,11 +159,7 @@ def make_checklist_comment_body(checklist: dict) -> str: marker = CHECKLIST_MARKER.format(checklist_id=checklist["id"]) include_patterns = checklist.get("include", []) exclude_patterns = checklist.get("exclude", []) - exclude_line = ( - f"**Excluding files matching:** `{'`, `'.join(exclude_patterns)}`\n\n" - if exclude_patterns - else "" - ) + exclude_line = f"**Excluding files matching:** `{'`, `'.join(exclude_patterns)}`\n\n" if exclude_patterns else "" body = ( f"{marker}\n" f"## 📋 {checklist['name']}\n\n" @@ -230,9 +226,7 @@ def find_ok_replies_for_checklists( """ replies: dict[str, list[Any]] = {checklist_id: [] for checklist_id in checklist_ids} comment_id_to_checklist_id: dict[int, str] = { - comment.id: checklist_id - for checklist_id, comment in existing_comments.items() - if checklist_id in checklist_ids + comment.id: checklist_id for checklist_id, comment in existing_comments.items() if checklist_id in checklist_ids } for comment in pr.get_review_comments(): @@ -294,10 +288,7 @@ def set_commit_status( ) -> None: """Set a commit status on the given SHA.""" desc = description[:COMMIT_STATUS_DESCRIPTION_MAX_LENGTH] - print( - f"Setting commit status: context='{context}', state='{state}', " - f"sha='{sha}', description='{desc}'" - ) + print(f"Setting commit status: context='{context}', state='{state}', sha='{sha}', description='{desc}'") repo.get_commit(sha).create_status( state=state, description=desc, @@ -429,9 +420,7 @@ def is_pr_in_merge_queue(pr: Any) -> bool: PR itself and fails the commit status (does not default to success) if that resolution or validation fails. """ - repo_name = getattr( - getattr(getattr(pr, "base", None), "repo", None), "full_name", "" - ) + repo_name = getattr(getattr(getattr(pr, "base", None), "repo", None), "full_name", "") if not repo_name or "/" not in repo_name: repo_name = os.environ.get("GITHUB_REPOSITORY", "") if "/" not in repo_name: @@ -468,12 +457,7 @@ def is_pr_in_merge_queue(pr: Any) -> bool: print(f"GraphQL merge-queue lookup failed: {exc}") return False - is_in_queue = ( - result.get("data", {}) - .get("repository", {}) - .get("pullRequest", {}) - .get("isInMergeQueue") - ) + is_in_queue = result.get("data", {}).get("repository", {}).get("pullRequest", {}).get("isInMergeQueue") if isinstance(is_in_queue, bool): return is_in_queue diff --git a/tools/review-checklists/scripts/helpers_test.py b/tools/review-checklists/scripts/helpers_test.py index d807b3210..06edc1529 100644 --- a/tools/review-checklists/scripts/helpers_test.py +++ b/tools/review-checklists/scripts/helpers_test.py @@ -156,9 +156,7 @@ def test_file_not_found_raises(self, monkeypatch, tmp_path): monkeypatch.delenv("RUNFILES_MANIFEST_FILE", raising=False) with patch( "helpers._find_checklists_config", - side_effect=FileNotFoundError( - "Cannot locate .github/review_checklists.yml" - ), + side_effect=FileNotFoundError("Cannot locate .github/review_checklists.yml"), ): with pytest.raises(FileNotFoundError): load_checklists() @@ -405,9 +403,7 @@ def test_finds_case_insensitive_ok(self): pr = MagicMock() pr.get_review_comments.return_value = [c1, c2] - result = find_ok_replies_for_checklists( - pr, {"api-review": c1}, ["api-review"] - ) + result = find_ok_replies_for_checklists(pr, {"api-review": c1}, ["api-review"]) assert len(result["api-review"]) == 1 def test_ignores_reply_to_different_comment(self): @@ -615,9 +611,7 @@ def Create(): class TestIsPrInMergeQueue: @patch("helpers._run_graphql_query") def test_true_when_graphql_returns_true(self, mock_query): - mock_query.return_value = { - "data": {"repository": {"pullRequest": {"isInMergeQueue": True}}} - } + mock_query.return_value = {"data": {"repository": {"pullRequest": {"isInMergeQueue": True}}}} pr = MagicMock() pr.base.repo.full_name = "org/repo" @@ -628,9 +622,7 @@ def test_true_when_graphql_returns_true(self, mock_query): @patch("helpers._run_graphql_query") def test_false_when_graphql_returns_false(self, mock_query): - mock_query.return_value = { - "data": {"repository": {"pullRequest": {"isInMergeQueue": False}}} - } + mock_query.return_value = {"data": {"repository": {"pullRequest": {"isInMergeQueue": False}}}} pr = MagicMock() pr.base.repo.full_name = "org/repo" @@ -673,12 +665,7 @@ def test_adds_notice_block(self): def test_updates_tampered_notice_block(self): pr = MagicMock() - pr.body = ( - "User summary\n" - f"{MERGE_QUEUE_NOTICE_START}\n" - "tampered\n" - f"{MERGE_QUEUE_NOTICE_END}" - ) + pr.body = f"User summary\n{MERGE_QUEUE_NOTICE_START}\ntampered\n{MERGE_QUEUE_NOTICE_END}" ensure_merge_queue_notice_description(pr) diff --git a/tools/review-checklists/scripts/post_checklists.py b/tools/review-checklists/scripts/post_checklists.py index 06560a002..24db51bd4 100644 --- a/tools/review-checklists/scripts/post_checklists.py +++ b/tools/review-checklists/scripts/post_checklists.py @@ -45,9 +45,7 @@ def main() -> None: - parser = argparse.ArgumentParser( - description="Post or update review-checklist findings on a PR." - ) + parser = argparse.ArgumentParser(description="Post or update review-checklist findings on a PR.") parser.add_argument( "--config-path", default=".github/review_checklists.yml", @@ -102,11 +100,7 @@ def main() -> None: print(f"Created checklist finding for '{checklist['id']}'") # Collect current acknowledgements and update evidence in PR description - posted_relevant_ids = [ - checklist["id"] - for checklist in relevant_checklists - if checklist["id"] in existing - ] + posted_relevant_ids = [checklist["id"] for checklist in relevant_checklists if checklist["id"] in existing] if posted_relevant_ids: ack_details = collect_acknowledgement_details(pr, existing, posted_relevant_ids) evidence_block = build_evidence_block(relevant_checklists, ack_details) diff --git a/tools/review-checklists/scripts/post_checklists_test.py b/tools/review-checklists/scripts/post_checklists_test.py index efefff7df..9d72fa685 100644 --- a/tools/review-checklists/scripts/post_checklists_test.py +++ b/tools/review-checklists/scripts/post_checklists_test.py @@ -46,9 +46,7 @@ class TestPostChecklistsMain: @patch("post_checklists.load_checklists", return_value=SAMPLE_CHECKLISTS) @patch("post_checklists.get_repo_and_pr") @patch("post_checklists.get_github_client") - def test_no_relevant_checklists_sets_success( - self, mock_gh, mock_repo_pr, mock_load, mock_status - ): + def test_no_relevant_checklists_sets_success(self, mock_gh, mock_repo_pr, mock_load, mock_status): repo = MagicMock() pr = MagicMock() pr.head.sha = "abc123" @@ -57,18 +55,14 @@ def test_no_relevant_checklists_sets_success( main() - mock_status.assert_called_once_with( - repo, "abc123", "success", "No checklists applicable" - ) + mock_status.assert_called_once_with(repo, "abc123", "success", "No checklists applicable") @patch("post_checklists.set_commit_status") @patch("post_checklists.find_existing_checklist_comments", return_value={}) @patch("post_checklists.load_checklists", return_value=SAMPLE_CHECKLISTS) @patch("post_checklists.get_repo_and_pr") @patch("post_checklists.get_github_client") - def test_creates_new_review_with_inline_comment( - self, mock_gh, mock_repo_pr, mock_load, mock_existing, mock_status - ): + def test_creates_new_review_with_inline_comment(self, mock_gh, mock_repo_pr, mock_load, mock_existing, mock_status): repo = MagicMock() pr = MagicMock() pr.head.sha = "abc123" @@ -94,9 +88,7 @@ def test_creates_new_review_with_inline_comment( @patch("post_checklists.load_checklists", return_value=SAMPLE_CHECKLISTS) @patch("post_checklists.get_repo_and_pr") @patch("post_checklists.get_github_client") - def test_updates_existing_review_when_body_changed( - self, mock_gh, mock_repo_pr, mock_load, mock_status - ): + def test_updates_existing_review_when_body_changed(self, mock_gh, mock_repo_pr, mock_load, mock_status): repo = MagicMock() pr = MagicMock() pr.head.sha = "abc123" @@ -118,9 +110,7 @@ def test_updates_existing_review_when_body_changed( @patch("post_checklists.load_checklists", return_value=SAMPLE_CHECKLISTS) @patch("post_checklists.get_repo_and_pr") @patch("post_checklists.get_github_client") - def test_skips_update_when_body_unchanged( - self, mock_gh, mock_repo_pr, mock_load, mock_status - ): + def test_skips_update_when_body_unchanged(self, mock_gh, mock_repo_pr, mock_load, mock_status): repo = MagicMock() pr = MagicMock() pr.head.sha = "abc123" From 4bd348341166c7929602ea2f793edc5a42e19a71 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Wed, 19 Aug 2026 12:57:37 +0200 Subject: [PATCH 34/34] Fix ruff F401 unused-import findings in helpers_test.py The Linters / ruff CI job flagged two unused imports: - 'os' (only referenced as 'helpers.os.path.isfile' inside patch(...) string targets, never as a bare 'os.' call) - 'helpers' (only referenced inside patch("helpers....") string targets, never as 'helpers.' in real code) Remove both; unittest.mock.patch resolves its string targets via its own import machinery and does not need these names bound in the test module's namespace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/review-checklists/scripts/helpers_test.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tools/review-checklists/scripts/helpers_test.py b/tools/review-checklists/scripts/helpers_test.py index 06edc1529..84b8ff2b8 100644 --- a/tools/review-checklists/scripts/helpers_test.py +++ b/tools/review-checklists/scripts/helpers_test.py @@ -15,7 +15,6 @@ from __future__ import annotations -import os import sys import types from unittest.mock import MagicMock, patch @@ -23,7 +22,6 @@ import pytest import yaml -import helpers from helpers import ( CHECKLIST_MARKER, MERGE_QUEUE_COMMENT_MARKER,