1 | ## @file
|
---|
2 | # GitHub API helper functions.
|
---|
3 | #
|
---|
4 | # Copyright (c) Microsoft Corporation.
|
---|
5 | # SPDX-License-Identifier: BSD-2-Clause-Patent
|
---|
6 | #
|
---|
7 |
|
---|
8 | import git
|
---|
9 | import logging
|
---|
10 | import re
|
---|
11 |
|
---|
12 | from collections import OrderedDict
|
---|
13 | from edk2toollib.utility_functions import RunPythonScript
|
---|
14 | from github import Auth, Github, GithubException
|
---|
15 | from io import StringIO
|
---|
16 | from typing import List
|
---|
17 |
|
---|
18 |
|
---|
19 | """GitHub API helper functions."""
|
---|
20 |
|
---|
21 |
|
---|
22 | def _authenticate(token: str):
|
---|
23 | """Authenticate to GitHub using a token.
|
---|
24 |
|
---|
25 | Returns a GitHub instance that is authenticated using the provided
|
---|
26 | token.
|
---|
27 |
|
---|
28 | Args:
|
---|
29 | token (str): The GitHub token to use for authentication.
|
---|
30 |
|
---|
31 | Returns:
|
---|
32 | Github: A GitHub instance.
|
---|
33 | """
|
---|
34 | auth = Auth.Token(token)
|
---|
35 | return Github(auth=auth)
|
---|
36 |
|
---|
37 |
|
---|
38 | def _get_pr(token: str, owner: str, repo: str, pr_number: int):
|
---|
39 | """Get the PR object from GitHub.
|
---|
40 |
|
---|
41 | Args:
|
---|
42 | token (str): The GitHub token to use for authentication.
|
---|
43 | owner (str): The GitHub owner (organization) name.
|
---|
44 | repo (str): The GitHub repository name (e.g. 'edk2').
|
---|
45 | pr_number (int): The pull request number.
|
---|
46 |
|
---|
47 | Returns:
|
---|
48 | PullRequest: A PyGithub PullRequest object for the given pull request
|
---|
49 | or None if the attempt to get the PR fails.
|
---|
50 | """
|
---|
51 | try:
|
---|
52 | g = _authenticate(token)
|
---|
53 | return g.get_repo(f"{owner}/{repo}").get_pull(pr_number)
|
---|
54 | except GithubException as ge:
|
---|
55 | print(
|
---|
56 | f"::error title=Error Getting PR {pr_number} Info!::"
|
---|
57 | f"{ge.data['message']}"
|
---|
58 | )
|
---|
59 | return None
|
---|
60 |
|
---|
61 |
|
---|
62 | def leave_pr_comment(
|
---|
63 | token: str, owner: str, repo: str, pr_number: int, comment_body: str
|
---|
64 | ):
|
---|
65 | """Leaves a comment on a PR.
|
---|
66 |
|
---|
67 | Args:
|
---|
68 | token (str): The GitHub token to use for authentication.
|
---|
69 | owner (str): The GitHub owner (organization) name.
|
---|
70 | repo (str): The GitHub repository name (e.g. 'edk2').
|
---|
71 | pr_number (int): The pull request number.
|
---|
72 | comment_body (str): The comment text. Markdown is supported.
|
---|
73 | """
|
---|
74 | if pr := _get_pr(token, owner, repo, pr_number):
|
---|
75 | try:
|
---|
76 | pr.create_issue_comment(comment_body)
|
---|
77 | except GithubException as ge:
|
---|
78 | print(
|
---|
79 | f"::error title=Error Commenting on PR {pr_number}!::"
|
---|
80 | f"{ge.data['message']}"
|
---|
81 | )
|
---|
82 |
|
---|
83 |
|
---|
84 | def get_reviewers_for_range(
|
---|
85 | workspace_path: str,
|
---|
86 | maintainer_file_path: str,
|
---|
87 | range_start: str = "master",
|
---|
88 | range_end: str = "HEAD",
|
---|
89 | ) -> List[str]:
|
---|
90 | """Get the reviewers for the current branch.
|
---|
91 |
|
---|
92 | !!! note
|
---|
93 | This function accepts a range of commits and returns the reviewers
|
---|
94 | for that set of commits as a single list of GitHub usernames. To get
|
---|
95 | the reviewers for a single commit, set `range_start` and `range_end`
|
---|
96 | to the commit SHA.
|
---|
97 |
|
---|
98 | Args:
|
---|
99 | workspace_path (str): The workspace path.
|
---|
100 | maintainer_file_path (str): The maintainer file path.
|
---|
101 | range_start (str, optional): The range start ref. Defaults to "master".
|
---|
102 | range_end (str, optional): The range end ref. Defaults to "HEAD".
|
---|
103 |
|
---|
104 | Returns:
|
---|
105 | List[str]: A list of GitHub usernames.
|
---|
106 | """
|
---|
107 | if range_start == range_end:
|
---|
108 | commits = [range_start]
|
---|
109 | else:
|
---|
110 | commits = [
|
---|
111 | c.hexsha
|
---|
112 | for c in git.Repo(workspace_path).iter_commits(
|
---|
113 | f"{range_start}..{range_end}"
|
---|
114 | )
|
---|
115 | ]
|
---|
116 |
|
---|
117 | raw_reviewers = []
|
---|
118 | for commit_sha in commits:
|
---|
119 | reviewer_stream_buffer = StringIO()
|
---|
120 | cmd_ret = RunPythonScript(
|
---|
121 | maintainer_file_path,
|
---|
122 | f"-g {commit_sha}",
|
---|
123 | workingdir=workspace_path,
|
---|
124 | outstream=reviewer_stream_buffer,
|
---|
125 | logging_level=logging.INFO,
|
---|
126 | )
|
---|
127 | if cmd_ret != 0:
|
---|
128 | print(
|
---|
129 | f"::error title=Reviewer Lookup Error!::Error calling "
|
---|
130 | f"GetMaintainer.py: [{cmd_ret}]: "
|
---|
131 | f"{reviewer_stream_buffer.getvalue()}"
|
---|
132 | )
|
---|
133 | return []
|
---|
134 |
|
---|
135 | commit_reviewers = reviewer_stream_buffer.getvalue()
|
---|
136 |
|
---|
137 | pattern = r"\[(.*?)\]"
|
---|
138 | matches = re.findall(pattern, commit_reviewers)
|
---|
139 | if not matches:
|
---|
140 | return []
|
---|
141 |
|
---|
142 | print(
|
---|
143 | f"::debug title=Commit {commit_sha[:7]} "
|
---|
144 | f"Reviewer(s)::{', '.join(matches)}"
|
---|
145 | )
|
---|
146 |
|
---|
147 | raw_reviewers.extend(matches)
|
---|
148 |
|
---|
149 | reviewers = list(OrderedDict.fromkeys([r.strip() for r in raw_reviewers]))
|
---|
150 |
|
---|
151 | print(f"::debug title=Total Reviewer Set::{', '.join(reviewers)}")
|
---|
152 |
|
---|
153 | return reviewers
|
---|
154 |
|
---|
155 |
|
---|
156 | def get_pr_sha(token: str, owner: str, repo: str, pr_number: int) -> str:
|
---|
157 | """Returns the commit SHA of given PR branch.
|
---|
158 |
|
---|
159 | This returns the SHA of the merge commit that GitHub creates from a
|
---|
160 | PR branch. This commit contains all of the files in the PR branch in
|
---|
161 | a single commit.
|
---|
162 |
|
---|
163 | Args:
|
---|
164 | token (str): The GitHub token to use for authentication.
|
---|
165 | owner (str): The GitHub owner (organization) name.
|
---|
166 | repo (str): The GitHub repository name (e.g. 'edk2').
|
---|
167 | pr_number (int): The pull request number.
|
---|
168 |
|
---|
169 | Returns:
|
---|
170 | str: The commit SHA of the PR branch. An empty string is returned
|
---|
171 | if the request fails.
|
---|
172 | """
|
---|
173 | if pr := _get_pr(token, owner, repo, pr_number):
|
---|
174 | merge_commit_sha = pr.merge_commit_sha
|
---|
175 | print(f"::debug title=PR {pr_number} Merge Commit SHA::{merge_commit_sha}")
|
---|
176 | return merge_commit_sha
|
---|
177 |
|
---|
178 | return ""
|
---|
179 |
|
---|
180 |
|
---|
181 | def add_reviewers_to_pr(
|
---|
182 | token: str, owner: str, repo: str, pr_number: int, user_names: List[str]
|
---|
183 | ) -> List[str]:
|
---|
184 | """Adds the set of GitHub usernames as reviewers to the PR.
|
---|
185 |
|
---|
186 | Args:
|
---|
187 | token (str): The GitHub token to use for authentication.
|
---|
188 | owner (str): The GitHub owner (organization) name.
|
---|
189 | repo (str): The GitHub repository name (e.g. 'edk2').
|
---|
190 | pr_number (int): The pull request number.
|
---|
191 | user_names (List[str]): List of GitHub usernames to add as reviewers.
|
---|
192 |
|
---|
193 | Returns:
|
---|
194 | List[str]: A list of GitHub usernames that were successfully added as
|
---|
195 | reviewers to the PR. This list will exclude any reviewers
|
---|
196 | from the list provided if they are not relevant to the PR.
|
---|
197 | """
|
---|
198 | if not user_names:
|
---|
199 | print(
|
---|
200 | "::debug title=No PR Reviewers Requested!::"
|
---|
201 | "The list of PR reviewers is empty so not adding any reviewers."
|
---|
202 | )
|
---|
203 | return []
|
---|
204 |
|
---|
205 | try:
|
---|
206 | g = _authenticate(token)
|
---|
207 | repo_gh = g.get_repo(f"{owner}/{repo}")
|
---|
208 | pr = repo_gh.get_pull(pr_number)
|
---|
209 | except GithubException as ge:
|
---|
210 | print(
|
---|
211 | f"::error title=Error Getting PR {pr_number} Info!::"
|
---|
212 | f"{ge.data['message']}"
|
---|
213 | )
|
---|
214 | return None
|
---|
215 |
|
---|
216 | # The pull request author cannot be a reviewer.
|
---|
217 | pr_author = pr.user.login.strip()
|
---|
218 |
|
---|
219 | # The current PR reviewers do not need to be requested again.
|
---|
220 | current_pr_requested_reviewers = [
|
---|
221 | r.login.strip() for r in pr.get_review_requests()[0] if r
|
---|
222 | ]
|
---|
223 | current_pr_reviewed_reviewers = [
|
---|
224 | r.user.login.strip() for r in pr.get_reviews() if r and r.user
|
---|
225 | ]
|
---|
226 | current_pr_reviewers = list(
|
---|
227 | set(current_pr_requested_reviewers + current_pr_reviewed_reviewers)
|
---|
228 | )
|
---|
229 |
|
---|
230 | # A user can only be added if they are a collaborator of the repository.
|
---|
231 | repo_collaborators = [c.login.strip() for c in repo_gh.get_collaborators() if c]
|
---|
232 | non_collaborators = [u for u in user_names if u not in repo_collaborators]
|
---|
233 |
|
---|
234 | excluded_pr_reviewers = [pr_author] + current_pr_reviewers + non_collaborators
|
---|
235 | new_pr_reviewers = [u for u in user_names if u not in excluded_pr_reviewers]
|
---|
236 |
|
---|
237 | # Notify the admins of the repository if non-collaborators are requested.
|
---|
238 | if non_collaborators:
|
---|
239 | print(
|
---|
240 | f"::warning title=Non-Collaborator Reviewers Found!::"
|
---|
241 | f"{', '.join(non_collaborators)}"
|
---|
242 | )
|
---|
243 |
|
---|
244 | for comment in pr.get_issue_comments():
|
---|
245 | # If a comment has already been made for these non-collaborators,
|
---|
246 | # do not make another comment.
|
---|
247 | if (
|
---|
248 | comment.user
|
---|
249 | and comment.user.login == "tianocore-assign-reviewers[bot]"
|
---|
250 | and "WARNING: Cannot add some reviewers" in comment.body
|
---|
251 | and all(u in comment.body for u in non_collaborators)
|
---|
252 | ):
|
---|
253 | break
|
---|
254 | else:
|
---|
255 | repo_admins = [
|
---|
256 | a.login for a in repo_gh.get_collaborators(permission="admin") if a
|
---|
257 | ]
|
---|
258 |
|
---|
259 | leave_pr_comment(
|
---|
260 | token,
|
---|
261 | owner,
|
---|
262 | repo,
|
---|
263 | pr_number,
|
---|
264 | f"⚠ **WARNING: Cannot add some reviewers**: A user "
|
---|
265 | f"specified as a reviewer for this PR is not a collaborator "
|
---|
266 | f"of the repository. Please add them as a collaborator to "
|
---|
267 | f"the repository so they can be requested in the future.\n\n"
|
---|
268 | f"Non-collaborators requested:\n"
|
---|
269 | f"{'\n'.join([f'- @{c}' for c in non_collaborators])}"
|
---|
270 | f"\n\nAttn Admins:\n"
|
---|
271 | f"{'\n'.join([f'- @{a}' for a in repo_admins])}\n---\n"
|
---|
272 | f"**Admin Instructions:**\n"
|
---|
273 | f"- Add the non-collaborators as collaborators to the "
|
---|
274 | f"appropriate team(s) listed in "
|
---|
275 | f"[teams](https://github.com/orgs/tianocore/teams)\n"
|
---|
276 | f"- If they are no longer needed as reviewers, remove them "
|
---|
277 | f"from [`Maintainers.txt`](https://github.com/tianocore/edk2/blob/HEAD/Maintainers.txt)",
|
---|
278 | )
|
---|
279 |
|
---|
280 | # Add any new reviewers to the PR if needed.
|
---|
281 | if new_pr_reviewers:
|
---|
282 | print(
|
---|
283 | f"::debug title=Adding New PR Reviewers::" f"{', '.join(new_pr_reviewers)}"
|
---|
284 | )
|
---|
285 |
|
---|
286 | pr.create_review_request(reviewers=new_pr_reviewers)
|
---|
287 |
|
---|
288 | return new_pr_reviewers
|
---|