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