summaryrefslogtreecommitdiff
path: root/.github
diff options
context:
space:
mode:
Diffstat (limited to '.github')
-rw-r--r--.github/pull_request_template.md8
-rw-r--r--.github/scripts/GitHub.py285
-rw-r--r--.github/scripts/RequestPrReviewers.py98
-rw-r--r--.github/scripts/requirements.txt13
-rw-r--r--.github/workflows/request-reviews.yml73
5 files changed, 473 insertions, 4 deletions
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 70e8c56..3ecabed 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -9,13 +9,13 @@
<_Delete lines in \<\> tags before creating the PR._>
- [ ] Breaking change?
- - **Breaking change** - Will this cause a break in build or boot behavior?
- - Examples: Add a new library class or move a module to a different repo.
+ - **Breaking change** - Does this PR cause a break in build or boot behavior?
+ - Examples: Does it add a new library class or move a module to a different repo.
- [ ] Impacts security?
- - **Security** - Does the change have a direct security impact?
+ - **Security** - Does this PR have a direct security impact?
- Examples: Crypto algorithm change or buffer overflow fix.
- [ ] Includes tests?
- - **Tests** - Does the change include any explicit test code?
+ - **Tests** - Does this PR include any explicit test code?
- Examples: Unit tests or integration tests.
## How This Was Tested
diff --git a/.github/scripts/GitHub.py b/.github/scripts/GitHub.py
new file mode 100644
index 0000000..628cd84
--- /dev/null
+++ b/.github/scripts/GitHub.py
@@ -0,0 +1,285 @@
+## @file
+# GitHub API helper functions.
+#
+# Copyright (c) Microsoft Corporation.
+# SPDX-License-Identifier: BSD-2-Clause-Patent
+#
+
+import git
+import logging
+import re
+
+from collections import OrderedDict
+from edk2toollib.utility_functions import RunPythonScript
+from github import Auth, Github, GithubException
+from io import StringIO
+from typing import List
+
+
+"""GitHub API helper functions."""
+
+
+def _authenticate(token: str):
+ """Authenticate to GitHub using a token.
+
+ Returns a GitHub instance that is authenticated using the provided
+ token.
+
+ Args:
+ token (str): The GitHub token to use for authentication.
+
+ Returns:
+ Github: A GitHub instance.
+ """
+ auth = Auth.Token(token)
+ return Github(auth=auth)
+
+
+def _get_pr(token: str, owner: str, repo: str, pr_number: int):
+ """Get the PR object from GitHub.
+
+ 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 (int): The pull request number.
+
+ Returns:
+ PullRequest: A PyGithub PullRequest object for the given pull request
+ or None if the attempt to get the PR fails.
+ """
+ try:
+ g = _authenticate(token)
+ return g.get_repo(f"{owner}/{repo}").get_pull(pr_number)
+ except GithubException as ge:
+ print(
+ f"::error title=Error Getting PR {pr_number} Info!::"
+ f"{ge.data['message']}"
+ )
+ return None
+
+
+def leave_pr_comment(
+ token: str, owner: str, repo: str, pr_number: int, 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 (int): The pull request number.
+ comment_body (str): The comment text. Markdown is supported.
+ """
+ if pr := _get_pr(token, owner, repo, pr_number):
+ try:
+ pr.create_issue_comment(comment_body)
+ except GithubException as ge:
+ print(
+ f"::error title=Error Commenting on PR {pr_number}!::"
+ f"{ge.data['message']}"
+ )
+
+
+def get_reviewers_for_range(
+ workspace_path: str,
+ maintainer_file_path: str,
+ range_start: str = "master",
+ range_end: str = "HEAD",
+) -> List[str]:
+ """Get the reviewers for the current branch.
+
+ !!! note
+ This function accepts a range of commits and returns the reviewers
+ for that set of commits as a single list of GitHub usernames. To get
+ the reviewers for a single commit, set `range_start` and `range_end`
+ to the commit SHA.
+
+ Args:
+ workspace_path (str): The workspace path.
+ maintainer_file_path (str): The maintainer file path.
+ range_start (str, optional): The range start ref. Defaults to "master".
+ range_end (str, optional): The range end ref. Defaults to "HEAD".
+
+ Returns:
+ List[str]: A list of GitHub usernames.
+ """
+ if range_start == range_end:
+ commits = [range_start]
+ else:
+ commits = [
+ c.hexsha
+ for c in git.Repo(workspace_path).iter_commits(
+ f"{range_start}..{range_end}"
+ )
+ ]
+
+ raw_reviewers = []
+ for commit_sha in commits:
+ reviewer_stream_buffer = StringIO()
+ cmd_ret = RunPythonScript(
+ maintainer_file_path,
+ f"-g {commit_sha}",
+ workingdir=workspace_path,
+ outstream=reviewer_stream_buffer,
+ logging_level=logging.INFO,
+ )
+ if cmd_ret != 0:
+ print(
+ f"::error title=Reviewer Lookup Error!::Error calling "
+ f"GetMaintainer.py: [{cmd_ret}]: "
+ f"{reviewer_stream_buffer.getvalue()}"
+ )
+ return []
+
+ commit_reviewers = reviewer_stream_buffer.getvalue()
+
+ pattern = r"\[(.*?)\]"
+ matches = re.findall(pattern, commit_reviewers)
+ if not matches:
+ return []
+
+ print(
+ f"::debug title=Commit {commit_sha[:7]} "
+ f"Reviewer(s)::{', '.join(matches)}"
+ )
+
+ raw_reviewers.extend(matches)
+
+ reviewers = list(OrderedDict.fromkeys([r.strip() for r in raw_reviewers]))
+
+ print(f"::debug title=Total Reviewer Set::{', '.join(reviewers)}")
+
+ return reviewers
+
+
+def get_pr_sha(token: str, owner: str, repo: str, pr_number: int) -> str:
+ """Returns the commit SHA of given PR branch.
+
+ This returns the SHA of the merge commit that GitHub creates from a
+ PR branch. This commit contains all of the files in the PR branch in
+ a single commit.
+
+ 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 (int): The pull request number.
+
+ Returns:
+ str: The commit SHA of the PR branch. An empty string is returned
+ if the request fails.
+ """
+ if pr := _get_pr(token, owner, repo, pr_number):
+ merge_commit_sha = pr.merge_commit_sha
+ print(f"::debug title=PR {pr_number} Merge Commit SHA::{merge_commit_sha}")
+ return merge_commit_sha
+
+ return ""
+
+
+def add_reviewers_to_pr(
+ token: str, owner: str, repo: str, pr_number: int, user_names: List[str]
+) -> 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 (int): The pull request number.
+ user_names (List[str]): List of GitHub usernames to add as reviewers.
+
+ Returns:
+ List[str]: A list of GitHub usernames that were successfully added as
+ reviewers to the PR. This list will exclude any reviewers
+ from the list provided if they are not relevant to the PR.
+ """
+ if not user_names:
+ print(
+ "::debug title=No PR Reviewers Requested!::"
+ "The list of PR reviewers is empty so not adding any reviewers."
+ )
+ return []
+
+ try:
+ g = _authenticate(token)
+ repo_gh = g.get_repo(f"{owner}/{repo}")
+ pr = repo_gh.get_pull(pr_number)
+ except GithubException as ge:
+ print(
+ f"::error title=Error Getting PR {pr_number} Info!::"
+ f"{ge.data['message']}"
+ )
+ return None
+
+ # The pull request author cannot be a reviewer.
+ pr_author = pr.user.login.strip()
+
+ # The current PR reviewers do not need to be requested again.
+ current_pr_requested_reviewers = [
+ r.login.strip() for r in pr.get_review_requests()[0]
+ ]
+ current_pr_reviewed_reviewers = [r.user.login.strip() for r in pr.get_reviews()]
+ current_pr_reviewers = list(
+ set(current_pr_requested_reviewers + current_pr_reviewed_reviewers)
+ )
+
+ # A user can only be added if they are a collaborator of the repository.
+ repo_collaborators = [c.login.strip() for c in repo_gh.get_collaborators()]
+ non_collaborators = [u for u in user_names if u not in repo_collaborators]
+
+ excluded_pr_reviewers = [pr_author] + current_pr_reviewers + non_collaborators
+ new_pr_reviewers = [u for u in user_names if u not in excluded_pr_reviewers]
+
+ # Notify the admins of the repository if non-collaborators are requested.
+ if non_collaborators:
+ print(
+ f"::warning title=Non-Collaborator Reviewers Found!::"
+ f"{', '.join(non_collaborators)}"
+ )
+
+ for comment in pr.get_issue_comments():
+ # If a comment has already been made for these non-collaborators,
+ # do not make another comment.
+ if (
+ comment.user.login == "github-actions[bot]"
+ and "WARNING: Cannot add some reviewers" in comment.body
+ and all(u in comment.body for u in non_collaborators)
+ ):
+ break
+ else:
+ repo_admins = [
+ a.login for a in repo_gh.get_collaborators(permission="admin")
+ ]
+
+ leave_pr_comment(
+ token,
+ owner,
+ repo,
+ pr_number,
+ f"&#9888; **WARNING: Cannot add some reviewers**: A user "
+ f"specified as a reviewer for this PR is not a collaborator "
+ f"of the repository. Please add them as a collaborator to "
+ f"the repository so they can be requested in the future.\n\n"
+ f"Non-collaborators requested:\n"
+ f"{'\n'.join([f'- @{c}' for c in non_collaborators])}"
+ f"\n\nAttn Admins:\n"
+ f"{'\n'.join([f'- @{a}' for a in repo_admins])}\n---\n"
+ f"**Admin Instructions:**\n"
+ f"- Add the non-collaborators as collaborators to the "
+ f"appropriate team(s) listed in "
+ f"[teams](https://github.com/orgs/tianocore/teams)\n"
+ f"- If they are no longer needed as reviewers, remove them "
+ f"from [`Maintainers.txt`](https://github.com/tianocore/edk2/blob/HEAD/Maintainers.txt)",
+ )
+
+ # Add any new reviewers to the PR if needed.
+ if new_pr_reviewers:
+ print(
+ f"::debug title=Adding New PR Reviewers::" f"{', '.join(new_pr_reviewers)}"
+ )
+
+ pr.create_review_request(reviewers=new_pr_reviewers)
+
+ return new_pr_reviewers
diff --git a/.github/scripts/RequestPrReviewers.py b/.github/scripts/RequestPrReviewers.py
new file mode 100644
index 0000000..fdff657
--- /dev/null
+++ b/.github/scripts/RequestPrReviewers.py
@@ -0,0 +1,98 @@
+## @file
+# Used in a CI workflow to request reviewers for a pull request.
+#
+# Refer to the following link for a list of pre-defined GitHub workflow
+# environment variables:
+# https://docs.github.com/actions/reference/environment-variables
+#
+# Copyright (c) Microsoft Corporation.
+# SPDX-License-Identifier: BSD-2-Clause-Patent
+#
+
+import git
+import GitHub
+import os
+import sys
+
+
+"""Request Pull Request Reviewers Helpers"""
+
+
+def request_pr_reviewers():
+ """Request pull request reviewers for a GitHub PR.
+
+ This function is intended to be used in a GitHub Actions workflow to
+ request reviewers for a pull request triggered by a GitHub event. The
+ function makes assumptions about GitHub workflow environment variables and
+ the pull request context in which it is run.
+
+ The function will exit with a non-zero status indicating an error if a
+ critical error occurs during execution so the workflow fails.
+
+ The following environment variables are expected to be set before calling
+ this function. The recommend GitHub context values are show for reference:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ ORG_NAME: ${{ github.repository_owner }}
+ PR_NUMBER: ${{ github.event.number}}
+ REPO_NAME: ${{ github.event.pull_request.base.repo.name }}
+ TARGET_BRANCH: ${{ github.event.pull_request.base.ref }}
+ WORKSPACE_PATH: ${{ github.workspace }}
+ """
+ WORKSPACE_PATH = os.environ["WORKSPACE_PATH"]
+ GET_MAINTAINER_LOCAL_PATH = os.path.join(
+ WORKSPACE_PATH, os.environ["GET_MAINTAINER_REL_PATH"]
+ )
+
+ # Step 1: Get the GitHub created PR commit SHA (contains all changes in a single commit)
+ pr_commit_sha = GitHub.get_pr_sha(
+ os.environ["GH_TOKEN"],
+ os.environ["ORG_NAME"],
+ os.environ["REPO_NAME"],
+ int(os.environ["PR_NUMBER"]),
+ )
+ if not pr_commit_sha:
+ sys.exit(1)
+
+ print(
+ f"::notice title=PR Commit SHA::Looking at files in consolidated PR commit: {pr_commit_sha}"
+ )
+
+ # Step 2: Fetch only the PR commit to get the files changed in the PR
+ git.Repo(WORKSPACE_PATH).remotes.origin.fetch(pr_commit_sha, depth=1)
+
+ # Step 3: Get the list of reviewers for the PR
+ reviewers = GitHub.get_reviewers_for_range(
+ WORKSPACE_PATH, GET_MAINTAINER_LOCAL_PATH, pr_commit_sha, pr_commit_sha
+ )
+ if not reviewers:
+ print("::notice title=No New Reviewers Found!::No reviewers found for this PR.")
+ sys.exit(0)
+
+ print(
+ f"::notice title=Preliminary Reviewer List::Total reviewer candidates for "
+ f"PR {os.environ['PR_NUMBER']}: {', '.join(reviewers)}"
+ )
+
+ # Step 4: Add the reviewers to the PR
+ # Note the final requested reviewer list in the workflow run for reference
+ new_reviewers = GitHub.add_reviewers_to_pr(
+ os.environ["GH_TOKEN"],
+ os.environ["ORG_NAME"],
+ os.environ["REPO_NAME"],
+ int(os.environ["PR_NUMBER"]),
+ reviewers,
+ )
+ if new_reviewers:
+ print(
+ f"::notice title=New Reviewers Added::New reviewers requested for PR "
+ f"{os.environ['PR_NUMBER']}: {', '.join(new_reviewers)}"
+ )
+ else:
+ print(
+ "::notice title=No New Reviewers Added::No reviewers were found that "
+ "should be newly requested."
+ )
+
+
+if __name__ == '__main__':
+ request_pr_reviewers()
diff --git a/.github/scripts/requirements.txt b/.github/scripts/requirements.txt
new file mode 100644
index 0000000..c589084
--- /dev/null
+++ b/.github/scripts/requirements.txt
@@ -0,0 +1,13 @@
+## @file
+# GitHub Helpers Python PIP requirements file
+#
+# This file provides the list of python components used in GitHub scripts in this repository.
+#
+# Copyright (c) Microsoft Corporation.
+# SPDX-License-Identifier: BSD-2-Clause-Patent
+#
+##
+
+edk2-pytool-library==0.*
+GitPython==3.*
+PyGithub==2.*
diff --git a/.github/workflows/request-reviews.yml b/.github/workflows/request-reviews.yml
new file mode 100644
index 0000000..e5db19c
--- /dev/null
+++ b/.github/workflows/request-reviews.yml
@@ -0,0 +1,73 @@
+
+# This workflow automatically adds the appropriate reviewers to a pull request.
+#
+# The workflow directly reuses logic in the BaseTools/Scripts/GetMaintainer.py script
+# to determine the appropriate reviewers, so it matches what a user would see running
+# the script locally.
+#
+# Copyright (c) Microsoft Corporation.
+# SPDX-License-Identifier: BSD-2-Clause-Patent
+
+name: Add Pull Request Reviewers
+
+on:
+ pull_request_target:
+ branches:
+ - master
+ types: [opened, ready_for_review, reopened, synchronize]
+
+env:
+ GET_MAINTAINER_REL_PATH: "BaseTools/Scripts/GetMaintainer.py"
+
+jobs:
+ auto-request-review:
+ name: Add Pull Request Reviewers
+ # Do not run on draft PRs and only run on PRs in the tianocore organization
+ if: ${{ github.event.pull_request.draft == false && github.repository_owner == 'tianocore' }}
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+
+ steps:
+ - name: Generate Token
+ id: generate-token
+ uses: actions/create-github-app-token@v1
+ with:
+ app-id: ${{ secrets.TIANOCORE_ASSIGN_REVIEWERS_APPLICATION_ID }}
+ private-key: ${{ secrets.TIANOCORE_ASSIGN_REVIEWERS_APPLICATION_PRIVATE_KEY }}
+
+ # Reduce checkout time with sparse-checkout
+ # - .github: Contains the scripts to interact with Github and add reviewers
+ # - BaseTools/Scripts: Contains the GetMaintainer.py script
+ # - Maintainers.txt: Contains the list of maintainers for the repository
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+ sparse-checkout: |
+ .github
+ BaseTools/Scripts
+ Maintainers.txt
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.x'
+ cache: 'pip'
+ cache-dependency-path: '.github/scripts/requirements.txt'
+
+ - name: Install PIP Modules
+ run: pip install -r .github/scripts/requirements.txt --upgrade
+
+ - name: Add Reviewers to Pull Request
+ env:
+ GH_TOKEN: ${{ steps.generate-token.outputs.token }}
+ ORG_NAME: ${{ github.repository_owner }}
+ PR_NUMBER: ${{ github.event.number}}
+ REPO_NAME: ${{ github.event.pull_request.base.repo.name }}
+ TARGET_BRANCH: ${{ github.event.pull_request.base.ref }}
+ WORKSPACE_PATH: ${{ github.workspace }}
+ run: python .github/scripts/RequestPrReviewers.py