diff --git a/BaseTools/Scripts/GitHub.py b/BaseTools/Scripts/GitHub.py new file mode 100644 index 0000000000000..7214e8eea0e7a --- /dev/null +++ b/BaseTools/Scripts/GitHub.py @@ -0,0 +1,160 @@ +## @file +# GitHub API helper functions. +# +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: BSD-2-Clause-Patent +# + +import logging +import re +import requests + +from collections import OrderedDict +from edk2toollib.utility_functions import RunCmd +from io import StringIO +from typing import List + +"""GitHub API helper functions.""" + + +def leave_pr_comment( + token: str, owner: str, repo: str, pr_number: str, comment_body: str +): + """Leaves a comment on a PR. + + Args: + token (str): The GitHub token to use for authentication. + owner (str): The GitHub owner (organization) name. + repo (str): The GitHub repository name (e.g. 'edk2'). + pr_number (str): The pull request number. + comment_body (str): The comment text. Markdown is supported. + """ + url = f"https://api.github.com/repos/{owner}/{repo}/issues/{pr_number}/comments" + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + } + data = {"body": comment_body} + response = requests.post(url, json=data, headers=headers) + response.raise_for_status() + + +def get_reviewers_for_current_branch( + workspace_path: str, maintainer_file_path: str +) -> List[str]: + """Get the reviewers for the current branch. + + Args: + workspace_path (str): The workspace path. + maintainer_file_path (str): The maintainer file path. + + Returns: + List[str]: A list of GitHub usernames. + """ + + out_stream_buffer = StringIO() + cmd_ret = RunCmd( + "python", + f"{maintainer_file_path} -g", + workingdir=workspace_path, + outstream=out_stream_buffer, + logging_level=logging.INFO, + ) + if cmd_ret != 0: + print( + f"::error title=Reviewer Lookup Error!::Error calling GetMaintainer.py: [{cmd_ret}]: {out_stream_buffer.getvalue()}" + ) + return [] + + raw_reviewers = out_stream_buffer.getvalue().strip() + + pattern = r"\[(.*?)\]" + matches = re.findall(pattern, raw_reviewers) + if not matches: + return [] + matches = list(OrderedDict.fromkeys([match.strip() for match in matches])) + return matches + + +def download_gh_file(github_url: str, local_path: str, token=None): + """Downloads a file from GitHub. + + Args: + github_url (str): The GitHub raw file URL. + local_path (str): A local path to write the file contents to. + token (_type_, optional): A GitHub authentication token. + Only needed for a private repo. Defaults to None. + """ + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + + try: + response = requests.get(github_url, headers=headers) + response.raise_for_status() + except requests.exceptions.HTTPError: + print( + f"::error title=HTTP Error!::Error downloading {github_url}: {response.reason}" + ) + return + + with open(local_path, "w", encoding="utf-8") as file: + file.write(response.text) + + +def add_reviewers_to_pr( + token: str, owner: str, repo: str, pr_number: str, user_names: List[str] +): + """Adds the set of GitHub usernames as reviewers to the PR. + + Args: + token (str): The GitHub token to use for authentication. + owner (str): The GitHub owner (organization) name. + repo (str): The GitHub repository name (e.g. 'edk2'). + pr_number (str): The pull request number. + user_names (List[str]): List of GitHub usernames to add as reviewers. + """ + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + } + pr_author_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}" + url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/requested_reviewers" + + response = requests.get(pr_author_url, headers=headers) + if response.status_code != 200: + print(f"::error title=HTTP Error!::Error getting PR author: {response.reason}") + return + pr_author = response.json().get("user").get("login").strip() + while pr_author in user_names: + user_names.remove(pr_author) + data = {"reviewers": user_names} + response = requests.post(url, json=data, headers=headers) + try: + response.raise_for_status() + print(f"response is {response.json()}") + except requests.exceptions.HTTPError: + if ( + response.status_code == 422 + and "Reviews may only be requested from collaborators" + in response.json().get("message") + ): + print( + f"::error title=User is not a Collaborator!::{response.json().get('message')}" + ) + leave_pr_comment( + token, + owner, + repo, + pr_number, + f"⚠ **WARNING: Cannot add reviewers**: A user specified as a " + f"reviewer for this PR is not a collaborator " + f"of the edk2 repository. Please add them as a collaborator to the " + f"repository and re-request the review.\n\n" + f"Users requested:\n{', '.join(user_names)}", + ) + elif response.status_code == 422: + print( + "::error title=Invalid Request!::The request is invalid. " + "Verify the API request string." + )