blob: dc57a320353f500676a56531ddcd3fc8cfa3ebf4 [file] [log] [blame]
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright 2025 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""
A script to verify if a user meets the requirements to become an OWNER of the
//chrome directory in the Chromium repository.
This script checks for:
1. Minimum number of commits in the //chrome directory.
2. Minimum number of code reviews in the //chrome directory.
3. Ownership of at least three subdirectories within //chrome, correctly
handling `file://` directives in OWNERS files.
"""
import argparse
import subprocess
import sys
from pathlib import Path
from typing import List, Set
# --- Configuration Constants ---
COMMIT_THRESHOLD = 300
REVIEW_THRESHOLD = 500
OWNERS_THRESHOLD = 3
SEARCH_DIRECTORY = "chrome"
class Colors:
"""ANSI color codes for beautiful terminal output."""
GREEN = '\033[92m'
YELLOW = '\033[93m'
RED = '\033[91m'
BOLD = '\033[1m'
END = '\033[0m'
def _run_git_command(command: List[str]) -> str:
"""
Executes a Git command and returns its stripped stdout.
Handles potential errors by printing a message and exiting.
"""
try:
process = subprocess.run(command,
capture_output=True,
text=True,
check=True,
encoding='utf-8')
return process.stdout.strip()
except FileNotFoundError:
print(
f"{Colors.RED}Error: 'git' command not found. "
f"Is Git installed and in your PATH?{Colors.END}",
file=sys.stderr)
sys.exit(1)
except subprocess.CalledProcessError as e:
# A non-zero exit code often means not in a repo.
error_message = e.stderr.strip()
if "not a git repository" in error_message:
print(
f"{Colors.RED}Error: This script must be run from within "
f"the Chromium git repository.{Colors.END}",
file=sys.stderr)
else:
print(f"{Colors.RED}Git command failed:\n{error_message}"
f"{Colors.END}",
file=sys.stderr)
sys.exit(1)
def get_commit_count(email: str, directory: str) -> int:
"""Counts commits by a given author in a specific directory."""
print(f"[*] Checking commit count for {Colors.BOLD}{email}{Colors.END}...")
command = [
"git", "rev-list", "--count", f"--author={email}", "HEAD", "--", directory
]
output = _run_git_command(command)
return int(output) if output.isdigit() else 0
def get_review_count(email: str, directory: str) -> int:
"""Counts reviews by a given user in a specific directory."""
print(f"[*] Checking review count for {Colors.BOLD}{email}{Colors.END}...")
# We run the git log command and count the resulting lines within Python.
# This avoids a shell pipe and is more robust and platform-independent.
command = [
"git", "log", f"--grep=Reviewed-by:.*<{email}>", "-E", "-i", "--oneline",
"--", directory
]
output = _run_git_command(command)
# An empty output from git log should result in 0 reviews.
return len(output.splitlines()) if output else 0
def _is_user_in_owner_file_recursively(owner_file: Path, email_lower: str,
visited_files: Set[Path]) -> bool:
"""
Recursively checks if an email is in an OWNERS file, following `file://`.
Args:
owner_file: The path to the OWNERS file to check.
email_lower: The lowercase email address to search for.
visited_files: A set of paths already checked to prevent infinite loops.
Returns:
True if the user is found, False otherwise.
"""
if owner_file in visited_files or not owner_file.is_file():
return False
visited_files.add(owner_file)
with owner_file.open('r', encoding='utf-8') as f:
for line in f:
stripped_line = line.strip()
# Ignore comments and empty lines
if not stripped_line or stripped_line.startswith('#'):
continue
# Direct email match
if email_lower in stripped_line.lower():
return True
# file:// directive match
if stripped_line.lower().startswith('file://'):
# Assumes the path is relative to the repository root.
referenced_path = Path(stripped_line[len('file://'):])
if _is_user_in_owner_file_recursively(referenced_path, email_lower,
visited_files):
return True
return False
def find_owned_directories(email: str, directory: str) -> List[Path]:
"""
Finds all OWNERS files within a directory that list the user as an owner.
"""
print(
f"[*] Searching for OWNERS files for {Colors.BOLD}{email}{Colors.END}...")
owned_files = []
root_dir = Path(directory)
email_lower = email.lower()
if not root_dir.is_dir():
print(f"{Colors.YELLOW}Warning: Directory '{directory}' not "
f"found.{Colors.END}",
file=sys.stderr)
return []
for owner_file in root_dir.rglob("OWNERS"):
# We start each top-level check with a fresh set of visited files.
if _is_user_in_owner_file_recursively(owner_file,
email_lower,
visited_files=set()):
owned_files.append(owner_file)
return owned_files
def _print_status(label: str, count: int, threshold: int):
"""Prints a formatted status line with a pass/fail indicator."""
if count >= threshold:
status = f"{Colors.GREEN}PASS{Colors.END}"
else:
status = f"{Colors.RED}FAIL{Colors.END}"
print(f" {label:<18} {Colors.BOLD}{count}{Colors.END} "
f"(Threshold: {threshold}) [{status}]")
def main():
"""Main function to parse arguments and run the checks."""
parser = argparse.ArgumentParser(
description="Check Chromium OWNERS requirements for a user.")
parser.add_argument(
"email",
help="The email address of the user to check (e.g., 'user@chromium.org')."
)
args = parser.parse_args()
email = args.email
print("-" * 60)
print(f"Checking requirements for: "
f"{Colors.BOLD}{Colors.YELLOW}{email}{Colors.END}")
print("-" * 60)
# --- Perform Checks ---
commit_count = get_commit_count(email, SEARCH_DIRECTORY)
review_count = get_review_count(email, SEARCH_DIRECTORY)
owned_files = find_owned_directories(email, SEARCH_DIRECTORY)
owners_count = len(owned_files)
# --- Print Summary ---
print("\n" + "-" * 60)
print(f"{Colors.BOLD}SUMMARY{Colors.END}")
print("-" * 60)
_print_status("Commit Count:", commit_count, COMMIT_THRESHOLD)
_print_status("Review Count:", review_count, REVIEW_THRESHOLD)
_print_status("OWNERS Files:", owners_count, OWNERS_THRESHOLD)
if owners_count > 0:
print(f"\n{Colors.BOLD}Found in {owners_count} OWNERS file(s):{Colors.END}")
# Sort by directory depth to list more top-level directories first,
# using the path itself as a tie-breaker for alphabetical sorting.
sorted_paths = sorted(owned_files, key=lambda p: (len(p.parts), p))
for path in sorted_paths:
print(f" - {Colors.GREEN}{path}{Colors.END}")
print("-" * 60)
if __name__ == "__main__":
main()