Skip to content

Commit 8f6bb51

Browse files
authored
On PRs from external users, dismiss stale reviews when a new commit is pushed. (#572)
With our new project settings, when a PR is approved, that approval will stay valid even if further commits are pushed into that PR's branch. For external users, though, we want to invalidate those approvals if any additional commits are made subsequent to the original approval. In this case, we consider anyone without "admin" access to be an "external user", and any PR from a forked repo to be an "external PR". In either of those scenarios, stale approvals will be dismissed. This PR adds a workflow that runs on pull_request_target, which runs inside the `main` branch and has enough access to dismiss reviews. It also adds a script that allows you to dismiss reviews on a PR.
1 parent be461e5 commit 8f6bb51

File tree

3 files changed

+165
-11
lines changed

3 files changed

+165
-11
lines changed

.github/workflows/checks_secure.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Checks (secure)
2+
# These are run on base branch with read/write access.
3+
4+
on:
5+
pull_request_target:
6+
types: [synchronize]
7+
8+
jobs:
9+
dismiss_stale_approvals:
10+
# Dismiss stale approvals for non-admins or if this PR comes from a fork.
11+
runs-on: ubuntu-latest
12+
# Only if another commit was added to the PR.
13+
steps:
14+
- name: Check user permission
15+
id: check
16+
uses: scherermichael-oss/[email protected]
17+
# This action sets outputs.has-permission to '1' or ''
18+
with:
19+
required-permission: admin
20+
env:
21+
GITHUB_TOKEN: ${{ github.token }}
22+
- uses: actions/checkout@v2
23+
if: steps.check.outputs.has-permission != 1 || github.event.pull_request.head.repo.full_name != github.repository
24+
with:
25+
submodules: false
26+
- name: Setup python
27+
if: steps.check.outputs.has-permission != 1 || github.event.pull_request.head.repo.full_name != github.repository
28+
uses: actions/setup-python@v2
29+
with:
30+
python-version: 3.7
31+
- name: Install prerequisites
32+
if: steps.check.outputs.has-permission != 1 || github.event.pull_request.head.repo.full_name != github.repository
33+
run: pip install -r scripts/gha/requirements.txt
34+
- name: Dismiss reviews
35+
if: steps.check.outputs.has-permission != 1 || github.event.pull_request.head.repo.full_name != github.repository
36+
shell: bash
37+
run: |
38+
python scripts/gha/dismiss_reviews.py --token ${{github.token}} --pull_number ${{github.event.pull_request.number}} --review_state=APPROVED --message "🍞 Dismissed stale approval on external PR."

scripts/gha/dismiss_reviews.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Copyright 2021 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""A utility to dismiss PR reviews.
16+
17+
USAGE:
18+
python scripts/gha/dismiss_reviews.py \
19+
--token ${{github.token}} \
20+
--pull_number ${{needs.check_trigger.outputs.pr_number}} \
21+
[--message 'Message to be posted on the dismissal.'] \
22+
[--reviewer github_username] \
23+
[--review_state ANY|APPROVED|CHANGES_REQUESTED|COMMENTED|PENDING]
24+
"""
25+
26+
import datetime
27+
import shutil
28+
29+
from absl import app
30+
from absl import flags
31+
from absl import logging
32+
33+
import github
34+
35+
FLAGS = flags.FLAGS
36+
_DEFAULT_MESSAGE = "Dismissing stale review."
37+
38+
flags.DEFINE_string(
39+
"token", None,
40+
"github.token: A token to authenticate on your repository.")
41+
42+
flags.DEFINE_string(
43+
"pull_number", None,
44+
"Github's pull request #.")
45+
46+
flags.DEFINE_string(
47+
"message", _DEFAULT_MESSAGE,
48+
"Message to post on dismissed reviews")
49+
50+
flags.DEFINE_string(
51+
"reviewer", None,
52+
"Reviewer to dismiss (by username). If unspecified, dismiss all reviews.")
53+
54+
flags.DEFINE_enum(
55+
"review_state", "ANY", ["ANY", "APPROVED", "CHANGES_REQUESTED", "COMMENTED",
56+
"PENDING"],
57+
"Only dismiss reviews in this state. Specify ANY for any state.")
58+
59+
def main(argv):
60+
if len(argv) > 1:
61+
raise app.UsageError("Too many command-line arguments.")
62+
# Get list of reviews from PR.
63+
reviews = github.get_reviews(FLAGS.token, FLAGS.pull_number)
64+
logging.debug("Found %d reviews", len(reviews))
65+
66+
# Filter out already-dismissed reviews.
67+
reviews = [r for r in reviews if r['state'] != 'DISMISSED']
68+
# Filter by review_state if specified.
69+
if FLAGS.review_state != 'ANY':
70+
reviews = [r for r in reviews if r['state'] == FLAGS.review_state]
71+
# Filter by reviewer's username, if specified.
72+
if FLAGS.reviewer:
73+
reviews = [r for r in reviews if r['user']['login'] == FLAGS.reviewer]
74+
75+
if reviews:
76+
review_ids = [r['id'] for r in reviews]
77+
logging.debug("Dismissing reviews: %s", review_ids)
78+
for review_id in review_ids:
79+
github.dismiss_review(FLAGS.token, FLAGS.pull_number,
80+
review_id, FLAGS.message)
81+
82+
if __name__ == "__main__":
83+
flags.mark_flag_as_required("token")
84+
flags.mark_flag_as_required("pull_number")
85+
app.run(main)

scripts/gha/github.py

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@
3838
FIREBASE_URL = '%s/repos/%s/%s' % (BASE_URL, OWNER, REPO)
3939
logging.set_verbosity(logging.INFO)
4040

41-
def requests_retry_session(retries=RETRIES,
42-
backoff_factor=BACKOFF,
41+
def requests_retry_session(retries=RETRIES,
42+
backoff_factor=BACKOFF,
4343
status_forcelist=RETRY_STATUS):
4444
session = requests.Session()
4545
retry = Retry(total=retries,
@@ -84,7 +84,7 @@ def update_issue_comment(token, issue_number, comment):
8484

8585
def search_issues_by_label(label):
8686
"""https://docs.github.com/en/rest/reference/search#search-issues-and-pull-requests"""
87-
url = f'{BASE_URL}/search/issues?q=repo:{OWNER}/{REPO}+label:"{label}"+is:issue'
87+
url = f'{BASE_URL}/search/issues?q=repo:{OWNER}/{REPO}+label:"{label}"+is:issue'
8888
headers = {'Accept': 'application/vnd.github.v3+json'}
8989
with requests_retry_session().get(url, headers=headers, timeout=TIMEOUT) as response:
9090
logging.info("search_issues_by_label: %s response: %s", url, response)
@@ -93,7 +93,7 @@ def search_issues_by_label(label):
9393

9494
def list_comments(token, issue_number):
9595
"""https://docs.github.com/en/rest/reference/issues#list-issue-comments"""
96-
url = f'{FIREBASE_URL}/issues/{issue_number}/comments'
96+
url = f'{FIREBASE_URL}/issues/{issue_number}/comments'
9797
headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'}
9898
with requests_retry_session().get(url, headers=headers, timeout=TIMEOUT) as response:
9999
logging.info("list_comments: %s response: %s", url, response)
@@ -102,7 +102,7 @@ def list_comments(token, issue_number):
102102

103103
def add_comment(token, issue_number, comment):
104104
"""https://docs.github.com/en/rest/reference/issues#create-an-issue-comment"""
105-
url = f'{FIREBASE_URL}/issues/{issue_number}/comments'
105+
url = f'{FIREBASE_URL}/issues/{issue_number}/comments'
106106
headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'}
107107
data = {'body': comment}
108108
with requests.post(url, headers=headers, data=json.dumps(data), timeout=TIMEOUT) as response:
@@ -111,7 +111,7 @@ def add_comment(token, issue_number, comment):
111111

112112
def update_comment(token, comment_id, comment):
113113
"""https://docs.github.com/en/rest/reference/issues#update-an-issue-comment"""
114-
url = f'{FIREBASE_URL}/issues/comments/{comment_id}'
114+
url = f'{FIREBASE_URL}/issues/comments/{comment_id}'
115115
headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'}
116116
data = {'body': comment}
117117
with requests_retry_session().patch(url, headers=headers, data=json.dumps(data), timeout=TIMEOUT) as response:
@@ -120,15 +120,15 @@ def update_comment(token, comment_id, comment):
120120

121121
def delete_comment(token, comment_id):
122122
"""https://docs.github.com/en/rest/reference/issues#delete-an-issue-comment"""
123-
url = f'{FIREBASE_URL}/issues/comments/{comment_id}'
123+
url = f'{FIREBASE_URL}/issues/comments/{comment_id}'
124124
headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'}
125125
with requests.delete(url, headers=headers, timeout=TIMEOUT) as response:
126126
logging.info("delete_comment: %s response: %s", url, response)
127127

128128

129129
def add_label(token, issue_number, label):
130130
"""https://docs.github.com/en/rest/reference/issues#add-labels-to-an-issue"""
131-
url = f'{FIREBASE_URL}/issues/{issue_number}/labels'
131+
url = f'{FIREBASE_URL}/issues/{issue_number}/labels'
132132
headers={}
133133
headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'}
134134
data = [label]
@@ -138,15 +138,15 @@ def add_label(token, issue_number, label):
138138

139139
def delete_label(token, issue_number, label):
140140
"""https://docs.github.com/en/rest/reference/issues#delete-a-label"""
141-
url = f'{FIREBASE_URL}/issues/{issue_number}/labels/{label}'
141+
url = f'{FIREBASE_URL}/issues/{issue_number}/labels/{label}'
142142
headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'}
143143
with requests.delete(url, headers=headers, timeout=TIMEOUT) as response:
144144
logging.info("delete_label: %s response: %s", url, response)
145145

146146

147147
def list_artifacts(token, run_id):
148148
"""https://docs.github.com/en/rest/reference/actions#list-workflow-run-artifacts"""
149-
url = f'{FIREBASE_URL}/actions/runs/{run_id}/artifacts'
149+
url = f'{FIREBASE_URL}/actions/runs/{run_id}/artifacts'
150150
headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'}
151151
with requests_retry_session().get(url, headers=headers, timeout=TIMEOUT) as response:
152152
logging.info("list_artifacts: %s response: %s", url, response)
@@ -155,9 +155,40 @@ def list_artifacts(token, run_id):
155155

156156
def download_artifact(token, artifact_id, output_path):
157157
"""https://docs.github.com/en/rest/reference/actions#download-an-artifact"""
158-
url = f'{FIREBASE_URL}/actions/artifacts/{artifact_id}/zip'
158+
url = f'{FIREBASE_URL}/actions/artifacts/{artifact_id}/zip'
159159
headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'}
160160
with requests.get(url, headers=headers, stream=True, timeout=TIMEOUT) as response:
161161
logging.info("download_artifact: %s response: %s", url, response)
162162
with open(output_path, 'wb') as file:
163163
shutil.copyfileobj(response.raw, file)
164+
165+
166+
def dismiss_review(token, pull_number, review_id, message):
167+
"""https://docs.github.com/en/rest/reference/pulls#dismiss-a-review-for-a-pull-request"""
168+
url = f'{FIREBASE_URL}/pulls/{pull_number}/reviews/{review_id}/dismissals'
169+
headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'}
170+
data = {'message': message}
171+
with requests.put(url, headers=headers, data=json.dumps(data),
172+
stream=True, timeout=TIMEOUT) as response:
173+
logging.info("dismiss_review: %s response: %s", url, response)
174+
return response.json()
175+
176+
def get_reviews(token, pull_number):
177+
"""https://docs.github.com/en/rest/reference/pulls#list-reviews-for-a-pull-request"""
178+
url = f'{FIREBASE_URL}/pulls/{pull_number}/reviews'
179+
headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'}
180+
page = 1
181+
per_page = 100
182+
results = []
183+
keep_going = True
184+
while keep_going:
185+
params = {'per_page': per_page, 'page': page}
186+
page = page + 1
187+
keep_going = False
188+
with requests.get(url, headers=headers, params=params,
189+
stream=True, timeout=TIMEOUT) as response:
190+
logging.info("get_reviews: %s response: %s", url, response)
191+
results = results + response.json()
192+
# If exactly per_page results were retrieved, read the next page.
193+
keep_going = (len(response.json()) == per_page)
194+
return results

0 commit comments

Comments
 (0)