Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/prerelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ jobs:

publish:
name: Publish Python distribution to PyPI
needs: [test-wheels, test-sdists]
needs: [test-wheel, test-sdist]
runs-on: ubuntu-latest
environment:
name: pypi
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ jobs:
run: uv run --no-sync mypy
if: ${{ always() }}
- name: Run `deptry` for dependency hygiene
run: uv run --no-sync deptry .
run: uv run --no-sync deptry src/
if: ${{ always() }}
- name: Run `bandit` for code vulnerabilities
run: uv run --no-sync bandit .
Expand Down
7 changes: 2 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ dependencies = [
"scipy >=1.15.3, <2",
"scipy >=1.16.2, <2; python_version >= '3.14'",
"matplotlib >=3.10.9, <3.11",
"deltakit-stim>=0.2,<0.3",
"deltakit-stim>=0.2.1,<0.3",
"requests >=2.32, <3",
"tqdm >=4.66, <5",
"galois >=0.4.11,<=0.5",
Expand Down Expand Up @@ -196,7 +196,4 @@ Normalize = "Normalize"

[tool.typos.files]
# Paths/patterns to exclude from spell checking
extend-exclude = [
"**/LICENSE",
"**/*.html",
]
extend-exclude = ["**/LICENSE", "**/*.html"]
220 changes: 220 additions & 0 deletions tools/check_reviewers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""Check whether the required approvals have been met from REQUIRED_REVIEWERS to allow the PR to
merge. Returns exit code 0 if approvals are met, 1 on error, and 2 if approvals are not met."""

import fnmatch
import json
import os
import sys
from pathlib import Path
from typing import NamedTuple

import requests


class ReqReviewerRule(NamedTuple):
"""
A single rule from the ``REQUIRED_REVIEWERS`` file.

Attributes:
pattern: Glob pattern used to match file paths.
owners: Usernames of the reviewers required for matching files.
"""

pattern: str
owners: list[str]


def get_changed_files(repo: str, pr_number: int, token: str) -> list[str]:
"""
Get the filenames of the files changed in a pull request.

Args:
repo: Repository in ``owner/repository`` format.
pr_number: Pull request number.
token: GitHub API token.

Returns:
The list of changed file paths.
"""
files = requests.get(
f"https://api.github.com/repos/{repo}/pulls/{pr_number}/files",
headers={"Authorization": f"token {token}"},
).json()

return [f["filename"] for f in files]


def get_approved_reviewers(repo: str, pr_number: int, token: str) -> set[str]:
"""
Get the usernames of reviewers who approved the pull request.

Args:
repo: Repository in ``owner/repository`` format.
pr_number: Pull request number.
token: GitHub API token.

Returns:
The set of usernames of approving reviewers.
"""
reviews = requests.get(
f"https://api.github.com/repos/{repo}/pulls/{pr_number}/reviews",
headers={"Authorization": f"token {token}"},
).json()

return {r["user"]["login"] for r in reviews if r["state"] == "APPROVED"}


def parse_required_reviewers_file(path: Path) -> list[ReqReviewerRule]:
"""Parse a ``REQUIRED_REVIEWERS`` file.

Args:
path: Path to the ``REQUIRED_REVIEWERS`` file.

Returns:
The parsed reviewer rules.
"""
rules = []
with path.open("r") as f:
for line in f:
stripped_line = line.strip()
if not stripped_line or stripped_line.startswith("#"):
continue

parts = stripped_line.split()
pattern = parts[0]
owners = [o.lstrip("@") for o in parts[1:]]
rules.append(ReqReviewerRule(pattern=pattern, owners=owners))

return rules


def determine_required_reviewers(
rules: list[ReqReviewerRule], filenames: list[str], pr_author: str
) -> set[str]:
"""Determine the reviewers required for a pull request.

The last matching rule for each file overrides previous matches. The
pull request author is excluded from the required reviewers.

Args:
rules: Parsed reviewer rules.
filenames: Files changed in the pull request.
pr_author: Username of the pull request author.

Returns:
The set of required reviewers.
"""
required = set()

for file in filenames:
last_match_owners = set()

for rule in rules:
if fnmatch.fnmatch(file, rule.pattern):
last_match_owners = rule.owners

if last_match_owners:
required.update(last_match_owners)

required.discard(pr_author)
return required


def build_reviewer_comment(required: set[str], approved: set[str], is_draft: bool) -> str:
"""
Build the pull request reviewer status comment.

Args:
required: Required reviewers.
approved: Reviewers who have approved the pull request.
is_draft: Whether the pull request is a draft.

Returns:
The comment body to post on the pull request.
"""
required_list = ", ".join(sorted(required))
approved_list = ", ".join(sorted(approved)) or "_None_"

if is_draft:
return (
"πŸ“ **Draft PR - suggested reviewers**\n\n"
"At least one of the following must approve this PR once it leaves draft:\n\n"
f"{required_list}"
)

if bool(approved & required):
return """βœ… **Reviewer requirement satisfied**"""

return (
"❌ **Missing required reviewer approval**\n\n"
"At least one of the following must approve this PR:\n\n"
f"{required_list}\n\nCurrently approved by:\n\n{approved_list}"
)


def post_or_update_comment(
repo: str, pr_number: str, token: str, comment_marker: str, comment_body: str
) -> None:
"""Post a pull request comment or update an existing one.

A comment is considered to already exist if it contains the supplied
marker.

Args:
repo: Repository in ``owner/repository`` format.
pr_number: Pull request number.
token: GitHub API token.
comment_marker: Marker used to identify the managed comment.
comment_body: Comment body to post.
"""
headers = {"Authorization": f"token {token}"}

# Find existing bot comment
comments = requests.get(
f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments", headers=headers
).json()

existing = None
for c in comments:
if c["user"]["type"] == "Bot" and comment_marker in c["body"]:
existing = c
break

# Create or update comment
body_with_marker = comment_marker + "\n" + comment_body
if existing:
requests.patch(existing["url"], headers=headers, json={"body": body_with_marker})
else:
requests.post(
f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments",
headers=headers,
json={"body": body_with_marker},
)


if __name__ == "__main__":
repo = os.environ["GITHUB_REPOSITORY"]
token = os.environ["GITHUB_TOKEN"]
is_draft = os.environ["PR_IS_DRAFT"] == "true"

with Path(os.environ["GITHUB_EVENT_PATH"]).open("r") as f:
event = json.load(f)
pr_number = event["pull_request"]["number"]
pr_author = event["pull_request"]["user"]["login"]

changed_files = get_changed_files(repo, pr_number, token)
approved_reviewers = get_approved_reviewers(repo, pr_number, token)

rules = parse_required_reviewers_file(Path(".github/REQUIRED_REVIEWERS"))
required_reviewers = determine_required_reviewers(rules, changed_files, pr_author)

marker = "<!-- required-reviewers-check -->"
comment_body = build_reviewer_comment(required_reviewers, approved_reviewers, is_draft)
post_or_update_comment(repo, pr_number, token, marker, comment_body)

if bool(approved_reviewers & required_reviewers):
print("Valid approval found")
sys.exit(0)
else:
print("Missing required approval")
sys.exit(2)
70 changes: 70 additions & 0 deletions tools/check_version_match.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""
Validates package version matches latest remote tag.
"""

import argparse
import logging
import sys

from tools.utils import extract_version

# logging
stream_handler = logging.StreamHandler()
logger = logging.Logger(__name__)
logger.addHandler(stream_handler)


def parse_version(v: str) -> tuple:
"""
Parse a semantic version string.

Args:
v: Semantic version string in the format MAJOR.MINOR.PATCH.

Returns:
A tuple containing the major, minor, and patch version numbers.

Raises:
argparse.ArgumentTypeError: If v is not in the format
MAJOR.MINOR.PATCH or contains non-integer components.
"""
try:
return tuple(map(int, v.split(".")))
except ValueError as err:
msg = f"Invalid semver format: '{v}' (expected MAJOR.MINOR.PATCH)"
raise argparse.ArgumentTypeError(msg) from err


def main():
desc = "Check that the project version is higher than the provided version."
parser = argparse.ArgumentParser(description=desc)
parser.add_argument(
"version",
type=parse_version,
help="Version to compare against (format: MAJOR.MINOR.PATCH)",
)

args = parser.parse_args()
tag_version_tup = args.version
tag_version = ".".join(map(str, tag_version_tup))

proj_version = extract_version()
proj_version_tup = parse_version(proj_version)

if proj_version_tup != tag_version_tup:
log_msg = (
f"Project version {proj_version} do not match "
f"latest tag version {tag_version_tup}."
)
logger.error(log_msg)
sys.exit(1)

logger.info(
"Project version %s do not match latest tag version %s",
proj_version,
tag_version,
)


if __name__ == "__main__":
main()
63 changes: 63 additions & 0 deletions tools/set_pre_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""
Script to set prerelease version number in all `pyproject.toml`s.
Usage: `python tools/set_pre_version.py <suffix>`
e.g. `python tools/set_pre_version.py -s .dev20250820160500`
"""

import argparse
import logging
from pathlib import Path
from packaging.version import Version


import tomlkit

# logging
stream_handler = logging.StreamHandler()
logger = logging.Logger(__name__)
logger.addHandler(stream_handler)


PROJ_HOME = Path(__file__).parents[1]

if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Append prerelease suffix to the base version."
)
parser.add_argument(
"-t",
"--timestamp",
help=(
"Prerelease version timestamp suffix in seconds."
),
)
parser.add_argument(
"-c",
"--commit",
help=(
"Prerelease version short commit hash suffix."
),
)
args = parser.parse_args()
timestamp_version_suffix = args.timestamp
commit_version_suffix = args.commit

# Update project version with suffix
path = PROJ_HOME / "pyproject.toml"

# Update file data
with path.open("r") as f:
data: dict = tomlkit.load(f)

version = Version(data["project"]["version"])
prerelease_version = f"{version.major}.{version.minor}.{version.micro + 1}"
data["project"]["version"] = (
prerelease_version +
f".dev{timestamp_version_suffix}+g{commit_version_suffix}"
)

# Write updated data to file
with path.open("w", encoding="utf-8") as f:
tomlkit.dump(data, f)

logger.info("Project successfully updated")
Loading
Loading