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 continue 140 else: 141 assignees = area.maintainers 142 143 if 'Platform' not in area.name: 144 break 145 146 if tmp_assignees and not assignees: 147 assignees = tmp_assignees 148 149 if assignees: 150 prop = (found_maintainers[assignees[0]] / num_files) * 100 151 log(f"Picked assignees: {assignees} ({prop:.2f}% ownership)") 152 log("+++++++++++++++++++++++++") 153 154 # Set labels 155 if labels: 156 if len(labels) < 10: 157 for l in labels: 158 log(f"adding label {l}...") 159 if not args.dry_run: 160 pr.add_to_labels(l) 161 else: 162 log(f"Too many labels to be applied") 163 164 if collab: 165 reviewers = [] 166 existing_reviewers = set() 167 168 revs = pr.get_reviews() 169 for review in revs: 170 existing_reviewers.add(review.user) 171 172 rl = pr.get_review_requests() 173 page = 0 174 for r in rl: 175 existing_reviewers |= set(r.get_page(page)) 176 page += 1 177 178 # check for reviewers that remove themselves from list of reviewer and 179 # do not attempt to add them again based on MAINTAINERS file. 180 self_removal = [] 181 for event in pr.get_issue_events(): 182 if event.event == 'review_request_removed' and event.actor == event.requested_reviewer: 183 self_removal.append(event.actor) 184 185 for collaborator in collab: 186 try: 187 gh_user = gh.get_user(collaborator) 188 if pr.user == gh_user or gh_user in existing_reviewers: 189 continue 190 if not gh_repo.has_in_collaborators(gh_user): 191 log(f"Skip '{collaborator}': not in collaborators") 192 continue 193 if gh_user in self_removal: 194 log(f"Skip '{collaborator}': self removed") 195 continue 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 assignees and not pr.assignee: 218 try: 219 for assignee in assignees: 220 u = gh.get_user(assignee) 221 ms.append(u) 222 except GithubException: 223 log(f"Error: Unknown user") 224 225 for mm in ms: 226 log(f"Adding assignee {mm}...") 227 if not args.dry_run: 228 pr.add_to_assignees(mm) 229 else: 230 log("not setting assignee") 231 232 time.sleep(1) 233 234 235def process_issue(gh, maintainer_file, number): 236 gh_repo = gh.get_repo(f"{args.org}/{args.repo}") 237 issue = gh_repo.get_issue(number) 238 239 log(f"Working on {issue.url}: {issue.title}") 240 241 if issue.assignees: 242 print(f"Already assigned {issue.assignees}, bailing out") 243 return 244 245 label_to_maintainer = defaultdict(set) 246 for _, area in maintainer_file.areas.items(): 247 if not area.labels: 248 continue 249 250 labels = set() 251 for label in area.labels: 252 labels.add(label.lower()) 253 labels = tuple(sorted(labels)) 254 255 for maintainer in area.maintainers: 256 label_to_maintainer[labels].add(maintainer) 257 258 # Add extra entries for areas with multiple labels so they match with just 259 # one label if it's specific enough. 260 for areas, maintainers in dict(label_to_maintainer).items(): 261 for area in areas: 262 if tuple([area]) not in label_to_maintainer: 263 label_to_maintainer[tuple([area])] = maintainers 264 265 issue_labels = set() 266 for label in issue.labels: 267 label_name = label.name.lower() 268 if tuple([label_name]) not in label_to_maintainer: 269 print(f"Ignoring label: {label}") 270 continue 271 issue_labels.add(label_name) 272 issue_labels = tuple(sorted(issue_labels)) 273 274 print(f"Using labels: {issue_labels}") 275 276 if issue_labels not in label_to_maintainer: 277 print(f"no match for the label set, not assigning") 278 return 279 280 for maintainer in label_to_maintainer[issue_labels]: 281 log(f"Adding {maintainer} to {issue.html_url}") 282 if not args.dry_run: 283 issue.add_to_assignees(maintainer) 284 285 286def process_modules(gh, maintainers_file): 287 manifest = Manifest.from_file() 288 289 repos = {} 290 for project in manifest.get_projects([]): 291 if not manifest.is_active(project): 292 continue 293 294 if isinstance(project, ManifestProject): 295 continue 296 297 area = f"West project: {project.name}" 298 if area not in maintainers_file.areas: 299 log(f"No area for: {area}") 300 continue 301 302 maintainers = maintainers_file.areas[area].maintainers 303 if not maintainers: 304 log(f"No maintainers for: {area}") 305 continue 306 307 collaborators = maintainers_file.areas[area].collaborators 308 309 log(f"Found {area}, maintainers={maintainers}, collaborators={collaborators}") 310 311 repo_name = f"{args.org}/{project.name}" 312 repos[repo_name] = maintainers_file.areas[area] 313 314 query = f"is:open is:pr no:assignee" 315 for repo in repos: 316 query += f" repo:{repo}" 317 318 issues = gh.search_issues(query=query) 319 for issue in issues: 320 pull = issue.as_pull_request() 321 322 if pull.draft: 323 continue 324 325 if pull.assignees: 326 log(f"ERROR: {pull.html_url} should have no assignees, found {pull.assignees}") 327 continue 328 329 repo_name = f"{args.org}/{issue.repository.name}" 330 area = repos[repo_name] 331 332 for maintainer in area.maintainers: 333 log(f"Assigning {maintainer} to {pull.html_url}") 334 if not args.dry_run: 335 pull.add_to_assignees(maintainer) 336 pull.create_review_request(maintainer) 337 338 for collaborator in area.collaborators: 339 log(f"Adding {collaborator} to {pull.html_url}") 340 if not args.dry_run: 341 pull.create_review_request(collaborator) 342 343 344def main(): 345 parse_args() 346 347 token = os.environ.get('GITHUB_TOKEN', None) 348 if not token: 349 sys.exit('Github token not set in environment, please set the ' 350 'GITHUB_TOKEN environment variable and retry.') 351 352 gh = Github(token) 353 maintainer_file = Maintainers(args.maintainer_file) 354 355 if args.pull_request: 356 process_pr(gh, maintainer_file, args.pull_request) 357 elif args.issue: 358 process_issue(gh, maintainer_file, args.issue) 359 elif args.modules: 360 process_modules(gh, maintainer_file) 361 else: 362 if args.since: 363 since = args.since 364 else: 365 today = datetime.date.today() 366 since = today - datetime.timedelta(days=1) 367 368 common_prs = f'repo:{args.org}/{args.repo} is:open is:pr base:main -is:draft no:assignee created:>{since}' 369 pulls = gh.search_issues(query=f'{common_prs}') 370 371 for issue in pulls: 372 process_pr(gh, maintainer_file, issue.number) 373 374 375if __name__ == "__main__": 376 main() 377