1
0
Fork 0
mirror of https://github.com/nix-community/home-manager.git synced 2025-11-08 11:36:05 +01:00
home-manager/lib/python/manage-reviewers.py
Austin Horstman 1ed730a977 ci: manage reviewers doesn't re-add manually removed reviewers
We dont want to try re-adding people that get removed manually.
Otherwise you start fighting CI.

Signed-off-by: Austin Horstman <khaneliman12@gmail.com>
2025-07-29 10:21:56 -05:00

297 lines
11 KiB
Python
Executable file

#!/usr/bin/env python3
"""
Manage pull request reviewers for Home Manager.
This script handles the reviewer management logic from the tag-maintainers workflow,
including checking for manually requested reviewers and managing removals.
"""
import argparse
import json
import logging
import subprocess
import sys
from typing import Final
# Configure logging to output to stderr
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
stream=sys.stderr,
)
MANUAL_REVIEW_REQUEST_QUERY: Final[str] = """
query($owner: String!, $repo: String!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
timelineItems(last: 250, itemTypes: [REVIEW_REQUESTED_EVENT, REVIEW_REQUEST_REMOVED_EVENT]) {
nodes {
__typename
... on ReviewRequestedEvent {
actor {
__typename
login
}
requestedReviewer {
... on User { login }
... on Bot { login }
}
}
... on ReviewRequestRemovedEvent {
actor {
__typename
login
}
requestedReviewer {
... on User { login }
... on Bot { login }
}
}
}
}
}
}
}
"""
class GHError(Exception):
"""Custom exception for errors related to 'gh' CLI commands."""
pass
def run_gh_command(
args: list[str],
input_data: str | None = None,
check: bool = True,
) -> subprocess.CompletedProcess:
"""Runs a GitHub CLI command and returns the CompletedProcess object."""
command = ["gh"] + args
try:
result = subprocess.run(
command,
input=input_data,
capture_output=True,
text=True,
check=check,
)
return result
except subprocess.CalledProcessError as e:
logging.error("Error running command: %s", " ".join(command))
logging.error("Stderr: %s", e.stderr.strip())
raise GHError(f"Failed to execute gh command: {e}") from e
def get_manual_reviewer_actions(
owner: str, repo: str, pr_number: int, bot_user_name: str
) -> tuple[set[str], set[str]]:
"""Fetches sets of reviewers who were manually requested or removed by someone other than the bot.
Returns:
tuple: (manually_requested, manually_removed) sets of usernames
"""
try:
result = run_gh_command([
"api", "graphql",
"-f", f"query={MANUAL_REVIEW_REQUEST_QUERY}",
"-F", f"owner={owner}",
"-F", f"repo={repo}",
"-F", f"prNumber={pr_number}",
])
data = json.loads(result.stdout)
nodes = data.get("data", {}).get("repository", {}).get("pullRequest", {}).get("timelineItems", {}).get("nodes", [])
manually_requested = set()
manually_removed = set()
for node in nodes:
if not node or not node.get("requestedReviewer") or not node.get("actor"):
continue
reviewer_login = node["requestedReviewer"]["login"]
actor_login = node["actor"].get("login")
# Skip bot actions
if actor_login == bot_user_name:
continue
# Check node type to determine if it's a request or removal
if node.get("__typename") == "ReviewRequestedEvent":
manually_requested.add(reviewer_login)
elif node.get("__typename") == "ReviewRequestRemovedEvent":
manually_removed.add(reviewer_login)
return manually_requested, manually_removed
except (GHError, json.JSONDecodeError, KeyError) as e:
logging.error("Could not determine manual reviewer actions: %s", e)
return set(), set()
def get_users_from_gh(args: list[str], error_message: str) -> set[str]:
"""A generic helper to get a set of users from a 'gh' command."""
try:
result = run_gh_command(args)
return {user.strip() for user in result.stdout.split("\n") if user.strip()}
except GHError as e:
logging.error("%s: %s", error_message, e)
return set()
def get_pending_reviewers(pr_number: int) -> set[str]:
"""Gets the set of currently pending reviewers for a PR."""
return get_users_from_gh(
["pr", "view", str(pr_number), "--json", "reviewRequests", "--jq", ".reviewRequests[].login"],
"Error getting pending reviewers",
)
def get_past_reviewers(owner: str, repo: str, pr_number: int) -> set[str]:
"""Gets the set of users who have already reviewed the PR."""
return get_users_from_gh(
["api", f"repos/{owner}/{repo}/pulls/{pr_number}/reviews", "--jq", ".[].user.login"],
"Error getting past reviewers",
)
def is_collaborator(owner: str, repo: str, username: str) -> bool:
"""
Checks if a user is a collaborator on the repository.
Handles 404 as a non-collaborator, while other errors are raised.
"""
result = run_gh_command(
["api", f"repos/{owner}/{repo}/collaborators/{username}"],
check=False
)
if result.returncode == 0:
return True
if "HTTP 404" in result.stderr:
logging.error(
"'%s' is not a collaborator in this repository.", username
)
return False
else:
logging.error(
"Unexpected error checking collaborator status for '%s'.", username
)
logging.error("Stderr: %s", result.stderr.strip())
raise GHError(
f"Unexpected API error for user '{username}': {result.stderr.strip()}"
)
def update_reviewers(
pr_number: int,
reviewers_to_add: set[str] | None = None,
reviewers_to_remove: set[str] | None = None,
owner: str | None = None,
repo: str | None = None,
) -> None:
"""Adds or removes reviewers from a PR in a single operation per action."""
if reviewers_to_add:
logging.info("Requesting reviews from: %s", ", ".join(reviewers_to_add))
try:
run_gh_command([
"pr", "edit", str(pr_number),
"--add-reviewer", ",".join(reviewers_to_add)
])
except GHError as e:
logging.error("Failed to add reviewers: %s", e)
if reviewers_to_remove and owner and repo:
logging.info("Removing review requests from: %s", ", ".join(reviewers_to_remove))
payload = json.dumps({"reviewers": list(reviewers_to_remove)})
try:
run_gh_command(
[
"api", "--method", "DELETE",
f"repos/{owner}/{repo}/pulls/{pr_number}/requested_reviewers",
"--input", "-",
],
input_data=payload,
)
except GHError as e:
logging.error("Failed to remove reviewers: %s", e)
def main() -> None:
"""Main function to handle command-line arguments and manage reviewers."""
parser = argparse.ArgumentParser(description="Manage pull request reviewers for Home Manager.")
parser.add_argument("--owner", required=True, help="Repository owner.")
parser.add_argument("--repo", required=True, help="Repository name.")
parser.add_argument("--pr-number", type=int, required=True, help="Pull request number.")
parser.add_argument("--pr-author", required=True, help="PR author's username.")
parser.add_argument("--current-maintainers", default="", help="Space-separated list of maintainers for the changed files.")
parser.add_argument("--changed-files", default="", help="Newline-separated list of changed files.")
parser.add_argument("--bot-user-name", default="", help="Bot user name to distinguish manual vs automated review requests.")
parser.add_argument("--dry-run", action="store_true", help="Show what would be done without making actual changes.")
args = parser.parse_args()
no_changed_files = not args.changed_files.strip()
# --- 1. Fetch current state from GitHub ---
maintainers: set[str] = set(args.current_maintainers.split())
pending_reviewers = get_pending_reviewers(args.pr_number)
past_reviewers = get_past_reviewers(args.owner, args.repo, args.pr_number)
manually_requested, manually_removed = get_manual_reviewer_actions(args.owner, args.repo, args.pr_number, args.bot_user_name)
logging.info("File Maintainers: %s", ' '.join(maintainers) or "None")
logging.info("Pending Reviewers: %s", ' '.join(pending_reviewers) or "None")
logging.info("Past Reviewers: %s", ' '.join(past_reviewers) or "None")
logging.info("Manually Requested: %s", ' '.join(manually_requested) or "None")
logging.info("Manually Removed: %s", ' '.join(manually_removed) or "None")
# --- 2. Determine reviewers to remove ---
reviewers_to_remove: set[str] = set()
if no_changed_files:
reviewers_to_remove = pending_reviewers - manually_requested
logging.info("No module files changed. Removing bot-requested reviewers.")
else:
outdated_reviewers = pending_reviewers - maintainers
reviewers_to_remove = outdated_reviewers - manually_requested
logging.info("Removing outdated bot-requested reviewers.")
if reviewers_to_remove:
if args.dry_run:
logging.info("DRY RUN: Would remove reviewers: %s", ", ".join(reviewers_to_remove))
else:
update_reviewers(
args.pr_number,
owner=args.owner,
repo=args.repo,
reviewers_to_remove=reviewers_to_remove
)
else:
logging.info("No reviewers to remove.")
# --- 3. Determine new reviewers to add ---
reviewers_to_add: set[str] = set()
if not no_changed_files and maintainers:
users_to_exclude = {args.pr_author} | past_reviewers | pending_reviewers | manually_removed
potential_reviewers = maintainers - users_to_exclude
reviewers_to_add = {
user for user in potential_reviewers if is_collaborator(args.owner, args.repo, user)
}
non_collaborators = potential_reviewers - reviewers_to_add
if non_collaborators:
logging.warning("Ignoring non-collaborators: %s", ", ".join(non_collaborators))
manually_removed_maintainers = reviewers_to_add & manually_removed
if manually_removed_maintainers:
logging.info("Not re-adding manually removed maintainers: %s", ", ".join(manually_removed_maintainers))
reviewers_to_add -= manually_removed
if reviewers_to_add:
if args.dry_run:
logging.info("DRY RUN: Would add reviewers: %s", ", ".join(reviewers_to_add))
else:
update_reviewers(args.pr_number, reviewers_to_add=reviewers_to_add)
else:
logging.info("No new reviewers to add.")
if __name__ == "__main__":
main()