diff --git a/.github/review_checklists.yml b/.github/review_checklists.yml new file mode 100644 index 000000000..e5124cd29 --- /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_apply.yml b/.github/workflows/review_checklists_apply.yml new file mode 100644 index 000000000..92d301c3d --- /dev/null +++ b/.github/workflows/review_checklists_apply.yml @@ -0,0 +1,178 @@ +# ******************************************************************************* +# 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 derives the original event +# 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 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. +# +# Original events handled: +# pull_request_target (opened/reopened/synchronize/edited) +# -> Post or update checklist findings on the PR. +# -> 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) +# -> Restore checklist findings if they were tampered with or deleted. +# -> 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) +# -> 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 +# 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 }} + # 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: + 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 + + # 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. + # + # 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 + run: | + 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. + # 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.workflow_run.event == 'pull_request_target' || + github.event.workflow_run.event == 'pull_request_review_comment' + uses: ./tools/review-checklists + with: + action: post + github-token: ${{ secrets.GITHUB_TOKEN }} + pr-number: ${{ steps.resolve_pr.outputs.pr_number }} + + # 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) 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' + uses: ./tools/review-checklists + with: + action: dismiss_sync + github-token: ${{ secrets.GITHUB_TOKEN }} + pr-number: ${{ steps.resolve_pr.outputs.pr_number }} + run-id: ${{ github.event.workflow_run.id }} + head-branch: ${{ github.event.workflow_run.head_branch }} + + # Step 3: Always re-check acknowledgement status. + # The script sets the commit status context "review-checklists" + # 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, 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. 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: + action: check + github-token: ${{ secrets.GITHUB_TOKEN }} + 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 new file mode 100644 index 000000000..349eb7575 --- /dev/null +++ b/.github/workflows/review_checklists_trigger.yml @@ -0,0 +1,98 @@ +# ******************************************************************************* +# 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. 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/ + +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: + trigger-apply: + name: Trigger apply stage + runs-on: ubuntu-24.04 + steps: + # 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/BUILD b/BUILD index e28bfe93d..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"], @@ -47,6 +58,7 @@ copyright_checker( "quality", "score", "third_party", + "tools", "//:BUILD", "//:MODULE.bazel", ], 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. diff --git a/MODULE.bazel b/MODULE.bazel index 68e246cf8..3416ce8da 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 = "//tools/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/tools/review-checklists/BUILD b/tools/review-checklists/BUILD new file mode 100644 index 000000000..b8862fff9 --- /dev/null +++ b/tools/review-checklists/BUILD @@ -0,0 +1,21 @@ +# ******************************************************************************* +# 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", + exec_compatible_with = ["@platforms//os:linux"], + requirements_txt = "requirements.txt", + target_compatible_with = ["@platforms//os:linux"], +) diff --git a/tools/review-checklists/README.md b/tools/review-checklists/README.md new file mode 100644 index 000000000..971ad4568 --- /dev/null +++ b/tools/review-checklists/README.md @@ -0,0 +1,249 @@ + + +# 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: 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 + +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 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 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`) +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. 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 + 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. **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 + 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`). +- **`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`) + +| 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 | 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 | +| `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/tools/review-checklists/action.yml b/tools/review-checklists/action.yml new file mode 100644 index 000000000..eb93681f1 --- /dev/null +++ b/tools/review-checklists/action.yml @@ -0,0 +1,116 @@ +# ******************************************************************************* +# 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 + required: true + github-token: + description: "GitHub token with repo permissions" + required: true + pr-number: + description: "Pull request number" + required: false + default: "" + 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: "" + 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: + description: > + 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'). 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: + 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: + # 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" + + - 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 }} + CHECKLISTS_EVENT_NAME: ${{ inputs.event-name || github.event_name }} + run: python3 "$GITHUB_ACTION_PATH/scripts/check_acknowledgements.py" --config-path "${{ inputs.config-path }}" + + + - name: Run dismiss_and_invalidate + if: inputs.action == 'dismiss_sync' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pr-number }} + 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/tools/review-checklists/requirements.txt b/tools/review-checklists/requirements.txt new file mode 100644 index 000000000..8a8af288e --- /dev/null +++ b/tools/review-checklists/requirements.txt @@ -0,0 +1,853 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# bazel run //tools/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 tools/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 tools/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 +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 + # 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 tools/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 tools/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 tools/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/tools/review-checklists/requirements.txt.in b/tools/review-checklists/requirements.txt.in new file mode 100644 index 000000000..777a58c27 --- /dev/null +++ b/tools/review-checklists/requirements.txt.in @@ -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 +# ******************************************************************************* + +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 new file mode 100644 index 000000000..928ee3ac7 --- /dev/null +++ b/tools/review-checklists/scripts/BUILD @@ -0,0 +1,115 @@ +# ******************************************************************************* +# 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 = ["//:review_checklists_config"], + imports = ["."], + visibility = ["//tools/review-checklists:__subpackages__"], + deps = [ + requirement("bazel-runfiles"), + requirement("gql"), + requirement("pathspec"), + 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", + deps = [":helpers"], +) + +py_binary( + name = "check_acknowledgements", + srcs = ["check_acknowledgements.py"], + main = "check_acknowledgements.py", + deps = [":helpers"], +) + +py_binary( + name = "dismiss_and_invalidate", + srcs = ["dismiss_and_invalidate.py"], + main = "dismiss_and_invalidate.py", + 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/tools/review-checklists/scripts/__init__.py b/tools/review-checklists/scripts/__init__.py new file mode 100644 index 000000000..fe8d40d4a --- /dev/null +++ b/tools/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/tools/review-checklists/scripts/check_acknowledgements.py b/tools/review-checklists/scripts/check_acknowledgements.py new file mode 100644 index 000000000..d1f53b5fb --- /dev/null +++ b/tools/review-checklists/scripts/check_acknowledgements.py @@ -0,0 +1,245 @@ +#!/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. 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. + +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 + +import argparse +import json +import os +import sys +from typing import Any + +from helpers import ( + 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, + get_repo_and_pr, + is_pr_in_merge_queue, + load_checklists, + match_checklists, + set_commit_status, + update_pr_description_with_evidence, +) + + +def _collect_ok_acknowledgements( + 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. + + 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. + """ + 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()} + + +def _acknowledgement_status( + approvers: 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. + + 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 checklist_id in posted_relevant_ids: + 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()] + 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_checklists = match_checklists(checklists, changed_files) + + if not relevant_checklists: + 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] + + 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 + # pending until every relevant checklist has been posted. + return "pending", "Checklist comments not yet posted" + + acks = _collect_ok_acknowledgements(pr, existing, posted_relevant_ids) + approvers = get_approving_reviewers(pr) + return _acknowledgement_status(approvers, posted_relevant_ids, 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("CHECKLISTS_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) + + 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) + + # 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) + + checklists = load_checklists(args.config_path) + changed_files = get_changed_files(pr) + relevant_checklists = match_checklists(checklists, changed_files) + + 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_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 + # pending rather than silently dropping it from consideration (see + # _validate_checklist_evidence for the same rationale). + set_commit_status( + repo, + pr.head.sha, + "pending", + "Checklist comments not yet posted", + ) + return + + 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, posted_relevant_ids) + evidence_block = build_evidence_block(relevant_checklists, ack_details) + update_pr_description_with_evidence(pr, evidence_block) + + approvers = get_approving_reviewers(pr) + state, description = _acknowledgement_status(approvers, posted_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 = {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") + 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/tools/review-checklists/scripts/check_acknowledgements_test.py b/tools/review-checklists/scripts/check_acknowledgements_test.py new file mode 100644 index 000000000..9c48a7a38 --- /dev/null +++ b/tools/review-checklists/scripts/check_acknowledgements_test.py @@ -0,0 +1,694 @@ +# ******************************************************************************* +# 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, + _validate_checklist_evidence, + 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", + }, +] + +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 +# --------------------------------------------------------------------------- + + +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() + + +# --------------------------------------------------------------------------- +# _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() +# --------------------------------------------------------------------------- + + +class TestCheckAcknowledgementsMain: + @patch("check_acknowledgements.is_pr_in_merge_queue", return_value=False) + @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, mock_in_queue): + 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.is_pr_in_merge_queue", return_value=False) + @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, mock_in_queue + ): + 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.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=[]) + @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, mock_in_queue + ): + 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.is_pr_in_merge_queue", return_value=False) + @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, + mock_in_queue, + 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.is_pr_in_merge_queue", return_value=False) + @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, + mock_in_queue, + 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.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") + 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 + mock_gh.return_value = gh + + with ( + patch.dict( + os.environ, + { + "CHECKLISTS_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"]), + ): + 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, + { + "CHECKLISTS_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: 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, + { + "CHECKLISTS_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", + ) + + +if __name__ == "__main__": + sys.exit(pytest.main(sys.argv[1:])) diff --git a/tools/review-checklists/scripts/dismiss_and_invalidate.py b/tools/review-checklists/scripts/dismiss_and_invalidate.py new file mode 100644 index 000000000..d0194a715 --- /dev/null +++ b/tools/review-checklists/scripts/dismiss_and_invalidate.py @@ -0,0 +1,152 @@ +#!/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 after a new push. + +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 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 + +import argparse +import os +from typing import Any + +from helpers import ( + find_existing_checklist_comments, + find_ok_replies_for_checklists, + get_changed_files, + get_github_client, + get_repo_and_pr, + 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, repo: Any) -> list[str]: + """Return files changed in the most recent push to the PR. + + 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. + """ + 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 = [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) + 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) + + +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, + 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, repo) + affected_checklists = match_checklists(checklists, new_files) + + 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_checklists if checklist["id"] in existing] + ok_replies = find_ok_replies_for_checklists(pr, existing, posted_affected_ids) + + any_invalidated = False + + for checklist_id in posted_affected_ids: + finding_comment = existing[checklist_id] + + for ok_comment in ok_replies[checklist_id]: + user = ok_comment.user.login + any_invalidated = True + try: + ok_comment.delete() + 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}") + + if any_invalidated: + set_commit_status( + repo, + pr.head.sha, + "pending", + "Checklist acknowledgements invalidated due to new changes", + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Invalidate OK acknowledgements after a new push.") + 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) + handle_synchronize(pr, repo, args.config_path) + + +if __name__ == "__main__": + main() diff --git a/tools/review-checklists/scripts/dismiss_and_invalidate_test.py b/tools/review-checklists/scripts/dismiss_and_invalidate_test.py new file mode 100644 index 000000000..610f4149b --- /dev/null +++ b/tools/review-checklists/scripts/dismiss_and_invalidate_test.py @@ -0,0 +1,225 @@ +# ******************************************************************************* +# 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 + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest +import sys + +from dismiss_and_invalidate import ( + _get_files_in_latest_push, + 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 + + +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", + "name": "API Review", + "include": ["src/api/*.py"], + "checklist": "- [ ] Reviewed", + }, +] + + +# --------------------------------------------------------------------------- +# _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.set_commit_status") + @patch("dismiss_and_invalidate.load_checklists", return_value=SAMPLE_CHECKLISTS) + def test_no_affected_checklists( + self, + mock_load, + mock_status, + monkeypatch, + ): + 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, repo, ".github/review_checklists.yml") + + mock_status.assert_not_called() + + @patch("dismiss_and_invalidate.set_commit_status") + @patch("dismiss_and_invalidate.load_checklists", return_value=SAMPLE_CHECKLISTS) + def test_deletes_ok_without_dismissing( + self, + mock_load, + mock_status, + monkeypatch, + ): + 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 + 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, repo, ".github/review_checklists.yml") + + ok_reply.delete.assert_called_once() + review.dismiss.assert_not_called() + mock_status.assert_called_once_with( + repo, + "newsha", + "pending", + "Checklist acknowledgements invalidated due to new changes", + ) + + +if __name__ == "__main__": + sys.exit(pytest.main(sys.argv[1:])) diff --git a/tools/review-checklists/scripts/helpers.py b/tools/review-checklists/scripts/helpers.py new file mode 100644 index 000000000..f16d5f231 --- /dev/null +++ b/tools/review-checklists/scripts/helpers.py @@ -0,0 +1,542 @@ +# ******************************************************************************* +# 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 os +from typing import Any + +import pathspec +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] + + 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): + 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. + + 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]: + """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_checklists = [] + 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: + if _file_matches_patterns(filepath, include_patterns): + if not _file_matches_patterns(filepath, exclude_patterns): + matched.add(filepath) + + if matched: + checklist_copy = dict(checklist) + checklist_copy["matched_files"] = sorted(matched) + relevant_checklists.append(checklist_copy) + return relevant_checklists + + +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) + 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 + checklist_id = body[start:end] + # Only keep top-level checklist comments (not replies). + if not getattr(comment, "in_reply_to_id", None): + result[checklist_id] = comment + return result + + +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, ...). + """ + 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_checklist_id: + continue + if (comment.body or "").strip().upper() != OK_KEYWORD: + continue + replies[comment_id_to_checklist_id[reply_to]].append(comment) + + return replies + + +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 { + checklist_id: [ + { + "reviewer": comment.user.login, + "acknowledged_at": comment.created_at.isoformat(), + } + for comment in comments + ] + for checklist_id, comments in replies.items() + } + + +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"): + # 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, + state: str, + description: str, + context: str = "review-checklists", +) -> 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}', 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_checklists: 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 checklist in relevant_checklists: + checklist_id = checklist["id"] + lines.append(f"### {checklist['name']} (`{checklist_id}`)") + lines.append("") + + acks = ack_details.get(checklist_id, []) + 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. + + 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", "") + 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/tools/review-checklists/scripts/helpers_test.py b/tools/review-checklists/scripts/helpers_test.py new file mode 100644 index 000000000..84b8ff2b8 --- /dev/null +++ b/tools/review-checklists/scripts/helpers_test.py @@ -0,0 +1,727 @@ +# ******************************************************************************* +# 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 sys +import types +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +from helpers import ( + CHECKLIST_MARKER, + MERGE_QUEUE_COMMENT_MARKER, + MERGE_QUEUE_NOTICE, + MERGE_QUEUE_NOTICE_END, + MERGE_QUEUE_NOTICE_START, + OK_KEYWORD, + _find_checklists_config, + 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, + 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_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): + checklist = [ + { + "id": "md-anywhere", + "name": "Markdown anywhere", + "include": ["*.md"], + "checklist": "- [ ] Reviewed", + } + ] + files = ["NOTE.md", "docs/NOTE.md", "docs/deep/NOTE.md"] + 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): + checklist = [ + { + "id": "md-root-only", + "name": "Markdown at root", + "include": ["/*.md"], + "checklist": "- [ ] Reviewed", + } + ] + files = ["NOTE.md", "docs/NOTE.md"] + result = match_checklists(checklist, 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) + 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): + 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): + checklist = SAMPLE_CHECKLISTS[0] + body = make_checklist_comment_body(checklist) + assert checklist["name"] in body + + def test_contains_checklist_content(self): + checklist = SAMPLE_CHECKLISTS[0] + body = make_checklist_comment_body(checklist) + assert "APIs documented" in body + + def test_contains_ok_instruction(self): + checklist = SAMPLE_CHECKLISTS[0] + body = make_checklist_comment_body(checklist) + assert OK_KEYWORD in body + + def test_contains_paths(self): + 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): + 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): + checklist = SAMPLE_CHECKLISTS[0] # api-review, no exclude + body = make_checklist_comment_body(checklist) + 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_for_checklists +# --------------------------------------------------------------------------- + + +class TestFindOkRepliesForChecklists: + 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_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"]: + 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_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(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, c2] + + 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(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, c2] + + result = find_ok_replies_for_checklists(pr, {"api-review": c1}, ["api-review"]) + assert result["api-review"] == [] + + +# --------------------------------------------------------------------------- +# 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 = f"User summary\n{MERGE_QUEUE_NOTICE_START}\ntampered\n{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/tools/review-checklists/scripts/post_checklists.py b/tools/review-checklists/scripts/post_checklists.py new file mode 100644 index 000000000..24db51bd4 --- /dev/null +++ b/tools/review-checklists/scripts/post_checklists.py @@ -0,0 +1,125 @@ +#!/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 ( + build_evidence_block, + collect_acknowledgement_details, + 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_checklists = match_checklists(checklists, changed_files) + + if not relevant_checklists: + 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 checklist in relevant_checklists: + 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 '{checklist['id']}'") + else: + 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 + # 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 = 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 '{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] + if posted_relevant_ids: + ack_details = collect_acknowledgement_details(pr, existing, posted_relevant_ids) + evidence_block = build_evidence_block(relevant_checklists, 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_checklists)} checklist(s) require reviewer acknowledgement", + ) + + print(f"Posted/updated {len(relevant_checklists)} checklist finding(s).") + + +if __name__ == "__main__": + main() diff --git a/tools/review-checklists/scripts/post_checklists_test.py b/tools/review-checklists/scripts/post_checklists_test.py new file mode 100644 index 000000000..9d72fa685 --- /dev/null +++ b/tools/review-checklists/scripts/post_checklists_test.py @@ -0,0 +1,198 @@ +# ******************************************************************************* +# 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_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", + "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:]))