1#!/usr/bin/env python3 2 3# Copyright (c) 2022 Intel Corp. 4# SPDX-License-Identifier: Apache-2.0 5 6import argparse 7import sys 8import os 9import time 10import datetime 11from github import Github, GithubException 12from github.GithubException import UnknownObjectException 13from collections import defaultdict 14from west.manifest import Manifest 15from west.manifest import ManifestProject 16 17TOP_DIR = os.path.join(os.path.dirname(__file__)) 18sys.path.insert(0, os.path.join(TOP_DIR, "scripts")) 19from get_maintainer import Maintainers 20 21def log(s): 22 if args.verbose > 0: 23 print(s, file=sys.stdout) 24 25def parse_args(): 26 global args 27 parser = argparse.ArgumentParser( 28 description=__doc__, 29 formatter_class=argparse.RawDescriptionHelpFormatter, allow_abbrev=False) 30 31 parser.add_argument("-M", "--maintainer-file", required=False, default="MAINTAINERS.yml", 32 help="Maintainer file to be used.") 33 34 group = parser.add_mutually_exclusive_group() 35 group.add_argument("-P", "--pull_request", required=False, default=None, type=int, 36 help="Operate on one pull-request only.") 37 group.add_argument("-I", "--issue", required=False, default=None, type=int, 38 help="Operate on one issue only.") 39 group.add_argument("-s", "--since", required=False, 40 help="Process pull-requests since date.") 41 group.add_argument("-m", "--modules", action="store_true", 42 help="Process pull-requests from modules.") 43 44 parser.add_argument("-y", "--dry-run", action="store_true", default=False, 45 help="Dry run only.") 46 47 parser.add_argument("-o", "--org", default="zephyrproject-rtos", 48 help="Github organisation") 49 50 parser.add_argument("-r", "--repo", default="zephyr", 51 help="Github repository") 52 53 parser.add_argument("-v", "--verbose", action="count", default=0, 54 help="Verbose Output") 55 56 args = parser.parse_args() 57 58def process_pr(gh, maintainer_file, number): 59 60 gh_repo = gh.get_repo(f"{args.org}/{args.repo}") 61 pr = gh_repo.get_pull(number) 62 63 log(f"working on https://github.com/{args.org}/{args.repo}/pull/{pr.number} : {pr.title}") 64 65 labels = set() 66 area_counter = defaultdict(int) 67 found_maintainers = defaultdict(int) 68 69 num_files = 0 70 all_areas = set() 71 fn = list(pr.get_files()) 72 73 manifest_change = False 74 for changed_file in fn: 75 if changed_file.filename in ['west.yml','submanifests/optional.yaml']: 76 manifest_change = True 77 break 78 79 # one liner PRs should be trivial 80 if pr.commits == 1 and (pr.additions <= 1 and pr.deletions <= 1) and not manifest_change: 81 labels = {'Trivial'} 82 83 if len(fn) > 500: 84 log(f"Too many files changed ({len(fn)}), skipping....") 85 return 86 87 for changed_file in fn: 88 num_files += 1 89 log(f"file: {changed_file.filename}") 90 areas = maintainer_file.path2areas(changed_file.filename) 91 92 if not areas: 93 continue 94 95 all_areas.update(areas) 96 is_instance = False 97 sorted_areas = sorted(areas, key=lambda x: 'Platform' in x.name, reverse=True) 98 for area in sorted_areas: 99 c = 1 if not is_instance else 0 100 101 area_counter[area] += c 102 labels.update(area.labels) 103 # FIXME: Here we count the same file multiple times if it exists in 104 # multiple areas with same maintainer 105 for area_maintainer in area.maintainers: 106 found_maintainers[area_maintainer] += c 107 108 if 'Platform' in area.name: 109 is_instance = True 110 111 area_counter = dict(sorted(area_counter.items(), key=lambda item: item[1], reverse=True)) 112 log(f"Area matches: {area_counter}") 113 log(f"labels: {labels}") 114 115 # Create a list of collaborators ordered by the area match 116 collab = list() 117 for area in area_counter: 118 collab += maintainer_file.areas[area.name].maintainers 119 collab += maintainer_file.areas[area.name].collaborators 120 collab = list(dict.fromkeys(collab)) 121 log(f"collab: {collab}") 122 123 _all_maintainers = dict(sorted(found_maintainers.items(), key=lambda item: item[1], reverse=True)) 124 125 log(f"Submitted by: {pr.user.login}") 126 log(f"candidate maintainers: {_all_maintainers}") 127 128 maintainers = list(_all_maintainers.keys()) 129 assignee = None 130 131 # we start with areas with most files changed and pick the maintainer from the first one. 132 # if the first area is an implementation, i.e. driver or platform, we 133 # continue searching for any other areas 134 for area, count in area_counter.items(): 135 if count == 0: 136 continue 137 if len(area.maintainers) > 0: 138 assignee = area.maintainers[0] 139 140 if 'Platform' not in area.name: 141 break 142 143 # if the submitter is the same as the maintainer, check if we have 144 # multiple maintainers 145 if len(maintainers) > 1 and pr.user.login == assignee: 146 log("Submitter is same as Assignee, trying to find another assignee...") 147 aff = list(area_counter.keys())[0] 148 for area in all_areas: 149 if area == aff: 150 if len(area.maintainers) > 1: 151 assignee = area.maintainers[1] 152 else: 153 log(f"This area has only one maintainer, keeping assignee as {assignee}") 154 155 if assignee: 156 prop = (found_maintainers[assignee] / num_files) * 100 157 log(f"Picked assignee: {assignee} ({prop:.2f}% ownership)") 158 log("+++++++++++++++++++++++++") 159 160 # Set labels 161 if labels: 162 if len(labels) < 10: 163 for l in labels: 164 log(f"adding label {l}...") 165 if not args.dry_run: 166 pr.add_to_labels(l) 167 else: 168 log(f"Too many labels to be applied") 169 170 if collab: 171 reviewers = [] 172 existing_reviewers = set() 173 174 revs = pr.get_reviews() 175 for review in revs: 176 existing_reviewers.add(review.user) 177 178 rl = pr.get_review_requests() 179 page = 0 180 for r in rl: 181 existing_reviewers |= set(r.get_page(page)) 182 page += 1 183 184 # check for reviewers that remove themselves from list of reviewer and 185 # do not attempt to add them again based on MAINTAINERS file. 186 self_removal = [] 187 for event in pr.get_issue_events(): 188 if event.event == 'review_request_removed' and event.actor == event.requested_reviewer: 189 self_removal.append(event.actor) 190 191 for collaborator in collab: 192 try: 193 gh_user = gh.get_user(collaborator) 194 if pr.user != gh_user and gh_repo.has_in_collaborators(gh_user): 195 if gh_user not in existing_reviewers and gh_user not in self_removal: 196 reviewers.append(collaborator) 197 except UnknownObjectException as e: 198 log(f"Can't get user '{collaborator}', account does not exist anymore? ({e})") 199 200 if len(existing_reviewers) < 15: 201 reviewer_vacancy = 15 - len(existing_reviewers) 202 reviewers = reviewers[:reviewer_vacancy] 203 204 if reviewers: 205 try: 206 log(f"adding reviewers {reviewers}...") 207 if not args.dry_run: 208 pr.create_review_request(reviewers=reviewers) 209 except GithubException: 210 log("cant add reviewer") 211 else: 212 log("not adding reviewers because the existing reviewer count is greater than or " 213 "equal to 15") 214 215 ms = [] 216 # assignees 217 if assignee and not pr.assignee: 218 try: 219 u = gh.get_user(assignee) 220 ms.append(u) 221 except GithubException: 222 log(f"Error: Unknown user") 223 224 for mm in ms: 225 log(f"Adding assignee {mm}...") 226 if not args.dry_run: 227 pr.add_to_assignees(mm) 228 else: 229 log("not setting assignee") 230 231 time.sleep(1) 232 233 234def process_issue(gh, maintainer_file, number): 235 gh_repo = gh.get_repo(f"{args.org}/{args.repo}") 236 issue = gh_repo.get_issue(number) 237 238 log(f"Working on {issue.url}: {issue.title}") 239 240 if issue.assignees: 241 print(f"Already assigned {issue.assignees}, bailing out") 242 return 243 244 label_to_maintainer = defaultdict(set) 245 for _, area in maintainer_file.areas.items(): 246 if not area.labels: 247 continue 248 249 labels = set() 250 for label in area.labels: 251 labels.add(label.lower()) 252 labels = tuple(sorted(labels)) 253 254 for maintainer in area.maintainers: 255 label_to_maintainer[labels].add(maintainer) 256 257 # Add extra entries for areas with multiple labels so they match with just 258 # one label if it's specific enough. 259 for areas, maintainers in dict(label_to_maintainer).items(): 260 for area in areas: 261 if tuple([area]) not in label_to_maintainer: 262 label_to_maintainer[tuple([area])] = maintainers 263 264 issue_labels = set() 265 for label in issue.labels: 266 label_name = label.name.lower() 267 if tuple([label_name]) not in label_to_maintainer: 268 print(f"Ignoring label: {label}") 269 continue 270 issue_labels.add(label_name) 271 issue_labels = tuple(sorted(issue_labels)) 272 273 print(f"Using labels: {issue_labels}") 274 275 if issue_labels not in label_to_maintainer: 276 print(f"no match for the label set, not assigning") 277 return 278 279 for maintainer in label_to_maintainer[issue_labels]: 280 log(f"Adding {maintainer} to {issue.html_url}") 281 if not args.dry_run: 282 issue.add_to_assignees(maintainer) 283 284 285def process_modules(gh, maintainers_file): 286 manifest = Manifest.from_file() 287 288 repos = {} 289 for project in manifest.get_projects([]): 290 if not manifest.is_active(project): 291 continue 292 293 if isinstance(project, ManifestProject): 294 continue 295 296 area = f"West project: {project.name}" 297 if area not in maintainers_file.areas: 298 log(f"No area for: {area}") 299 continue 300 301 maintainers = maintainers_file.areas[area].maintainers 302 if not maintainers: 303 log(f"No maintainers for: {area}") 304 continue 305 306 collaborators = maintainers_file.areas[area].collaborators 307 308 log(f"Found {area}, maintainers={maintainers}, collaborators={collaborators}") 309 310 repo_name = f"{args.org}/{project.name}" 311 repos[repo_name] = maintainers_file.areas[area] 312 313 query = f"is:open is:pr no:assignee" 314 for repo in repos: 315 query += f" repo:{repo}" 316 317 issues = gh.search_issues(query=query) 318 for issue in issues: 319 pull = issue.as_pull_request() 320 321 if pull.draft: 322 continue 323 324 if pull.assignees: 325 log(f"ERROR: {pull.html_url} should have no assignees, found {pull.assignees}") 326 continue 327 328 repo_name = f"{args.org}/{issue.repository.name}" 329 area = repos[repo_name] 330 331 for maintainer in area.maintainers: 332 log(f"Assigning {maintainer} to {pull.html_url}") 333 if not args.dry_run: 334 pull.add_to_assignees(maintainer) 335 pull.create_review_request(maintainer) 336 337 for collaborator in area.collaborators: 338 log(f"Adding {collaborator} to {pull.html_url}") 339 if not args.dry_run: 340 pull.create_review_request(collaborator) 341 342 343def main(): 344 parse_args() 345 346 token = os.environ.get('GITHUB_TOKEN', None) 347 if not token: 348 sys.exit('Github token not set in environment, please set the ' 349 'GITHUB_TOKEN environment variable and retry.') 350 351 gh = Github(token) 352 maintainer_file = Maintainers(args.maintainer_file) 353 354 if args.pull_request: 355 process_pr(gh, maintainer_file, args.pull_request) 356 elif args.issue: 357 process_issue(gh, maintainer_file, args.issue) 358 elif args.modules: 359 process_modules(gh, maintainer_file) 360 else: 361 if args.since: 362 since = args.since 363 else: 364 today = datetime.date.today() 365 since = today - datetime.timedelta(days=1) 366 367 common_prs = f'repo:{args.org}/{args.repo} is:open is:pr base:main -is:draft no:assignee created:>{since}' 368 pulls = gh.search_issues(query=f'{common_prs}') 369 370 for issue in pulls: 371 process_pr(gh, maintainer_file, issue.number) 372 373 374if __name__ == "__main__": 375 main() 376