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