| #!/usr/bin/env python3 | |
| # ===-- commit-access-review.py --------------------------------------------===# | |
| # | |
| # Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. | |
| # See https://llvm.org/LICENSE.txt for license information. | |
| # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception | |
| # | |
| # ===------------------------------------------------------------------------===# | |
| # | |
| # ===------------------------------------------------------------------------===# | |
| import concurrent.futures | |
| import datetime | |
| import github | |
| import re | |
| import requests | |
| import time | |
| import sys | |
| import re | |
| class User: | |
| THRESHOLD = 5 | |
| def __init__(self, name, triage_list): | |
| self.name = name | |
| self.authored = 0 | |
| self.merged = 0 | |
| self.reviewed = 0 | |
| self.triage_list = triage_list | |
| def add_authored(self, val=1): | |
| self.authored += val | |
| if self.meets_threshold(): | |
| print(self.name, "meets the threshold with authored commits") | |
| del self.triage_list[self.name] | |
| def set_authored(self, val): | |
| self.authored = 0 | |
| self.add_authored(val) | |
| def add_merged(self, val=1): | |
| self.merged += val | |
| if self.meets_threshold(): | |
| print(self.name, "meets the threshold with merged commits") | |
| del self.triage_list[self.name] | |
| def add_reviewed(self, val=1): | |
| self.reviewed += val | |
| if self.meets_threshold(): | |
| print(self.name, "meets the threshold with reviewed commits") | |
| del self.triage_list[self.name] | |
| def get_total(self): | |
| return self.authored + self.merged + self.reviewed | |
| def meets_threshold(self): | |
| return self.get_total() >= self.THRESHOLD | |
| def __repr__(self): | |
| return "{} : a: {} m: {} r: {}".format( | |
| self.name, self.authored, self.merged, self.reviewed | |
| ) | |
| def check_manual_requests( | |
| gh: github.Github, start_date: datetime.datetime | |
| ) -> list[str]: | |
| """ | |
| Return a list of users who have been asked since ``start_date`` if they | |
| want to keep their commit access or if they have applied for commit | |
| access since ``start_date`` | |
| """ | |
| query = """ | |
| query ($query: String!, $after: String) { | |
| search(query: $query, type: ISSUE, first: 100, after: $after) { | |
| nodes { | |
| ... on Issue { | |
| author { | |
| login | |
| } | |
| body | |
| } | |
| } | |
| pageInfo { | |
| hasNextPage | |
| endCursor | |
| } | |
| } | |
| } | |
| """ | |
| formatted_start_date = start_date.strftime("%Y-%m-%dT%H:%M:%S") | |
| variables = { | |
| "query": f"type:issue created:>{formatted_start_date} org:llvm repo:llvm-project label:infra:commit-access,infra:commit-access-request" | |
| } | |
| has_next_page = True | |
| users = [] | |
| while has_next_page: | |
| res_header, res_data = gh._Github__requester.graphql_query( | |
| query=query, variables=variables | |
| ) | |
| data = res_data["data"] | |
| for issue in data["search"]["nodes"]: | |
| users.extend([user[1:] for user in re.findall("@[^ ,\n]+", issue["body"])]) | |
| if issue["author"]: | |
| users.append(issue["author"]["login"]) | |
| has_next_page = data["search"]["pageInfo"]["hasNextPage"] | |
| if has_next_page: | |
| variables["after"] = data["search"]["pageInfo"]["endCursor"] | |
| return users | |
| def get_num_commits(gh: github.Github, user: str, start_date: datetime.datetime) -> int: | |
| """ | |
| Get number of commits that ``user`` has been made since ``start_date`. | |
| """ | |
| variables = { | |
| "owner": "llvm", | |
| "user": user, | |
| "start_date": start_date.strftime("%Y-%m-%dT%H:%M:%S"), | |
| } | |
| user_query = """ | |
| query ($user: String!) { | |
| user(login: $user) { | |
| id | |
| } | |
| } | |
| """ | |
| res_header, res_data = gh._Github__requester.graphql_query( | |
| query=user_query, variables=variables | |
| ) | |
| data = res_data["data"] | |
| variables["user_id"] = data["user"]["id"] | |
| query = """ | |
| query ($owner: String!, $user_id: ID!, $start_date: GitTimestamp!){ | |
| organization(login: $owner) { | |
| teams(query: "llvm-committers" first:1) { | |
| nodes { | |
| repositories { | |
| nodes { | |
| ref(qualifiedName: "main") { | |
| target { | |
| ... on Commit { | |
| history(since: $start_date, author: {id: $user_id }) { | |
| totalCount | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| """ | |
| count = 0 | |
| res_header, res_data = gh._Github__requester.graphql_query( | |
| query=query, variables=variables | |
| ) | |
| data = res_data["data"] | |
| for repo in data["organization"]["teams"]["nodes"][0]["repositories"]["nodes"]: | |
| count += int(repo["ref"]["target"]["history"]["totalCount"]) | |
| if count >= User.THRESHOLD: | |
| break | |
| return count | |
| def get_review_count( | |
| gh: github.Github, user: str, start_date: datetime.datetime | |
| ) -> int: | |
| """ | |
| Return the number of reviews that ``user`` has done since ``start_date``. | |
| """ | |
| query = """ | |
| query ($query: String!) { | |
| search(query: $query, type: ISSUE, first: 5) { | |
| issueCount | |
| } | |
| } | |
| """ | |
| formatted_start_date = start_date.strftime("%Y-%m-%dT%H:%M:%S") | |
| variables = { | |
| "owner": "llvm", | |
| "repo": "llvm-project", | |
| "user": user, | |
| "query": f"type:pr commenter:{user} -author:{user} merged:>{formatted_start_date} org:llvm", | |
| } | |
| res_header, res_data = gh._Github__requester.graphql_query( | |
| query=query, variables=variables | |
| ) | |
| data = res_data["data"] | |
| return int(data["search"]["issueCount"]) | |
| def count_prs(gh: github.Github, triage_list: dict, start_date: datetime.datetime): | |
| """ | |
| Fetch all the merged PRs for the project since ``start_date`` and update | |
| ``triage_list`` with the number of PRs merged for each user. | |
| """ | |
| query = """ | |
| query ($query: String!, $after: String) { | |
| search(query: $query, type: ISSUE, first: 100, after: $after) { | |
| issueCount, | |
| nodes { | |
| ... on PullRequest { | |
| author { | |
| login | |
| } | |
| mergedBy { | |
| login | |
| } | |
| } | |
| } | |
| pageInfo { | |
| hasNextPage | |
| endCursor | |
| } | |
| } | |
| } | |
| """ | |
| date_begin = start_date | |
| date_end = None | |
| while date_begin < datetime.datetime.now(): | |
| date_end = date_begin + datetime.timedelta(days=7) | |
| formatted_date_begin = date_begin.strftime("%Y-%m-%dT%H:%M:%S") | |
| formatted_date_end = date_end.strftime("%Y-%m-%dT%H:%M:%S") | |
| variables = { | |
| "query": f"type:pr is:merged merged:{formatted_date_begin}..{formatted_date_end} org:llvm", | |
| } | |
| has_next_page = True | |
| while has_next_page: | |
| print(variables) | |
| res_header, res_data = gh._Github__requester.graphql_query( | |
| query=query, variables=variables | |
| ) | |
| data = res_data["data"] | |
| for pr in data["search"]["nodes"]: | |
| # Users can be None if the user has been deleted. | |
| if not pr["author"]: | |
| continue | |
| author = pr["author"]["login"] | |
| if author in triage_list: | |
| triage_list[author].add_authored() | |
| if not pr["mergedBy"]: | |
| continue | |
| merger = pr["mergedBy"]["login"] | |
| if author == merger: | |
| continue | |
| if merger not in triage_list: | |
| continue | |
| triage_list[merger].add_merged() | |
| has_next_page = data["search"]["pageInfo"]["hasNextPage"] | |
| if has_next_page: | |
| variables["after"] = data["search"]["pageInfo"]["endCursor"] | |
| date_begin = date_end | |
| # 4 because that's how many cores the default github runners have. Also, if we | |
| # make this too high, we risk hitting some of GitHub's secondary rate limits. | |
| THREAD_POOL_MAX_WORKERS = 4 | |
| def main(): | |
| token = sys.argv[1] | |
| gh = github.Github(auth=github.Auth.Token(token)) | |
| org = gh.get_organization("llvm") | |
| repo = org.get_repo("llvm-project") | |
| one_year_ago = datetime.datetime.now() - datetime.timedelta(days=365) | |
| triage_list = {} | |
| for collaborator in repo.get_collaborators(permission="push"): | |
| triage_list[collaborator.login] = User(collaborator.login, triage_list) | |
| print("Start:", len(triage_list), "triagers") | |
| # Step 0 Check if users have requested commit access in the last year. | |
| for user in check_manual_requests(gh, one_year_ago): | |
| if user in triage_list: | |
| print(user, "requested commit access in the last year.") | |
| del triage_list[user] | |
| print("After Request Check:", len(triage_list), "triagers") | |
| # Step 1 count all PRs authored or merged | |
| count_prs(gh, triage_list, one_year_ago) | |
| print("After PRs:", len(triage_list), "triagers") | |
| if len(triage_list) == 0: | |
| sys.exit(0) | |
| # Step 2 check for reviews | |
| with concurrent.futures.ThreadPoolExecutor( | |
| max_workers=THREAD_POOL_MAX_WORKERS | |
| ) as executor: | |
| executor.map( | |
| lambda user: triage_list[user].add_reviewed( | |
| get_review_count(gh, user, one_year_ago) | |
| ), | |
| list(triage_list.keys()), | |
| ) | |
| print("After Reviews:", len(triage_list), "triagers") | |
| if len(triage_list) == 0: | |
| sys.exit(0) | |
| # Step 3 check for number of commits | |
| with concurrent.futures.ThreadPoolExecutor( | |
| max_workers=THREAD_POOL_MAX_WORKERS | |
| ) as executor: | |
| # Override the total number of commits to not double count commits and | |
| # authored PRs. | |
| executor.map( | |
| lambda user: triage_list[user].set_authored( | |
| get_num_commits(gh, user, one_year_ago) | |
| ), | |
| list(triage_list.keys()), | |
| ) | |
| print("After Commits:", len(triage_list), "triagers") | |
| print("Complete:", len(triage_list), "triagers") | |
| with open("triagers.log", "w") as triagers_log: | |
| for user in triage_list: | |
| print(triage_list[user].__repr__()) | |
| triagers_log.write(user + "\n") | |
| if __name__ == "__main__": | |
| main() |