blob: 69c6328173d13ba86d54e8f7c163e19eaceb46e1 [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.
#
"""SME (Subject Matter Expert) Label Reviewer Checker for Pull Requests.
Verifies that for PRs with designated labels attached, at least one designated
SME username from any of the matching labels' lists has approved the PR.
Uses the GitHub CLI (`gh`) to fetch PR reviews and labels.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import subprocess
import sys
from dataclasses import dataclass, field
from typing import Any
import yaml
DEFAULT_REPO = "project-chip/connectedhomeip"
DEFAULT_CONFIG_PATH = ".github/label_reviewers.yaml"
DEFAULT_OVERRIDE_LABELS = ("no-sme-check-required", "sdk-maintainer-approved")
DEFAULT_OVERRIDE_LABEL = DEFAULT_OVERRIDE_LABELS[0]
LOGGER = logging.getLogger(__name__)
class UniqueKeySafeLoader(yaml.SafeLoader):
"""YAML SafeLoader that raises ValueError on duplicate mapping keys."""
def _construct_mapping(
loader: yaml.SafeLoader, node: yaml.MappingNode, deep: bool = False
) -> dict[Any, Any]:
"""Constructs a YAML mapping while rejecting duplicate keys."""
mapping: dict[Any, Any] = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
if key in mapping:
raise ValueError(f"Duplicate YAML key detected: {key!r}")
mapping[key] = loader.construct_object(value_node, deep=deep)
return mapping
UniqueKeySafeLoader.add_constructor(
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping
)
@dataclass
class LabelRule:
"""Represents a configured label and its designated SME reviewers."""
name: str
smes: list[str]
@dataclass
class LabelEvaluation:
"""Represents the evaluation status of a single label on a pull request."""
rule: LabelRule
present_on_pr: bool
approvers: list[str] = field(default_factory=list)
@property
def satisfied(self) -> bool:
"""Returns True if the label is either not on the PR or has at least one SME approver."""
return not self.present_on_pr or len(self.approvers) > 0
def fetch_pull_request_data(repo: str, pr_number: int) -> dict[str, Any]:
"""Fetches pull request details and reviews using the gh CLI."""
cmd = [
"gh",
"pr",
"view",
str(pr_number),
"--repo",
repo,
"--json",
"author,title,state,labels,reviews",
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, check=True)
return json.loads(proc.stdout)
except subprocess.CalledProcessError as e:
raise RuntimeError(
f"gh pr view failed (exit code {e.returncode}): {e.stderr.strip()}"
) from e
except json.JSONDecodeError as e:
raise RuntimeError(f"Failed to parse gh output as JSON: {e}") from e
def parse_label_config(config_path: str) -> dict[str, LabelRule]:
"""Parses the YAML configuration file mapping labels to SME reviewers.
Expected format is strictly a mapping of label names to lists of usernames:
label_name:
- username1
- username2
"""
if not os.path.exists(config_path):
raise FileNotFoundError(f"Configuration file not found: {config_path}")
try:
with open(config_path, encoding="utf-8") as f:
content = yaml.load(f, Loader=UniqueKeySafeLoader)
if content is None:
content = {}
except yaml.YAMLError as e:
raise ValueError(f"YAML syntax error in {config_path}: {e}") from e
if not isinstance(content, dict):
raise ValueError(
f"Invalid format in {config_path}: expected a YAML mapping of label names to reviewer lists."
)
mapping: dict[str, LabelRule] = {}
for label_raw, val in content.items():
if not isinstance(label_raw, str) or not label_raw.strip():
raise ValueError(
f"Invalid label key {label_raw!r} in {config_path}: expected a non-empty string."
)
label_name = label_raw.strip()
label_key = label_name.lower()
if label_key in mapping:
raise ValueError(
f"Duplicate normalized label key '{label_name}' in {config_path} "
f"(conflicts with '{mapping[label_key].name}')."
)
if not isinstance(val, list):
raise ValueError(
f"Invalid format for label '{label_name}' in {config_path}: "
f"expected a list of usernames, got {type(val).__name__}."
)
smes: list[str] = []
for u in val:
if not isinstance(u, str) or not u.strip():
raise ValueError(
f"Invalid reviewer entry under label '{label_name}' in {config_path}: "
f"expected a username string, got {u!r}."
)
clean_name = u.strip()
if clean_name.startswith("@"):
raise ValueError(
f"Invalid reviewer '{clean_name}' under label '{label_name}' in {config_path}: "
f"do not include '@' in usernames."
)
smes.append(clean_name)
if not smes:
raise ValueError(
f"Label '{label_name}' in {config_path} must have at least one reviewer listed."
)
mapping[label_key] = LabelRule(
name=label_name,
smes=smes,
)
return mapping
def extract_approvers(pr_data: dict[str, Any]) -> set[str]:
"""Extracts lowercase usernames of approved reviewers from PR JSON data, excluding the author."""
pr_author = (pr_data.get("author") or {}).get("login", "")
author_lower = pr_author.lower() if pr_author else ""
raw_reviews = pr_data.get("reviews")
if raw_reviews is None:
raw_reviews = pr_data.get("latestReviews", [])
reviews = sorted(
raw_reviews,
key=lambda review: review.get("submittedAt") or "",
)
latest_states: dict[str, str] = {}
for review in reviews:
reviewer = (review.get("author") or {}).get("login", "").lower()
state = review.get("state")
if reviewer and state not in {"COMMENTED", "PENDING"}:
latest_states[reviewer] = state
return {
reviewer
for reviewer, state in latest_states.items()
if state == "APPROVED" and reviewer != author_lower
}
def evaluate_pr_labels(
pr_labels: list[str],
config_mapping: dict[str, LabelRule],
approvers: set[str],
) -> list[LabelEvaluation]:
"""Evaluates each matching label attached to the PR against active approvers."""
pr_label_keys = {
label.strip().lower()
for label in pr_labels
if label and label.strip()
}
evaluations: list[LabelEvaluation] = []
for key, rule in config_mapping.items():
if key in pr_label_keys:
matching_approvers = [u for u in rule.smes if u.lower() in approvers]
evaluations.append(
LabelEvaluation(
rule=rule,
present_on_pr=True,
approvers=matching_approvers,
)
)
return evaluations
def find_active_override(
pr_labels: list[str],
override_labels: str | list[str] | tuple[str, ...] = DEFAULT_OVERRIDE_LABELS,
) -> str | None:
"""Returns the matching override label name if present on the PR, or None."""
if isinstance(override_labels, str):
target_list = [override_labels]
else:
target_list = list(override_labels)
clean_pr_labels = {
label.strip().lower(): label.strip()
for label in pr_labels
if label and label.strip()
}
for target in target_list:
key = target.strip().lower()
if key in clean_pr_labels:
return clean_pr_labels[key]
return None
def check_override_present(
pr_labels: list[str],
override_labels: str | list[str] | tuple[str, ...] = DEFAULT_OVERRIDE_LABELS,
) -> bool:
"""Checks if any override label is attached to the PR (case-insensitive)."""
return find_active_override(pr_labels, override_labels) is not None
def is_sme_review_satisfied(
evaluations: list[LabelEvaluation],
overridden: bool = False,
) -> bool:
"""Returns True if overridden, no monitored labels are present, or ANY label has at least one SME approval."""
if overridden or not evaluations:
return True
return any(ev.satisfied for ev in evaluations)
def generate_step_summary(
evaluations: list[LabelEvaluation],
pr_number: int,
pr_title: str,
pr_author: str,
passed: bool,
overridden: bool = False,
override_label: str = DEFAULT_OVERRIDE_LABEL,
) -> str:
"""Builds a GitHub Actions Markdown Step Summary."""
md = []
md.append(f"## 🏷️ SME Label Review Check for PR #{pr_number}\n")
md.append(f"**Title**: {pr_title} ")
md.append(f"**Author**: @{pr_author} \n")
if overridden:
md.append(
f"> ⚠️ **SME Review Requirement Bypassed**: Override label `{override_label}` is attached to this PR.\n"
)
if not evaluations:
if not overridden:
md.append(
"> ℹ️ **No monitored SME review labels attached.** \n"
"> This PR does not currently require specialized subject matter expert sign-off."
)
return "\n".join(md)
md.append("| Label | Status | Required SMEs | Approved By |")
md.append("| :--- | :---: | :--- | :--- |")
for ev in evaluations:
label_code = f"`{ev.rule.name}`"
req_smes = ", ".join([f"@{u}" for u in ev.rule.smes])
if ev.satisfied:
status_icon = "✅ Approved"
approvers_str = ", ".join([f"@{u}" for u in ev.approvers])
elif overridden:
status_icon = f"⚪ Overridden (`{override_label}`)"
approvers_str = "*None*"
else:
status_icon = "⚪ Satisfied (by other label)" if passed else "❌ Missing"
approvers_str = "*None*"
md.append(f"| {label_code} | {status_icon} | {req_smes} | {approvers_str} |")
md.append("")
if overridden:
md.append(
"> ⚠️ **SME Review Requirement Bypassed via Override Label.** \n"
f"> The `{override_label}` label was applied to skip SME sign-off."
)
elif passed:
all_matching_approvers = sorted({f"@{u}" for ev in evaluations for u in ev.approvers})
if all_matching_approvers:
md.append(
"> ✅ **SME Review Requirement Met!** \n"
f"> At least one designated reviewer ({', '.join(all_matching_approvers)}) has approved this PR."
)
else:
md.append(
"> ✅ **SME Review Requirement Met!** \n"
"> This PR does not require SME approval."
)
else:
all_req_smes = sorted({f"@{u}" for ev in evaluations for u in ev.rule.smes})
md.append(
"> ❌ **Review Required**: Missing SME approval. \n"
f"> Please request review from at least one SME from any of the required label lists: {', '.join(all_req_smes)}."
)
return "\n".join(md)
def write_github_outputs(output_path: str, outputs: dict[str, str]) -> None:
"""Writes key-value outputs to $GITHUB_OUTPUT file."""
try:
with open(output_path, "a", encoding="utf-8") as f:
for k, v in outputs.items():
f.write(f"{k}={v}\n")
except OSError as e:
LOGGER.warning("Failed writing to GITHUB_OUTPUT: %s", e)
def write_step_summary(summary_path: str, summary_markdown: str) -> None:
"""Appends markdown content to $GITHUB_STEP_SUMMARY file."""
try:
with open(summary_path, "a", encoding="utf-8") as f:
f.write(summary_markdown + "\n")
except OSError as e:
LOGGER.warning("Failed writing to GITHUB_STEP_SUMMARY: %s", e)
def sync_labels_to_github(
repo: str,
config_mapping: dict[str, LabelRule],
override_labels: str | list[str] | tuple[str, ...] = DEFAULT_OVERRIDE_LABELS,
) -> list[str]:
"""Ensures all configured labels and override labels exist in the GitHub repository using gh CLI."""
cmd = ["gh", "label", "list", "--repo", repo, "--limit", "1000", "--json", "name"]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, check=True)
data = json.loads(proc.stdout)
existing_labels = {
item["name"].strip().lower() for item in data if "name" in item
}
except subprocess.CalledProcessError as e:
LOGGER.error("Could not list existing labels on %s: %s", repo, e.stderr.strip())
raise RuntimeError(
f"Could not list existing labels on {repo}: {e.stderr.strip()}"
) from e
except Exception as e:
LOGGER.error("Could not list existing labels on %s: %s", repo, e)
raise RuntimeError(f"Could not list existing labels on {repo}: {e}") from e
created: list[str] = []
target_override_labels = (
[override_labels] if isinstance(override_labels, str) else list(override_labels)
)
for o_label in target_override_labels:
if o_label and o_label.strip().lower() not in existing_labels:
LOGGER.info(
"Override label '%s' does not exist on %s. Creating...",
o_label,
repo,
)
create_cmd = [
"gh",
"label",
"create",
o_label,
"--repo",
repo,
"--description",
"Override to bypass required SME reviews",
"--color",
"fbca04",
]
try:
subprocess.run(create_cmd, capture_output=True, text=True, check=True)
created.append(o_label)
LOGGER.info(
"✅ Successfully created override label '%s' on GitHub.",
o_label,
)
except subprocess.CalledProcessError as e:
LOGGER.error(
"Could not create override label '%s': %s",
o_label,
e.stderr.strip(),
)
raise RuntimeError(
f"Could not create override label '{o_label}': {e.stderr.strip()}"
) from e
for key, rule in config_mapping.items():
if key not in existing_labels:
LOGGER.info(
"Label '%s' does not exist on %s. Creating...",
rule.name,
repo,
)
create_cmd = [
"gh",
"label",
"create",
rule.name,
"--repo",
repo,
"--description",
f"Requires SME review: {rule.name}",
"--color",
"ededed",
]
try:
subprocess.run(create_cmd, capture_output=True, text=True, check=True)
created.append(rule.name)
LOGGER.info(
"✅ Successfully created label '%s' on GitHub.",
rule.name,
)
except subprocess.CalledProcessError as e:
LOGGER.error(
"Could not create label '%s': %s",
rule.name,
e.stderr.strip(),
)
raise RuntimeError(
f"Could not create label '{rule.name}': {e.stderr.strip()}"
) from e
return created
def main() -> int:
"""CLI entry point for checking SME label reviewers on pull requests."""
parser = argparse.ArgumentParser(
description="Verify that PRs with designated labels are approved by designated SME reviewers."
)
parser.add_argument(
"--pr",
type=int,
help="Pull request number",
)
parser.add_argument(
"--repo",
default=os.environ.get("GITHUB_REPOSITORY", DEFAULT_REPO),
help=f"GitHub repository in owner/repo format (default: {DEFAULT_REPO})",
)
parser.add_argument(
"--config",
default=DEFAULT_CONFIG_PATH,
help=f"Path to the YAML label reviewers file (default: {DEFAULT_CONFIG_PATH})",
)
parser.add_argument(
"--sync-labels",
action="store_true",
help="Ensure all configured labels exist on GitHub, creating any that are missing.",
)
parser.add_argument(
"--validate-config",
action="store_true",
help="Validate the syntax and format of the configuration file and exit.",
)
parser.add_argument(
"--override-label",
action="append",
dest="override_labels",
help=f"Label name that bypasses SME review checks (can be specified multiple times; default: {', '.join(DEFAULT_OVERRIDE_LABELS)})",
)
parser.add_argument(
"--log-level",
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Logging level",
)
args = parser.parse_args()
override_labels: list[str] = []
if args.override_labels:
for item in args.override_labels:
override_labels.extend([s.strip() for s in item.split(",") if s.strip()])
else:
override_labels = list(DEFAULT_OVERRIDE_LABELS)
logging.basicConfig(
level=getattr(logging, args.log_level.upper()),
format="%(asctime)s [%(levelname)s] %(message)s",
)
LOGGER.info("Loading configuration from %s...", args.config)
try:
config_mapping = parse_label_config(args.config)
except Exception as e:
LOGGER.error("Failed to load config: %s", e)
return 2
LOGGER.info("Configured labels with SME reviewers (%d):", len(config_mapping))
for rule in config_mapping.values():
LOGGER.debug(" - '%s': %s", rule.name, rule.smes)
if args.validate_config:
print(
f"✅ Configuration in {args.config} is valid ({len(config_mapping)} label(s) configured)."
)
return 0
if args.sync_labels:
LOGGER.info("Syncing labels from %s to repository %s...", args.config, args.repo)
try:
created = sync_labels_to_github(args.repo, config_mapping, override_labels)
except Exception as e:
LOGGER.error("Label synchronization failed: %s", e)
return 1
if created:
print(f"Created {len(created)} new label(s) on GitHub: {', '.join(created)}")
else:
print("All configured labels already exist on GitHub.")
if not args.pr:
return 0
if not args.pr:
LOGGER.error("PR number not provided. Pass --pr <number>.")
return 2
pr_number = args.pr
LOGGER.info("Fetching PR #%d from %s...", pr_number, args.repo)
try:
pr_data = fetch_pull_request_data(args.repo, pr_number)
except Exception as e:
LOGGER.error("Failed to fetch PR #%d: %s", pr_number, e)
return 2
pr_title = pr_data.get("title", "")
pr_author = (pr_data.get("author") or {}).get("login", "")
pr_state = pr_data.get("state", "")
pr_labels = [label.get("name", "") for label in pr_data.get("labels", [])]
print("\n" + "=" * 72)
print(f"SME Label Review Check for PR #{pr_number}: '{pr_title}'")
print(f"Repository: {args.repo} | Author: @{pr_author} | State: {pr_state}")
print("=" * 72)
print(f"Active PR labels: {pr_labels}\n")
approvers = extract_approvers(pr_data)
LOGGER.info("Active approved reviews from: %s", sorted(approvers) or "None")
active_override = find_active_override(pr_labels, override_labels)
overridden = active_override is not None
if overridden:
print(f"⚠️ Override label '{active_override}' is present on this PR.")
print(" SME review check is bypassed.\n" + "=" * 72)
evaluations = evaluate_pr_labels(pr_labels, config_mapping, approvers)
if not evaluations:
print("ℹ️ No designated SME review labels attached to this PR.")
print(" Check passed automatically.\n" + "=" * 72)
passed = True
else:
print(f"Found {len(evaluations)} monitored SME label(s) on this PR:\n")
passed = is_sme_review_satisfied(evaluations, overridden=overridden)
all_matching_approvers = sorted({u for ev in evaluations for u in ev.approvers})
for idx, ev in enumerate(evaluations, 1):
req_smes = ", ".join([f"@{u}" for u in ev.rule.smes])
if ev.satisfied:
approver_mentions = ", ".join([f"@{u}" for u in ev.approvers])
print(f" [{idx}] Label: '{ev.rule.name}'")
print(f" Required SMEs: {req_smes}")
print(f" Status: ✅ APPROVED by {approver_mentions}\n")
elif overridden:
print(f" [{idx}] Label: '{ev.rule.name}'")
print(f" Required SMEs: {req_smes}")
print(f" Status: ⚪ OVERRIDDEN by '{active_override}'\n")
else:
print(f" [{idx}] Label: '{ev.rule.name}'")
print(f" Required SMEs: {req_smes}")
if passed:
print(
f" Status: ⚪ Not directly reviewed (requirement satisfied by {', '.join([f'@{u}' for u in all_matching_approvers])})\n"
)
else:
print(" Status: ❌ MISSING APPROVAL\n")
if not passed:
all_smes = sorted({f"@{u}" for ev in evaluations for u in ev.rule.smes})
print(
f" Action: Requires at least one approval from any of: {', '.join(all_smes)}\n"
)
print("=" * 72)
# Output parameters for GitHub Actions
gh_output = os.environ.get("GITHUB_OUTPUT")
if gh_output:
missing_labels = [
ev.rule.name for ev in evaluations if not ev.satisfied
]
approved_labels = [ev.rule.name for ev in evaluations if ev.satisfied]
write_github_outputs(
gh_output,
{
"passed": "true" if passed else "false",
"overridden": "true" if overridden else "false",
"total_monitored_labels": str(len(evaluations)),
"missing_count": "0" if passed else str(len(missing_labels)),
"approved_labels": ",".join(approved_labels),
"missing_labels": "" if passed else ",".join(missing_labels),
},
)
# Step summary for GitHub Actions
gh_summary = os.environ.get("GITHUB_STEP_SUMMARY")
if gh_summary:
summary_md = generate_step_summary(
evaluations,
pr_number,
pr_title,
pr_author,
passed,
overridden=overridden,
override_label=active_override
or (override_labels[0] if override_labels else DEFAULT_OVERRIDE_LABEL),
)
write_step_summary(gh_summary, summary_md)
if overridden:
print(f"⚠️ BYPASSED: SME review check overridden by '{active_override}'.\n")
return 0
if passed:
print("✅ SUCCESS: SME review requirement has been satisfied.\n")
return 0
print("❌ FAILURE: Missing required SME review approval(s).\n")
return 1
if __name__ == "__main__":
sys.exit(main())