|
| 1 | +# Copyright 2025 - Pruna AI GmbH. All rights reserved. |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +import json |
| 16 | +import os |
| 17 | +import re |
| 18 | +from collections import Counter |
| 19 | +from pathlib import Path |
| 20 | + |
| 21 | +import github |
| 22 | +from github import Github |
| 23 | + |
| 24 | + |
| 25 | +def pattern_to_regex(pattern): |
| 26 | + """Turn a CODEOWNERS glob ``pattern`` into a regex for matching file paths.""" |
| 27 | + if pattern.startswith("/"): |
| 28 | + start_anchor = True |
| 29 | + pattern = re.escape(pattern[1:]) |
| 30 | + else: |
| 31 | + start_anchor = False |
| 32 | + pattern = re.escape(pattern) |
| 33 | + pattern = pattern.replace(r"\*", "[^/]*") |
| 34 | + if start_anchor: |
| 35 | + pattern = r"^\/?" + pattern # Allow an optional leading slash after the start of the string |
| 36 | + return pattern |
| 37 | + |
| 38 | + |
| 39 | +def get_file_owners(file_path, codeowners_lines): |
| 40 | + """Return owner logins for ``file_path`` using CODEOWNERS rules (last match wins).""" |
| 41 | + for line in reversed(codeowners_lines): |
| 42 | + line = line.split('#')[0].strip() |
| 43 | + if not line: |
| 44 | + continue |
| 45 | + |
| 46 | + parts = line.split() |
| 47 | + pattern = parts[0] |
| 48 | + # Can be empty, e.g. for dummy files with explicitly no owner |
| 49 | + owners = [owner.removeprefix("@") for owner in parts[1:]] |
| 50 | + |
| 51 | + file_regex = pattern_to_regex(pattern) |
| 52 | + if re.search(file_regex, file_path) is not None: |
| 53 | + return owners # It can be empty |
| 54 | + return [] |
| 55 | + |
| 56 | + |
| 57 | +def get_dispatch_owners(codeowners_lines): |
| 58 | + """Return fallback owners from the catch-all ``*`` CODEOWNERS rule.""" |
| 59 | + for line in codeowners_lines: |
| 60 | + line = line.split("#", 1)[0].strip() |
| 61 | + if not line: |
| 62 | + continue |
| 63 | + |
| 64 | + parts = line.split() |
| 65 | + if parts[0] == "*": |
| 66 | + return [owner.removeprefix("@") for owner in parts[1:]] |
| 67 | + |
| 68 | + return [] |
| 69 | + |
| 70 | + |
| 71 | +def main(): |
| 72 | + """Load the PR event, skip if reviews exist or reviewers are already requested, then request recent owners.""" |
| 73 | + script_dir = Path(__file__).parent.absolute() |
| 74 | + with open(script_dir / "codeowners_assignment") as f: |
| 75 | + codeowners_lines = f.readlines() |
| 76 | + |
| 77 | + g = Github(os.environ['GITHUB_TOKEN']) |
| 78 | + repo = g.get_repo("PrunaAI/pruna") |
| 79 | + with open(os.environ['GITHUB_EVENT_PATH']) as f: |
| 80 | + event = json.load(f) |
| 81 | + |
| 82 | + pr_number = event['pull_request']['number'] |
| 83 | + pr = repo.get_pull(pr_number) |
| 84 | + pr_author = pr.user.login |
| 85 | + |
| 86 | + # Skipping exceptions |
| 87 | + existing_reviews = list(pr.get_reviews()) |
| 88 | + if existing_reviews: |
| 89 | + return |
| 90 | + |
| 91 | + users_requested, teams_requested = pr.get_review_requests() |
| 92 | + users_requested = list(users_requested) |
| 93 | + if users_requested: |
| 94 | + return |
| 95 | + |
| 96 | + # Counting recent owner matches |
| 97 | + latest_owner_matches = Counter() |
| 98 | + for file in pr.get_files(): |
| 99 | + owners = set(get_file_owners(file.filename, codeowners_lines)) |
| 100 | + owners.discard(pr_author) |
| 101 | + if not owners: |
| 102 | + continue |
| 103 | + |
| 104 | + commits = repo.get_commits(path=file.filename) |
| 105 | + for commit in commits: |
| 106 | + if commit.author is None: |
| 107 | + continue |
| 108 | + |
| 109 | + login = commit.author.login |
| 110 | + if login in owners: |
| 111 | + latest_owner_matches[login] += file.changes |
| 112 | + break |
| 113 | + |
| 114 | + top_owners = [owner for owner, _ in latest_owner_matches.most_common(2)] |
| 115 | + |
| 116 | + if not top_owners: |
| 117 | + top_owners = [ |
| 118 | + owner for owner in get_dispatch_owners(codeowners_lines) |
| 119 | + if owner != pr_author |
| 120 | + ] |
| 121 | + try: |
| 122 | + pr.create_review_request(top_owners) |
| 123 | + except github.GithubException as e: |
| 124 | + raise e |
| 125 | + |
| 126 | + |
| 127 | +if __name__ == "__main__": |
| 128 | + main() |
0 commit comments