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