1#!/usr/bin/env python3 2 3# SPDX-License-Identifier: Apache-2.0 4# Copyright The Zephyr Project Contributors 5 6import argparse 7import os 8import sys 9 10import yaml 11from github import Github 12 13 14def load_areas(filename: str): 15 with open(filename) as f: 16 doc = yaml.safe_load(f) 17 return { 18 k: v for k, v in doc.items() if isinstance(v, dict) and ("files" in v or "files-regex" in v) 19 } 20 21 22def set_or_empty(d, key): 23 return set(d.get(key, []) or []) 24 25 26def check_github_access(usernames, repo_fullname, token): 27 """Check if each username has at least Triage access to the repo.""" 28 gh = Github(token) 29 repo = gh.get_repo(repo_fullname) 30 missing_access = set() 31 for username in usernames: 32 try: 33 collab = repo.get_collaborator_permission(username) 34 # Permissions: admin, maintain, write, triage, read 35 if collab not in ("admin", "maintain", "write", "triage"): 36 missing_access.add(username) 37 except Exception: 38 missing_access.add(username) 39 return missing_access 40 41 42def compare_areas(old, new, repo_fullname=None, token=None): 43 old_areas = set(old.keys()) 44 new_areas = set(new.keys()) 45 46 added_areas = new_areas - old_areas 47 removed_areas = old_areas - new_areas 48 common_areas = old_areas & new_areas 49 50 all_added_maintainers = set() 51 all_added_collaborators = set() 52 53 print("=== Areas Added ===") 54 for area in sorted(added_areas): 55 print(f"+ {area}") 56 entry = new[area] 57 all_added_maintainers.update(set_or_empty(entry, "maintainers")) 58 all_added_collaborators.update(set_or_empty(entry, "collaborators")) 59 60 print("\n=== Areas Removed ===") 61 for area in sorted(removed_areas): 62 print(f"- {area}") 63 64 print("\n=== Area Changes ===") 65 for area in sorted(common_areas): 66 changes = [] 67 old_entry = old[area] 68 new_entry = new[area] 69 70 # Compare maintainers 71 old_maint = set_or_empty(old_entry, "maintainers") 72 new_maint = set_or_empty(new_entry, "maintainers") 73 added_maint = new_maint - old_maint 74 removed_maint = old_maint - new_maint 75 if added_maint: 76 changes.append(f" Maintainers added: {', '.join(sorted(added_maint))}") 77 all_added_maintainers.update(added_maint) 78 if removed_maint: 79 changes.append(f" Maintainers removed: {', '.join(sorted(removed_maint))}") 80 81 # Compare collaborators 82 old_collab = set_or_empty(old_entry, "collaborators") 83 new_collab = set_or_empty(new_entry, "collaborators") 84 added_collab = new_collab - old_collab 85 removed_collab = old_collab - new_collab 86 if added_collab: 87 changes.append(f" Collaborators added: {', '.join(sorted(added_collab))}") 88 all_added_collaborators.update(added_collab) 89 if removed_collab: 90 changes.append(f" Collaborators removed: {', '.join(sorted(removed_collab))}") 91 92 # Compare status 93 old_status = old_entry.get("status") 94 new_status = new_entry.get("status") 95 if old_status != new_status: 96 changes.append(f" Status changed: {old_status} -> {new_status}") 97 98 # Compare labels 99 old_labels = set_or_empty(old_entry, "labels") 100 new_labels = set_or_empty(new_entry, "labels") 101 added_labels = new_labels - old_labels 102 removed_labels = old_labels - new_labels 103 if added_labels: 104 changes.append(f" Labels added: {', '.join(sorted(added_labels))}") 105 if removed_labels: 106 changes.append(f" Labels removed: {', '.join(sorted(removed_labels))}") 107 108 # Compare files 109 old_files = set_or_empty(old_entry, "files") 110 new_files = set_or_empty(new_entry, "files") 111 added_files = new_files - old_files 112 removed_files = old_files - new_files 113 if added_files: 114 changes.append(f" Files added: {', '.join(sorted(added_files))}") 115 if removed_files: 116 changes.append(f" Files removed: {', '.join(sorted(removed_files))}") 117 118 # Compare files-regex 119 old_regex = set_or_empty(old_entry, "files-regex") 120 new_regex = set_or_empty(new_entry, "files-regex") 121 added_regex = new_regex - old_regex 122 removed_regex = old_regex - new_regex 123 if added_regex: 124 changes.append(f" files-regex added: {', '.join(sorted(added_regex))}") 125 if removed_regex: 126 changes.append(f" files-regex removed: {', '.join(sorted(removed_regex))}") 127 128 if changes: 129 print(f"* {area}") 130 for c in changes: 131 print(c) 132 133 print("\n=== Summary ===") 134 print(f"Total areas added: {len(added_areas)}") 135 print(f"Total maintainers added: {len(all_added_maintainers)}") 136 if all_added_maintainers: 137 print(" Added maintainers: " + ", ".join(sorted(all_added_maintainers))) 138 print(f"Total collaborators added: {len(all_added_collaborators)}") 139 if all_added_collaborators: 140 print(" Added collaborators: " + ", ".join(sorted(all_added_collaborators))) 141 142 # Check GitHub access if repo and token are provided 143 144 print("\n=== GitHub Access Check ===") 145 missing_maint = check_github_access(all_added_maintainers, repo_fullname, token) 146 missing_collab = check_github_access(all_added_collaborators, repo_fullname, token) 147 if missing_maint: 148 print("Maintainers without at least triage access:") 149 for u in sorted(missing_maint): 150 print(f" - {u}") 151 if missing_collab: 152 print("Collaborators without at least triage access:") 153 for u in sorted(missing_collab): 154 print(f" - {u}") 155 if not missing_maint and not missing_collab: 156 print("All added maintainers and collaborators have required access.") 157 else: 158 print("Some added maintainers or collaborators do not have sufficient access.") 159 160 # --- GitHub Actions inline annotation --- 161 # Try to find the line number in the new file for each missing user 162 def find_line_for_user(yaml_file, user_set): 163 """Return a dict of user -> line number in yaml_file for missing users.""" 164 user_lines = {} 165 try: 166 with open(yaml_file) as f: 167 lines = f.readlines() 168 for idx, line in enumerate(lines, 1): 169 for user in user_set: 170 if user in line: 171 user_lines[user] = idx 172 return user_lines 173 except Exception: 174 return {} 175 176 all_missing_users = missing_maint | missing_collab 177 user_lines = find_line_for_user(args.new, all_missing_users) 178 179 for user, line in user_lines.items(): 180 print( 181 f"::error file={args.new},line={line},title=User lacks access::" 182 f"{user} does not have needed access level to {repo_fullname}" 183 ) 184 185 # For any missing users not found in the file, print a general error 186 for user in sorted(all_missing_users - set(user_lines)): 187 print( 188 f"::error title=User lacks access::{user} does not have needed " 189 f"access level to {repo_fullname}" 190 ) 191 192 sys.exit(1) 193 194 195def main(): 196 parser = argparse.ArgumentParser( 197 description="Compare two MAINTAINERS.yml files and show changes in areas, " 198 "maintainers, collaborators, etc.", 199 allow_abbrev=False, 200 ) 201 parser.add_argument("old", help="Old MAINTAINERS.yml file") 202 parser.add_argument("new", help="New MAINTAINERS.yml file") 203 parser.add_argument("--repo", help="GitHub repository in org/repo format for access check") 204 parser.add_argument("--token", help="GitHub token for API access (required for access check)") 205 global args 206 args = parser.parse_args() 207 208 old_areas = load_areas(args.old) 209 new_areas = load_areas(args.new) 210 token = os.environ.get("GITHUB_TOKEN") or args.token 211 212 if not token or not args.repo: 213 print("GitHub token and repository are required for access check.") 214 sys.exit(1) 215 216 compare_areas(old_areas, new_areas, repo_fullname=args.repo, token=token) 217 218 219if __name__ == "__main__": 220 main() 221