blob: 53f05668591074389e48e52791758cab945e2693 [file]
#!/usr/bin/env python3
#
# Copyright (c) 2026 Project CHIP Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import json
import logging
import os
import urllib.request
from collections.abc import Iterable
from enum import Enum
from typing import NamedTuple
import click
import coloredlogs
import pathspec
import yaml
from github import Github, GithubException
from github.Commit import Commit
from github.PullRequest import PullRequest
log = logging.getLogger(__name__)
_LOG_LEVELS = logging.getLevelNamesMapping()
DEFAULT_REPOSITORY = "project-chip/connectedhomeip"
DEFAULT_CONFIG_PATH = ".github/platform_maintainers.yaml"
ELIGIBILITY_COMMENT_MARKER = "<!-- pr-checker-bot-eligibility-marker -->"
class ValidationCheck(Enum):
CI = "ci"
PULLAPPROVE = "pullapprove"
COMMENTS = "comments"
class GroupApproval(NamedTuple):
"""Represents an approval for a platform group."""
approver: str
files: set[str]
class PlatformGroup:
"""Represents a platform group configuration."""
def __init__(self, name: str, maintainers: list[str], paths: list[str]) -> None:
self.name = name
self.maintainers = sorted({m.strip().lower() for m in maintainers})
self.paths = sorted({p.strip() for p in paths})
self.spec = pathspec.PathSpec.from_lines("gitignore", self.paths)
self.path_specs = {
glob: pathspec.PathSpec.from_lines("gitignore", [glob])
for glob in self.paths
}
def matches_file(self, filepath: str) -> bool:
"""Checks if a file path matches this group's configured paths."""
return self.spec.match_file(filepath)
def get_matched_globs(self, files: Iterable[str]) -> list[str]:
"""Returns the list of glob patterns in this group that match any of the given files."""
matched_globs = set()
for f in files:
for glob, spec in self.path_specs.items():
if spec.match_file(f):
matched_globs.add(glob)
return sorted(matched_globs)
class PrCheckerBot:
"""Orchestrates scanning, checking coverage, and auto-merging PRs that affect platform-maintained paths."""
def __init__(
self,
token: str,
repo_name: str,
config_path: str,
dry_run: bool,
skip_checks: list[str] | None = None,
) -> None:
self.token = token
self.repo_name = repo_name
self.api = Github(token)
self.repo = self.api.get_repo(repo_name)
self.config_path = config_path
self.dry_run = dry_run
self.skip_checks = {ValidationCheck(c) for c in (skip_checks or [])}
self.single_pr_mode = False
self.groups: dict[str, PlatformGroup] = {}
self._bot_username = None
self.load_config()
@property
def bot_username(self) -> str:
"""Retrieves the username of the authenticated bot, falling back to a default on failure."""
if self._bot_username is None:
try:
self._bot_username = self.api.get_user().login.lower()
except GithubException:
self._bot_username = "pr-checker-bot"
return self._bot_username
def load_config(self) -> None:
"""Loads and validates the platform groups configuration from the YAML file."""
if not os.path.exists(self.config_path):
raise FileNotFoundError(f"Config file not found at {self.config_path}")
with open(self.config_path, encoding="utf-8") as f:
content = yaml.safe_load(f)
if not isinstance(content, dict):
raise ValueError(
"Invalid config file format. Expected a YAML dictionary of groups."
)
for name, data in content.items():
if not isinstance(name, str):
raise ValueError(f"Group name '{name}' must be a string.")
if not isinstance(data, dict):
raise ValueError(
f"Invalid data format for group '{name}'. Expected a dictionary."
)
invalid_keys = set(data.keys()) - {"maintainers", "paths"}
if invalid_keys:
raise ValueError(
f"Group '{name}' contains unrecognized keys: {list(invalid_keys)}"
)
maintainers = data.get("maintainers")
paths = data.get("paths")
if not isinstance(maintainers, list) or not maintainers:
raise ValueError(
f"Group '{name}' must contain a non-empty 'maintainers' list."
)
if not isinstance(paths, list) or not paths:
raise ValueError(
f"Group '{name}' must contain a non-empty 'paths' list."
)
if not all(isinstance(m, str) and m.strip() for m in maintainers):
raise ValueError(
f"Group '{name}' maintainers must be non-empty strings."
)
if not all(isinstance(p, str) and p.strip() for p in paths):
raise ValueError(f"Group '{name}' paths must be non-empty strings.")
self.groups[name] = PlatformGroup(name, maintainers, paths)
log.info("Loaded %d platform groups from config.", len(self.groups))
def get_pr_review_states(self, pr: PullRequest) -> tuple[set[str], set[str]]:
"""Returns the set of users who have currently approved and those who have requested changes."""
user_reviews = {}
# get_reviews() returns reviews chronologically
for review in pr.get_reviews():
review_user = getattr(review.user, "login", None)
if not review_user:
continue
user = review_user.lower()
if review.state in ("APPROVED", "CHANGES_REQUESTED", "DISMISSED"):
user_reviews[user] = review.state
approvers = {
user for user, state in user_reviews.items() if state == "APPROVED"
}
# Exclude author from self-approval
pr_author = getattr(pr.user, "login", None)
author = pr_author.lower() if pr_author else ""
approvers.discard(author)
change_requesters = {
user for user, state in user_reviews.items() if state == "CHANGES_REQUESTED"
}
return approvers, change_requesters
def analyze_pr_files(self, pr: PullRequest) -> tuple[dict[str, set[str]], set[str]]:
"""Analyzes the files changed in the PR.
Returns:
- matched_files_per_group: Map of group_name -> set of files matching it.
- uncovered_files: Set of files that matched no group.
"""
matched_files_per_group: dict[str, set[str]] = {
name: set() for name in self.groups
}
uncovered_files = set()
# pr.get_files() returns PaginatedList of File objects
for pr_file in pr.get_files():
filepaths_to_check = [pr_file.filename]
prev_filepath = getattr(pr_file, "previous_filename", None)
if prev_filepath:
filepaths_to_check.append(prev_filepath)
for filepath in filepaths_to_check:
matched_any = False
for group_name, group in self.groups.items():
if group.matches_file(filepath):
matched_files_per_group[group_name].add(filepath)
matched_any = True
if not matched_any:
uncovered_files.add(filepath)
return matched_files_per_group, uncovered_files
def check_and_process_pr(self, pr: PullRequest) -> None:
"""Checks the coverage and approvals for a single PR, and merges if eligible."""
if pr.user is None or not getattr(pr.user, "login", None):
log.info(
"PR #%d has no valid author (deleted account). Skipping.",
pr.number,
)
return
pr_author = pr.user.login
log.info("Checking PR #%d: '%s' (Author: %s)", pr.number, pr.title, pr_author)
if pr.state != "open":
if self.dry_run and self.single_pr_mode:
log.info(
"PR #%d state is '%s' but bypassing open check for testing in dry-run.",
pr.number,
pr.state,
)
else:
log.info(
"PR #%d is not open (state: '%s'). Skipping.",
pr.number,
pr.state,
)
return
if pr.draft:
if self.dry_run and self.single_pr_mode:
log.info(
"PR #%d is draft but bypassing draft check for testing in dry-run.",
pr.number,
)
else:
log.info("PR #%d is a draft. Skipping.", pr.number)
return
# Perform file analysis first (saves API calls for ineligible PRs)
matched_files, uncovered_files = self.analyze_pr_files(pr)
if uncovered_files:
log.info(
"PR #%d contains files outside the platform-maintained scope. Skipping. Uncovered files: %s",
pr.number,
list(uncovered_files)[:5],
)
self.remove_eligibility_comment(pr)
return
# Determine which groups are active (i.e. have changed files)
active_groups = {name: files for name, files in matched_files.items() if files}
if not active_groups:
log.info("PR #%d has no changed files? Skipping.", pr.number)
self.remove_eligibility_comment(pr)
return
log.info(
"PR #%d is fully covered by platform groups: %s",
pr.number,
list(active_groups.keys()),
)
# Get the commit object once for subsequent status/CI checks
commit = self.repo.get_commit(sha=pr.head.sha)
if (
ValidationCheck.PULLAPPROVE not in self.skip_checks
and self._is_pullapprove_green(commit)
):
log.info(
"PR #%d has a successful pullapprove check. Skipping bot merge (standard flow applies).",
pr.number,
)
self.remove_eligibility_comment(pr)
return
# Get current approvals and change requests
approvers, change_requesters = self.get_pr_review_states(pr)
log.debug(
"PR #%d current approvers: %s, change requesters: %s",
pr.number,
list(approvers),
list(change_requesters),
)
if change_requesters:
log.info(
"PR #%d has active changes requested by: %s. Skipping.",
pr.number,
list(change_requesters),
)
return
# Check approvals for each active group
missing_approvals: dict[str, PlatformGroup] = {}
valid_approvals_per_group: dict[str, GroupApproval] = {}
for group_name, files in active_groups.items():
group = self.groups[group_name]
group_approvers = approvers.intersection(group.maintainers)
if not group_approvers:
missing_approvals[group_name] = group
else:
# Pick the first matched approver for documentation
valid_approvals_per_group[group_name] = GroupApproval(
sorted(group_approvers)[0], files
)
unresolved_threads = []
if ValidationCheck.COMMENTS not in self.skip_checks:
unresolved_threads = self._get_unresolved_threads(pr)
ci_passed = True
if ValidationCheck.CI not in self.skip_checks:
ci_passed = self._has_ci_passed(pr, commit)
is_mergeable = pr.mergeable
is_ready = (
not missing_approvals
and not unresolved_threads
and ci_passed
and is_mergeable is True
)
if not is_ready:
log.info(
"PR #%d is eligible but not ready to merge. Updating status comment.",
pr.number,
)
self.post_eligibility_comment(
pr,
active_groups,
missing_approvals,
unresolved_threads,
ci_passed,
is_mergeable,
)
return
log.info(
"PR #%d is fully approved, CI passed, no unresolved comments, and ready to merge!",
pr.number,
)
self.merge_pr(pr, valid_approvals_per_group)
def _is_pullapprove_green(self, commit: Commit) -> bool:
"""Returns True if the pullapprove check exists and is in the 'success' state."""
combined_status = commit.get_combined_status()
for status in combined_status.statuses:
if status.context == "pullapprove":
return status.state == "success"
return False
def _get_unresolved_threads(self, pr: PullRequest) -> list[dict]:
"""Queries GitHub GraphQL API to find active unresolved review threads on the PR."""
owner, repo_name = self.repo_name.split("/")
query = """
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
reviewThreads(first: 100) {
pageInfo {
hasNextPage
}
nodes {
isResolved
comments(first: 1) {
nodes {
author { login }
body
url
}
}
}
}
}
}
}
"""
variables = {"owner": owner, "name": repo_name, "number": pr.number}
req = urllib.request.Request(
"https://api.github.com/graphql",
data=json.dumps({"query": query, "variables": variables}).encode("utf-8"),
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
"User-Agent": "pr-checker-bot",
},
method="POST",
)
unresolved = []
try:
with urllib.request.urlopen(req, timeout=30) as response:
res_data = json.loads(response.read().decode("utf-8"))
if "errors" in res_data:
raise RuntimeError(
f"GraphQL API returned errors: {res_data['errors']}"
)
data = res_data.get("data")
if (
not data
or not data.get("repository")
or not data["repository"].get("pullRequest")
):
raise RuntimeError(
f"GraphQL response missing PR repository/pullRequest data: {res_data}"
)
threads_data = data["repository"]["pullRequest"]["reviewThreads"]
if threads_data["pageInfo"]["hasNextPage"]:
log.warning(
"PR #%d has more than 100 review threads. Gating merge to be safe.",
pr.number,
)
unresolved.append(
{
"author": "system",
"body_preview": "Too many review threads (>100). Please resolve or clean up threads.",
"url": pr.html_url + "/files",
}
)
threads = threads_data["nodes"]
for thread in threads:
if not thread["isResolved"]:
first_comment = (
thread["comments"]["nodes"][0]
if thread["comments"]["nodes"]
else None
)
author = (
first_comment["author"]["login"]
if first_comment and first_comment["author"]
else "unknown"
)
url = first_comment["url"] if first_comment else ""
body_preview = ""
if first_comment and first_comment.get("body"):
body_preview = " ".join(
first_comment["body"].split()
)
if len(body_preview) > 40:
body_preview = body_preview[:37] + "..."
unresolved.append(
{
"author": author,
"body_preview": body_preview,
"url": url,
}
)
except Exception as e:
log.error(
"Failed to query unresolved threads for PR #%d: %s",
pr.number,
e,
)
raise
return unresolved
def _has_ci_passed(self, pr: PullRequest, commit: Commit) -> bool:
"""Checks if all CI checks (combined status and check runs) have passed on the PR's latest commit."""
combined_status = commit.get_combined_status()
check_suites = list(commit.get_check_suites())
# Guard against empty checks / premature success when commit is fresh.
# A normal PR run has at least 10 combined statuses and check suites.
total_checks = len(combined_status.statuses) + len(check_suites)
if total_checks < 10:
log.info(
"PR #%d HEAD commit %s has only %d CI checks registered (expected >= 10). Treating as pending.",
pr.number,
commit.sha[:8],
total_checks,
)
return False
# pullapprove is ignored because it delegates normal PR approvals. Since this
# bot bypasses standard reviews for platform-restricted changes, PullApprove
# will remain pending forever.
ignored_contexts = {"pullapprove"}
for status in combined_status.statuses:
if status.context in ignored_contexts:
continue
if status.state != "success":
log.info(
"PR #%d HEAD commit %s status '%s' is '%s' (%s)",
pr.number,
pr.head.sha[:8],
status.context,
status.state,
status.description,
)
return False
for suite in check_suites:
if suite.status != "completed":
log.info(
"PR #%d HEAD commit %s check suite '%s' is not completed (status: '%s')",
pr.number,
pr.head.sha[:8],
suite.id,
suite.status,
)
return False
if suite.conclusion not in ("success", "neutral", "skipped"):
log.info(
"PR #%d HEAD commit %s check suite '%s' failed (conclusion: '%s')",
pr.number,
pr.head.sha[:8],
suite.id,
suite.conclusion,
)
return False
return True
def _find_bot_comments(self, pr: PullRequest) -> list:
"""Finds comments posted by this bot on the PR."""
bot_comments = []
for comment in pr.get_issue_comments():
comment_user = getattr(comment.user, "login", None)
if comment_user and comment_user.lower() == self.bot_username:
if comment.body and ELIGIBILITY_COMMENT_MARKER in comment.body:
bot_comments.append(comment)
return bot_comments
def post_eligibility_comment(
self,
pr: PullRequest,
active_groups: dict[str, set[str]],
missing_approvals: dict[str, PlatformGroup],
unresolved_threads: list[dict],
ci_passed: bool,
mergeable: bool | None,
) -> None:
"""Posts or updates a comment stating the auto-merge status of the PR."""
# Generate comment body first so we can compare it
comment_body = f"{ELIGIBILITY_COMMENT_MARKER}\n"
comment_body += "### Platform Maintainers Auto-Merge Info\n"
comment_body += "This PR is restricted to platform-maintained paths and is eligible for auto-merging upon approval from the designated maintainers.\n\n"
comment_body += (
"To merge, we require at least one approval from each of these groups:\n"
)
for group_name, files in active_groups.items():
group = self.groups[group_name]
maintainer_mentions = ", ".join([f"@{m}" for m in group.maintainers])
status = (
"❌ Needs approval"
if group_name in missing_approvals
else "✅ Approved"
)
comment_body += f"- **{group_name}**: {maintainer_mentions} ({status})\n"
comment_body += " *Paths matched:*\n"
for glob in group.get_matched_globs(files):
comment_body += f" * `{glob}`\n"
comment_body += "\n### Merge Requirements Status\n"
if not missing_approvals and not unresolved_threads and ci_passed and mergeable is True:
comment_body += "✅ **All checks passed. PR is ready for merge.**\n"
else:
comment_body += "⚠️ **PR is not ready to merge yet:**\n"
if missing_approvals:
comment_body += "- ❌ Needs platform maintainer approvals (see above).\n"
else:
comment_body += "- ✅ Has all platform maintainer approvals.\n"
if unresolved_threads:
comment_body += "- ❌ Has unresolved review conversations:\n"
for thread in unresolved_threads:
link_part = f" ([Link]({thread['url']}))" if thread["url"] else ""
comment_preview = f': *"{thread["body_preview"]}"*' if thread["body_preview"] else ""
comment_body += f" * Unresolved thread by @{thread['author']}{link_part}{comment_preview}\n"
else:
comment_body += "- ✅ All review conversations resolved.\n"
if ci_passed:
comment_body += "- ✅ All CI status and check suites passed.\n"
else:
comment_body += "- ❌ CI checks are pending or failed.\n"
if mergeable is True:
comment_body += "- ✅ No merge conflicts.\n"
elif mergeable is False:
comment_body += "- ❌ PR has merge conflicts (resolve conflicts before merge).\n"
else:
comment_body += "- ⚠️ Mergeability state is computing on GitHub.\n"
bot_comments = self._find_bot_comments(pr)
if bot_comments:
main_comment = bot_comments[0]
if main_comment.body.strip() != comment_body.strip():
if self.dry_run:
log.info(
"[Dry Run] Would update eligibility comment on PR #%d",
pr.number,
)
else:
log.info("Updating eligibility comment on PR #%d", pr.number)
main_comment.edit(comment_body)
else:
log.debug(
"PR #%d already has an up-to-date eligibility comment.",
pr.number,
)
for duplicate in bot_comments[1:]:
if self.dry_run:
log.info(
"[Dry Run] Would delete duplicate eligibility comment on PR #%d",
pr.number,
)
else:
log.info(
"Deleting duplicate eligibility comment on PR #%d",
pr.number,
)
try:
duplicate.delete()
except GithubException as e:
log.error(
"Failed to delete duplicate comment #%d: %s",
duplicate.id,
e,
)
else:
if self.dry_run:
log.info(
"[Dry Run] Would post eligibility comment to PR #%d:\n%s",
pr.number,
comment_body,
)
else:
log.info("Posting eligibility comment to PR #%d", pr.number)
pr.create_issue_comment(comment_body)
def remove_eligibility_comment(self, pr: PullRequest) -> None:
"""Removes the eligibility comment if it exists on the PR."""
if pr.comments == 0:
return
for comment in self._find_bot_comments(pr):
if self.dry_run:
log.info(
"[Dry Run] Would delete stale eligibility comment on PR #%d",
pr.number,
)
else:
log.info(
"Deleting stale eligibility comment on PR #%d",
pr.number,
)
try:
comment.delete()
except GithubException as e:
log.error("Failed to delete stale comment #%d: %s", comment.id, e)
def merge_pr(
self, pr: PullRequest, valid_approvals_per_group: dict[str, GroupApproval]
) -> None:
"""Merges the PR and posts a comment explaining the approvals."""
# Generate merge comment explaining reasons
merge_reason_comment = "### Platform Maintainers Auto-Merge Executed\n"
merge_reason_comment += "This PR has been automatically merged. It contains changes restricted to platform-maintained paths and received the required maintainer approvals:\n\n"
for group_name, approval in valid_approvals_per_group.items():
group = self.groups[group_name]
matched_globs = group.get_matched_globs(approval.files)
globs_str = ", ".join([f"`{g}`" for g in matched_globs])
merge_reason_comment += f"- **{group_name}** changes (matching {globs_str}) approved by @{approval.approver}\n"
if self.dry_run:
log.info(
"[Dry Run] Would post merge comment to PR #%d:\n%s",
pr.number,
merge_reason_comment,
)
log.info("[Dry Run] Would merge PR #%d (method: squash)", pr.number)
else:
log.info("Merging PR #%d", pr.number)
# Use squash merge
pr.merge(
merge_method="squash",
commit_title=f"{pr.title} (Auto-merged by platform-bot)",
sha=pr.head.sha,
)
log.info("Posting merge explanation comment to PR #%d", pr.number)
try:
pr.create_issue_comment(merge_reason_comment)
except GithubException as e:
log.error(
"Failed to post merge explanation comment to PR #%d: %s",
pr.number,
e,
)
def run(self, pr_number: int | None = None) -> None:
"""Runs the bot, either scanning all open PRs or processing a single PR."""
self.single_pr_mode = pr_number is not None
has_errors = False
if self.single_pr_mode:
log.info("Processing single PR #%d...", pr_number)
try:
pr = self.repo.get_pull(pr_number)
self.check_and_process_pr(pr)
except Exception as e:
log.exception("Error processing PR #%d: %s", pr_number, e)
has_errors = True
else:
log.info("Scanning open pull requests...")
open_prs = self.repo.get_pulls(state="open")
for pr in open_prs:
try:
self.check_and_process_pr(pr)
except Exception as e:
log.exception("Error processing PR #%d: %s", pr.number, e)
has_errors = True
if has_errors:
raise RuntimeError("One or more PRs encountered errors during processing.")
@click.command()
@click.option(
"--log-level",
default="INFO",
type=click.Choice(list(_LOG_LEVELS.keys()), case_sensitive=False),
help="Determines the verbosity of script output.",
)
@click.option(
"--token-env",
default="GH_TOKEN",
help="Environment variable containing the GitHub token",
)
@click.option(
"--token-file",
type=click.Path(exists=True, dir_okay=False, readable=True),
help="Read github token from the given file",
)
@click.option(
"--repo",
default=DEFAULT_REPOSITORY,
help=f"Github repository name (default: {DEFAULT_REPOSITORY})",
)
@click.option(
"--config",
default=DEFAULT_CONFIG_PATH,
help=f"Path to the platform maintainers yaml config (default: {DEFAULT_CONFIG_PATH})",
)
@click.option(
"--dry-run",
default=False,
is_flag=True,
help="Simulate merging and commenting without executing",
)
@click.option(
"--pr",
type=int,
help="Process only this specific pull request number.",
)
@click.option(
"--skip-check",
multiple=True,
type=click.Choice(["ci", "pullapprove", "comments"]),
help="Validation check to skip. Can be specified multiple times.",
)
def main(
log_level: str,
token_env: str,
token_file: str | None,
repo: str,
config: str,
dry_run: bool,
pr: int | None,
skip_check: tuple[str, ...],
) -> None:
"""Platform Merge Bot entry point.
Example Dry-Run and Validation Testing:
---------------------------------------
# Run the bot in dry-run mode on a specific PR (even if closed/merged) to see what it would do:
GITHUB_TOKEN=$(gh auth token) python3 scripts/tools/pr_checker_bot.py --dry-run --pr 72779
# Run in dry-run mode, skipping CI and PullApprove status validations:
GITHUB_TOKEN=$(gh auth token) python3 scripts/tools/pr_checker_bot.py --dry-run --pr 72779 --skip-check ci --skip-check pullapprove
"""
coloredlogs.install(
level=_LOG_LEVELS[log_level.upper()],
fmt="%(asctime)s %(levelname)-7s %(message)s",
)
gh_token = None
if token_file:
with open(token_file, encoding="utf-8") as f:
gh_token = f.read().strip()
if not gh_token:
raise click.ClickException(f"Token file {token_file} is empty")
else:
gh_token = os.environ.get(token_env) or os.environ.get("GITHUB_TOKEN")
if not gh_token:
raise click.ClickException(
f"Require a token. Set environment variable '{token_env}' (or 'GITHUB_TOKEN') or provide --token-file"
)
bot = PrCheckerBot(
gh_token, repo, config, dry_run, skip_checks=list(skip_check)
)
bot.run(pr_number=pr)
if __name__ == "__main__":
main()