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 for changed_file in fn: 74 if changed_file.filename in ['west.yml','submanifests/optional.yaml']: 75 break 76 77 if pr.commits == 1 and (pr.additions <= 1 and pr.deletions <= 1): 78 labels = {'size: XS'} 79 80 if len(fn) > 500: 81 log(f"Too many files changed ({len(fn)}), skipping....") 82 return 83 84 for changed_file in fn: 85 num_files += 1 86 log(f"file: {changed_file.filename}") 87 areas = maintainer_file.path2areas(changed_file.filename) 88 89 if not areas: 90 continue 91 92 all_areas.update(areas) 93 is_instance = False 94 sorted_areas = sorted(areas, key=lambda x: 'Platform' in x.name, reverse=True) 95 for area in sorted_areas: 96 c = 1 if not is_instance else 0 97 98 area_counter[area] += c 99 labels.update(area.labels) 100 # FIXME: Here we count the same file multiple times if it exists in 101 # multiple areas with same maintainer 102 for area_maintainer in area.maintainers: 103 found_maintainers[area_maintainer] += c 104 105 if 'Platform' in area.name: 106 is_instance = True 107 108 area_counter = dict(sorted(area_counter.items(), key=lambda item: item[1], reverse=True)) 109 log(f"Area matches: {area_counter}") 110 log(f"labels: {labels}") 111 112 # Create a list of collaborators ordered by the area match 113 collab = list() 114 for area in area_counter: 115 collab += maintainer_file.areas[area.name].maintainers 116 collab += maintainer_file.areas[area.name].collaborators 117 collab = list(dict.fromkeys(collab)) 118 log(f"collab: {collab}") 119 120 _all_maintainers = dict(sorted(found_maintainers.items(), key=lambda item: item[1], reverse=True)) 121 122 log(f"Submitted by: {pr.user.login}") 123 log(f"candidate maintainers: {_all_maintainers}") 124 125 assignees = [] 126 tmp_assignees = [] 127 128 # we start with areas with most files changed and pick the maintainer from the first one. 129 # if the first area is an implementation, i.e. driver or platform, we 130 # continue searching for any other areas involved 131 for area, count in area_counter.items(): 132 if count == 0: 133 continue 134 if len(area.maintainers) > 0: 135 tmp_assignees = area.maintainers 136 if pr.user.login in area.maintainers: 137 # submitter = assignee, try to pick next area and 138 # assign someone else other than the submitter 139 # when there also other maintainers for the area 140 # assign them 141 if len(area.maintainers) > 1: 142 assignees = area.maintainers.copy() 143 assignees.remove(pr.user.login) 144 else: 145 continue 146 else: 147 assignees = area.maintainers 148 149 if 'Platform' not in area.name: 150 break 151 152 if tmp_assignees and not assignees: 153 assignees = tmp_assignees 154 155 if assignees: 156 prop = (found_maintainers[assignees[0]] / num_files) * 100 157 log(f"Picked assignees: {assignees} ({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 or gh_user in existing_reviewers: 195 continue 196 if not gh_repo.has_in_collaborators(gh_user): 197 log(f"Skip '{collaborator}': not in collaborators") 198 continue 199 if gh_user in self_removal: 200 log(f"Skip '{collaborator}': self removed") 201 continue 202 reviewers.append(collaborator) 203 except UnknownObjectException as e: 204 log(f"Can't get user '{collaborator}', account does not exist anymore? ({e})") 205 206 if len(existing_reviewers) < 15: 207 reviewer_vacancy = 15 - len(existing_reviewers) 208 reviewers = reviewers[:reviewer_vacancy] 209 210 if reviewers: 211 try: 212 log(f"adding reviewers {reviewers}...") 213 if not args.dry_run: 214 pr.create_review_request(reviewers=reviewers) 215 except GithubException: 216 log("cant add reviewer") 217 else: 218 log("not adding reviewers because the existing reviewer count is greater than or " 219 "equal to 15") 220 221 ms = [] 222 # assignees 223 if assignees and not pr.assignee: 224 try: 225 for assignee in assignees: 226 u = gh.get_user(assignee) 227 ms.append(u) 228 except GithubException: 229 log(f"Error: Unknown user") 230 231 for mm in ms: 232 log(f"Adding assignee {mm}...") 233 if not args.dry_run: 234 pr.add_to_assignees(mm) 235 else: 236 log("not setting assignee") 237 238 time.sleep(1) 239 240 241def process_issue(gh, maintainer_file, number): 242 gh_repo = gh.get_repo(f"{args.org}/{args.repo}") 243 issue = gh_repo.get_issue(number) 244 245 log(f"Working on {issue.url}: {issue.title}") 246 247 if issue.assignees: 248 print(f"Already assigned {issue.assignees}, bailing out") 249 return 250 251 label_to_maintainer = defaultdict(set) 252 for _, area in maintainer_file.areas.items(): 253 if not area.labels: 254 continue 255 256 labels = set() 257 for label in area.labels: 258 labels.add(label.lower()) 259 labels = tuple(sorted(labels)) 260 261 for maintainer in area.maintainers: 262 label_to_maintainer[labels].add(maintainer) 263 264 # Add extra entries for areas with multiple labels so they match with just 265 # one label if it's specific enough. 266 for areas, maintainers in dict(label_to_maintainer).items(): 267 for area in areas: 268 if tuple([area]) not in label_to_maintainer: 269 label_to_maintainer[tuple([area])] = maintainers 270 271 issue_labels = set() 272 for label in issue.labels: 273 label_name = label.name.lower() 274 if tuple([label_name]) not in label_to_maintainer: 275 print(f"Ignoring label: {label}") 276 continue 277 issue_labels.add(label_name) 278 issue_labels = tuple(sorted(issue_labels)) 279 280 print(f"Using labels: {issue_labels}") 281 282 if issue_labels not in label_to_maintainer: 283 print(f"no match for the label set, not assigning") 284 return 285 286 for maintainer in label_to_maintainer[issue_labels]: 287 log(f"Adding {maintainer} to {issue.html_url}") 288 if not args.dry_run: 289 issue.add_to_assignees(maintainer) 290 291 292def process_modules(gh, maintainers_file): 293 manifest = Manifest.from_file() 294 295 repos = {} 296 for project in manifest.get_projects([]): 297 if not manifest.is_active(project): 298 continue 299 300 if isinstance(project, ManifestProject): 301 continue 302 303 area = f"West project: {project.name}" 304 if area not in maintainers_file.areas: 305 log(f"No area for: {area}") 306 continue 307 308 maintainers = maintainers_file.areas[area].maintainers 309 if not maintainers: 310 log(f"No maintainers for: {area}") 311 continue 312 313 collaborators = maintainers_file.areas[area].collaborators 314 315 log(f"Found {area}, maintainers={maintainers}, collaborators={collaborators}") 316 317 repo_name = f"{args.org}/{project.name}" 318 repos[repo_name] = maintainers_file.areas[area] 319 320 query = f"is:open is:pr no:assignee" 321 for repo in repos: 322 query += f" repo:{repo}" 323 324 issues = gh.search_issues(query=query) 325 for issue in issues: 326 pull = issue.as_pull_request() 327 328 if pull.draft: 329 continue 330 331 if pull.assignees: 332 log(f"ERROR: {pull.html_url} should have no assignees, found {pull.assignees}") 333 continue 334 335 repo_name = f"{args.org}/{issue.repository.name}" 336 area = repos[repo_name] 337 338 for maintainer in area.maintainers: 339 log(f"Assigning {maintainer} to {pull.html_url}") 340 if not args.dry_run: 341 pull.add_to_assignees(maintainer) 342 pull.create_review_request(maintainer) 343 344 for collaborator in area.collaborators: 345 log(f"Adding {collaborator} to {pull.html_url}") 346 if not args.dry_run: 347 pull.create_review_request(collaborator) 348 349 350def main(): 351 parse_args() 352 353 token = os.environ.get('GITHUB_TOKEN', None) 354 if not token: 355 sys.exit('Github token not set in environment, please set the ' 356 'GITHUB_TOKEN environment variable and retry.') 357 358 gh = Github(token) 359 maintainer_file = Maintainers(args.maintainer_file) 360 361 if args.pull_request: 362 process_pr(gh, maintainer_file, args.pull_request) 363 elif args.issue: 364 process_issue(gh, maintainer_file, args.issue) 365 elif args.modules: 366 process_modules(gh, maintainer_file) 367 else: 368 if args.since: 369 since = args.since 370 else: 371 today = datetime.date.today() 372 since = today - datetime.timedelta(days=1) 373 374 common_prs = f'repo:{args.org}/{args.repo} is:open is:pr base:main -is:draft no:assignee created:>{since}' 375 pulls = gh.search_issues(query=f'{common_prs}') 376 377 for issue in pulls: 378 process_pr(gh, maintainer_file, issue.number) 379 380 381if __name__ == "__main__": 382 main() 383