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