Skip to content

Commit 8302aed

Browse files
authored
Add project integration PR helper (#10376)
Sample PRs (external users): In the contact list: #10422 (comment) Previous contributed: #10419 (comment) Unknown contributor: #10416 (comment) Integrating new project: #10439 (comment) Skip commenting for internal members
1 parent f56ffe5 commit 8302aed

File tree

3 files changed

+317
-0
lines changed

3 files changed

+317
-0
lines changed

.github/workflows/pr_helper.yml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: PR helper
2+
on:
3+
pull_request:
4+
types:
5+
- opened
6+
branches:
7+
- master
8+
paths:
9+
- 'projects/**'
10+
11+
jobs:
12+
build:
13+
runs-on: ubuntu-latest
14+
15+
permissions:
16+
contents: read
17+
pull-requests: write
18+
19+
steps:
20+
- uses: actions/checkout@v3
21+
- name: Setup python environment
22+
uses: actions/setup-python@v3
23+
with:
24+
python-version: 3.8
25+
cache: pip
26+
cache-dependency-path: |
27+
infra/ci/requirements.txt
28+
29+
- name: Install dependencies
30+
run: |
31+
python -m pip install --upgrade pip
32+
pip install -r infra/ci/requirements.txt
33+
34+
- name: setup go environment
35+
uses: actions/setup-go@v4
36+
with:
37+
go-version: 'stable'
38+
- run: go install github.com/ossf/criticality_score/cmd/criticality_score@latest
39+
40+
- name: Check if authors are authorized to modify.
41+
id: checkAuthor
42+
env:
43+
GITHUBTOKEN: ${{secrets.GITHUB_TOKEN}}
44+
PRAUTHOR: ${{ github.event.pull_request.user.login }}
45+
PRNUMBER: ${{ github.event.pull_request.number }}
46+
run: python infra/pr_helper.py
47+
48+
- name: Leave comments
49+
if: env.IS_INTERNAL == 'FALSE'
50+
uses: actions/github-script@v6
51+
with:
52+
github-token: ${{secrets.GITHUB_TOKEN}}
53+
script: |
54+
github.rest.issues.createComment({
55+
issue_number: context.issue.number,
56+
owner: context.repo.owner,
57+
repo: context.repo.repo,
58+
body: '${{env.MESSAGE}}'
59+
})
60+
61+
- name: Add labels for valid PR
62+
if: env.IS_READY_FOR_MERGE == 'True'
63+
uses: actions/github-script@v6
64+
with:
65+
script: |
66+
github.rest.issues.addLabels({
67+
issue_number: context.issue.number,
68+
owner: context.repo.owner,
69+
repo: context.repo.repo,
70+
labels: ['Ready to merge']
71+
})

infra/ci/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ pylint==2.5.3
55
pytest==7.1.2
66
pytest-xdist==2.5.0
77
PyYAML==6.0
8+
requests==2.31.0
89
yapf==0.32.0

infra/pr_helper.py

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
#!/usr/bin/env python
2+
# Copyright 2023 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
################################################################################
17+
"""Adds comments for PR to provide more information for approvers."""
18+
import base64
19+
import json
20+
import os
21+
import subprocess
22+
23+
import requests
24+
import yaml
25+
26+
OWNER = 'google'
27+
REPO = 'oss-fuzz'
28+
GITHUB_URL = 'https://github.com/'
29+
API_URL = 'https://api.github.com'
30+
BASE_URL = f'{API_URL}/repos/{OWNER}/{REPO}'
31+
BRANCH = 'master'
32+
CRITICALITY_SCORE_PATH = '/home/runner/go/bin/criticality_score'
33+
34+
35+
def get_criticality_score(repo_url):
36+
"""Gets the criticality score of the project."""
37+
report = subprocess.run([
38+
CRITICALITY_SCORE_PATH, '--format', 'json',
39+
'-gcp-project-id=clusterfuzz-external', '-depsdev-disable', repo_url
40+
],
41+
capture_output=True,
42+
text=True)
43+
44+
report_dict = json.loads(report.stdout)
45+
return report_dict.get('default_score', 'N/A')
46+
47+
48+
def is_known_contributor(content, email):
49+
"""Checks if the author is in the contact list."""
50+
return (email == content.get('primary_contact') or
51+
email in content.get('vendor_ccs', []) or
52+
email in content.get('auto_ccs', []))
53+
54+
55+
def save_env(message, is_ready_for_merge, is_internal=False):
56+
"""Saves the outputs as environment variables."""
57+
with open(os.environ['GITHUB_ENV'], 'a') as github_env:
58+
github_env.write(f'MESSAGE={message}\n')
59+
github_env.write(f'IS_READY_FOR_MERGE={is_ready_for_merge}\n')
60+
github_env.write(f'IS_INTERNAL={is_internal}')
61+
62+
63+
def main():
64+
"""Verifies if a PR is ready to merge."""
65+
github = GithubHandler()
66+
67+
# Bypasses PRs of the internal members.
68+
if github.is_author_internal_member():
69+
save_env(None, None, True)
70+
return
71+
72+
message = ''
73+
is_ready_for_merge = True
74+
pr_author = github.get_pr_author()
75+
# Gets all modified projects path.
76+
projects_path = github.get_projects_path()
77+
verified, email = github.get_author_email()
78+
79+
for project_path in projects_path:
80+
project_url = f'{GITHUB_URL}/{OWNER}/{REPO}/tree/{BRANCH}/{project_path}'
81+
content_dict = github.get_project_yaml(project_path)
82+
83+
# Gets information for the new integrating project.
84+
if not content_dict:
85+
is_ready_for_merge = False
86+
new_project = github.get_integrated_project_info()
87+
repo_url = new_project.get('main_repo')
88+
if repo_url is None:
89+
message += (f'@{pr_author} is integrating a new project, '
90+
'but the `repo_url` is missing. '
91+
'The criticality score cannot be computed.<br/>')
92+
else:
93+
message += (f'@{pr_author} is integrating a new project:<br/>'
94+
f'- Main repo: {repo_url}<br/> - Criticality score: '
95+
f'{get_criticality_score(repo_url)}<br/>')
96+
continue
97+
98+
# Checks if the author is in the contact list.
99+
if email:
100+
if is_known_contributor(content_dict, email):
101+
# Checks if the email is verified.
102+
if verified:
103+
message += (
104+
f'@{pr_author} is either the primary contact or '
105+
f'is in the CCs list of [{project_path}]({project_url}).<br/>')
106+
continue
107+
message += (f'@{pr_author} is either the primary contact or '
108+
f'is in the CCs list of [{project_path}]({project_url}), '
109+
f'but their email {email} '
110+
'is not verified.<br/>')
111+
112+
# Checks the previous commits.
113+
commit_sha = github.has_author_modified_project(project_path)
114+
if commit_sha is None:
115+
message += (
116+
f'@{pr_author} is a new contributor to '
117+
f'[{project_path}]({project_url}). The PR must be approved by known '
118+
'contributors before it can be merged.<br/>')
119+
is_ready_for_merge = False
120+
continue
121+
122+
# If the previous commit is not associated with a pull request.
123+
pr_message = (f'@{pr_author} has previously contributed to '
124+
f'[{project_path}]({project_url}). The previous commit was '
125+
f'{GITHUB_URL}/{OWNER}/{REPO}/commit/{commit_sha}<br/>')
126+
127+
pr_url = github.get_pull_request_url(commit_sha)
128+
if pr_url is not None:
129+
pr_message = (f'@{pr_author} has previously contributed to '
130+
f'[{project_path}]({project_url}). '
131+
f'The previous PR was {pr_url}<br/>')
132+
message += pr_message
133+
134+
save_env(message, is_ready_for_merge, False)
135+
136+
137+
class GithubHandler:
138+
"""Github requests handler."""
139+
140+
def __init__(self):
141+
self._pr_author = os.environ['PRAUTHOR']
142+
self._token = os.environ['GITHUBTOKEN']
143+
self._pr_number = os.environ['PRNUMBER']
144+
self._headers = {
145+
'Authorization': f'Bearer {self._token}',
146+
'X-GitHub-Api-Version': '2022-11-28'
147+
}
148+
os.environ['GITHUB_AUTH_TOKEN'] = self._token
149+
150+
def get_pr_author(self):
151+
"""Gets the pr author user name."""
152+
return self._pr_author
153+
154+
def get_projects_path(self):
155+
"""Gets the current project path."""
156+
response = requests.get(f'{BASE_URL}/pulls/{self._pr_number}/files',
157+
headers=self._headers)
158+
159+
projects_path = set()
160+
for file in response.json():
161+
file_path = file['filename']
162+
dir_path = os.path.dirname(file_path)
163+
if dir_path is not None and dir_path.split(os.sep)[0] == 'projects':
164+
projects_path.add(dir_path)
165+
return list(projects_path)
166+
167+
def get_author_email(self):
168+
"""Retrieves the author's email address for a pull request,
169+
including non-public emails."""
170+
user_response = requests.get(f'{API_URL}/users/{self._pr_author}')
171+
if user_response.ok:
172+
email = user_response.json()['email']
173+
if email:
174+
return True, email
175+
176+
commits_response = requests.get(
177+
f'{BASE_URL}/pulls/{self._pr_number}/commits', headers=self._headers)
178+
if not commits_response.ok:
179+
return False, None
180+
email = commits_response.json()[0]['commit']['author']['email']
181+
verified = commits_response.json()[0]['commit']['verification']['verified']
182+
return verified, email
183+
184+
def get_project_yaml(self, project_path):
185+
"""Gets the project yaml file."""
186+
contents_url = f'{BASE_URL}/contents/{project_path}/project.yaml'
187+
return self.get_yaml_file_content(contents_url)
188+
189+
def get_yaml_file_content(self, contents_url):
190+
"""Gets yaml file content."""
191+
response = requests.get(contents_url, headers=self._headers)
192+
if not response.ok:
193+
return {}
194+
content = base64.b64decode(response.json()['content']).decode('UTF-8')
195+
return yaml.safe_load(content)
196+
197+
def get_integrated_project_info(self):
198+
"""Gets the new integrated project."""
199+
response = requests.get(f'{BASE_URL}/pulls/{self._pr_number}/files',
200+
headers=self._headers)
201+
202+
for file in response.json():
203+
file_path = file['filename']
204+
if 'project.yaml' in file_path:
205+
return self.get_yaml_file_content(file['contents_url'])
206+
207+
return None
208+
209+
def get_pull_request_url(self, commit):
210+
"""Gets the pull request url."""
211+
pr_response = requests.get(f'{BASE_URL}/commits/{commit}/pulls',
212+
headers=self._headers)
213+
if not pr_response.ok:
214+
return None
215+
return pr_response.json()[0]['html_url']
216+
217+
def is_author_internal_member(self):
218+
"""Returns if the author is an internal member."""
219+
response = requests.get(f'{BASE_URL}/contents/infra/MAINTAINERS.csv',
220+
headers=self._headers)
221+
if not response.ok:
222+
return False
223+
224+
maintainers = base64.b64decode(response.json()['content']).decode('UTF-8')
225+
for line in maintainers.split(os.linesep):
226+
if self._pr_author == line.split(',')[2]:
227+
return True
228+
229+
return False
230+
231+
def has_author_modified_project(self, project_path):
232+
"""Checks if the author has modified this project before."""
233+
commits_response = requests.get(
234+
f'{BASE_URL}/commits?path={project_path}&author={self._pr_author}',
235+
headers=self._headers)
236+
237+
if not commits_response.ok or not commits_response.json():
238+
return None
239+
240+
commit = commits_response.json()[0]
241+
return commit['sha']
242+
243+
244+
if __name__ == '__main__':
245+
main()

0 commit comments

Comments
 (0)