Skip to content

Commit 2e62aa0

Browse files
authored
Improve auto merge script some more (#658)
- Do most actions with a bot token instead of a cla-workable token - Check that the automerge labeler has write access to the repo. - Create and edit a status update comment. - Don't attempt to sync the branch if it's already known to be good. (Especially since the sync is a bit finnicky.)
1 parent 342bcc3 commit 2e62aa0

File tree

1 file changed

+187
-29
lines changed

1 file changed

+187
-29
lines changed

dev_tools/auto_merge.py

Lines changed: 187 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
from dev_tools.github_repository import GithubRepository
1111

1212

13+
cla_access_token = None
14+
15+
1316
class CurrentStuckGoToNextError(RuntimeError):
1417
pass
1518

@@ -84,6 +87,130 @@ def body(self) -> str:
8487
return self.payload['body']
8588

8689

90+
def check_collaborator_has_write(repo: GithubRepository, username: str):
91+
"""
92+
References:
93+
https://developer.github.com/v3/issues/events/#list-events-for-an-issue
94+
"""
95+
url = ("https://api.github.com/repos/{}/{}/collaborators/{}/permission"
96+
"?access_token={}".format(repo.organization,
97+
repo.name,
98+
username,
99+
repo.access_token))
100+
101+
response = requests.get(url)
102+
103+
if response.status_code != 200:
104+
raise RuntimeError(
105+
'Collaborator check failed. Code: {}. Content: {}.'.format(
106+
response.status_code, response.content))
107+
108+
payload = json.JSONDecoder().decode(response.content.decode())
109+
if payload['permission'] not in ['admin', 'write']:
110+
raise CurrentStuckGoToNextError(
111+
'Only collaborators with write permission can use automerge.')
112+
113+
114+
def check_auto_merge_labeler(repo: GithubRepository, pull_id: int):
115+
"""
116+
References:
117+
https://developer.github.com/v3/issues/events/#list-events-for-an-issue
118+
"""
119+
url = ("https://api.github.com/repos/{}/{}/issues/{}/events"
120+
"?access_token={}".format(repo.organization,
121+
repo.name,
122+
pull_id,
123+
repo.access_token))
124+
125+
response = requests.get(url)
126+
127+
if response.status_code != 200:
128+
raise RuntimeError(
129+
'Event check failed. Code: {}. Content: {}.'.format(
130+
response.status_code, response.content))
131+
132+
payload = json.JSONDecoder().decode(response.content.decode())
133+
relevant = [event
134+
for event in payload
135+
if event['event'] == 'labeled' and
136+
event['label']['name'] == 'automerge']
137+
if not relevant:
138+
raise CurrentStuckGoToNextError('"automerge" label was never added.')
139+
140+
check_collaborator_has_write(repo, relevant[-1]['actor']['login'])
141+
142+
143+
def find_existing_status_comment(repo: GithubRepository, pull_id: int
144+
) -> Optional[Dict[str, Any]]:
145+
expected_user = 'CirqBot'
146+
expected_text = 'Automerge pending: '
147+
148+
comments = list_pr_comments(repo, pull_id)
149+
for comment in comments:
150+
if comment['user']['login'] == expected_user:
151+
if expected_text in comment['body']:
152+
return comment
153+
154+
return None
155+
156+
157+
def add_comment(repo: GithubRepository, pull_id: int, text: str) -> None:
158+
"""
159+
References:
160+
https://developer.github.com/v3/issues/comments/#create-a-comment
161+
"""
162+
url = ("https://api.github.com/repos/{}/{}/issues/{}/comments"
163+
"?access_token={}".format(repo.organization,
164+
repo.name,
165+
pull_id,
166+
repo.access_token))
167+
data = {
168+
'body': text
169+
}
170+
response = requests.post(url, json=data)
171+
172+
if response.status_code != 201:
173+
raise RuntimeError('Add comment failed. Code: {}. Content: {}.'.format(
174+
response.status_code, response.content))
175+
176+
177+
def edit_comment(repo: GithubRepository, text: str, comment_id: int) -> None:
178+
"""
179+
References:
180+
https://developer.github.com/v3/issues/comments/#edit-a-comment
181+
"""
182+
url = ("https://api.github.com/repos/{}/{}/issues/comments/{}"
183+
"?access_token={}".format(repo.organization,
184+
repo.name,
185+
comment_id,
186+
repo.access_token))
187+
data = {
188+
'body': text
189+
}
190+
response = requests.patch(url, json=data)
191+
192+
if response.status_code != 200:
193+
raise RuntimeError('Edit comment failed. Code: {}. Content: {}.'.format(
194+
response.status_code, response.content))
195+
196+
197+
def leave_status_comment(repo: GithubRepository,
198+
pull_id: int,
199+
success: Optional[bool],
200+
state_description: str) -> None:
201+
cur = find_existing_status_comment(repo, pull_id)
202+
if success:
203+
body = 'Automerge done.'
204+
elif success is None:
205+
body = 'Automerge pending: {}'.format(state_description)
206+
else:
207+
body = 'Automerge cancelled: {}'.format(state_description)
208+
if cur is None:
209+
add_comment(repo, pull_id, body)
210+
else:
211+
edit_comment(repo, body, cur['id'])
212+
213+
87214
def get_pr_check_status(pr: PullRequestDetails) -> Any:
88215
"""
89216
References:
@@ -138,40 +265,37 @@ def wait_for_status(repo: GithubRepository,
138265
while True:
139266
pr = PullRequestDetails.from_github(repo, pull_id)
140267
if pr.payload['state'] != 'open':
141-
raise CurrentStuckGoToNextError(
142-
'Not an open PR! {}'.format(pr.payload))
268+
raise CurrentStuckGoToNextError('Not an open PR.')
143269

144270
if prev_pr and pr.branch_sha == prev_pr.branch_sha:
145271
if fail_if_same:
146-
raise CurrentStuckGoToNextError(
147-
'Doing the same thing twice while expecting '
148-
'different results.')
272+
raise CurrentStuckGoToNextError("I think I'm stuck in a loop.")
149273
wait_a_tick()
150274
continue
151275

152276
if not pr.marked_automergeable:
153-
raise CurrentStuckGoToNextError(
154-
'Not labelled with "automerge".')
277+
raise CurrentStuckGoToNextError('"automerge" label was removed.')
155278
if pr.base_branch_name != 'master':
156-
raise CurrentStuckGoToNextError(
157-
'PR not merging into master: {}.'.format(pr.payload))
279+
raise CurrentStuckGoToNextError('Failed to merge into master.')
158280

159281
review_status = get_pr_review_status(pr)
160282
if not any(review['state'] == 'APPROVED' for review in review_status):
161-
raise CurrentStuckGoToNextError(
162-
'No approved review: {}'.format(review_status))
283+
raise CurrentStuckGoToNextError('No approved review.')
163284
if any(review['state'] == 'REQUEST_CHANGES'
164285
for review in review_status):
165-
raise CurrentStuckGoToNextError(
166-
'Review requesting changes: {}'.format(review_status))
286+
raise CurrentStuckGoToNextError('A review is requesting changes.')
167287

168288
check_status = get_pr_check_status(pr)
169289
if check_status['state'] == 'pending':
170290
wait_a_tick()
171291
continue
172292
if check_status['state'] != 'success':
173-
raise CurrentStuckGoToNextError(
174-
'A status check is failing: {}'.format(check_status))
293+
raise CurrentStuckGoToNextError('A status check is failing.')
294+
295+
if pr.payload['mergeable'] is None:
296+
# Github still computing mergeable flag.
297+
wait_a_tick()
298+
continue
175299

176300
return pr
177301

@@ -230,16 +354,17 @@ def watch_for_cla_restore(pr: PullRequestDetails):
230354
raise CurrentStuckGoToNextError('CLA stuck on no.')
231355

232356

233-
def list_pr_comments(pr: PullRequestDetails) -> List[Dict[str, Any]]:
357+
def list_pr_comments(repo: GithubRepository, pull_id: int
358+
) -> List[Dict[str, Any]]:
234359
"""
235360
References:
236361
https://developer.github.com/v3/issues/comments/#list-comments-on-an-issue
237362
"""
238363
url = ("https://api.github.com/repos/{}/{}/issues/{}/comments"
239-
"?access_token={}".format(pr.repo.organization,
240-
pr.repo.name,
241-
pr.pull_id,
242-
pr.repo.access_token))
364+
"?access_token={}".format(repo.organization,
365+
repo.name,
366+
pull_id,
367+
repo.access_token))
243368
response = requests.get(url)
244369
if response.status_code != 200:
245370
raise RuntimeError(
@@ -271,7 +396,7 @@ def find_spurious_coauthor_comment_id(pr: PullRequestDetails) -> Optional[int]:
271396
expected_text = ('one or more commits were authored or co-authored '
272397
'by someone other than the pull request submitter')
273398

274-
comments = list_pr_comments(pr)
399+
comments = list_pr_comments(pr.repo, pr.pull_id)
275400
for comment in comments:
276401
if comment['user']['login'] == expected_user:
277402
if expected_text in comment['body']:
@@ -284,7 +409,7 @@ def find_spurious_fixed_comment_id(pr: PullRequestDetails) -> Optional[int]:
284409
expected_user = 'googlebot'
285410
expected_text = 'A Googler has manually verified that the CLAs look good.'
286411

287-
comments = list_pr_comments(pr)
412+
comments = list_pr_comments(pr.repo, pr.pull_id)
288413
for comment in comments:
289414
if comment['user']['login'] == expected_user:
290415
if expected_text in comment['body']:
@@ -306,7 +431,8 @@ def fight_cla_bot(pr: PullRequestDetails) -> None:
306431
# Manually indicate that this is fine.
307432
add_labels_to_pr(pr.repo,
308433
pr.pull_id,
309-
'cla: yes')
434+
'cla: yes',
435+
override_token=cla_access_token)
310436

311437
spurious_comment_id = find_spurious_coauthor_comment_id(pr)
312438
if spurious_comment_id is not None:
@@ -335,7 +461,7 @@ def sync_with_master(pr: PullRequestDetails) -> bool:
335461
data = {
336462
'base': pr.branch_name,
337463
'head': master_sha,
338-
'commit_message': 'Update branch [automerge]'.format(pr.branch_name)
464+
'commit_message': 'Update branch (automerge)'.format(pr.branch_name)
339465
}
340466
response = requests.post(url, json=data)
341467

@@ -348,6 +474,10 @@ def sync_with_master(pr: PullRequestDetails) -> bool:
348474
# Already merged.
349475
return False
350476

477+
if response.status_code == 409:
478+
# Merge conflict.
479+
raise CurrentStuckGoToNextError("There's a merge conflict.")
480+
351481
raise RuntimeError('Sync with master failed. Code: {}. Content: {}.'.format(
352482
response.status_code, response.content))
353483

@@ -418,7 +548,8 @@ def auto_delete_pr_branch(pr: PullRequestDetails) -> bool:
418548

419549
def add_labels_to_pr(repo: GithubRepository,
420550
pull_id: int,
421-
*labels: str) -> None:
551+
*labels: str,
552+
override_token: str = None) -> None:
422553
"""
423554
References:
424555
https://developer.github.com/v3/issues/labels/#add-labels-to-an-issue
@@ -427,7 +558,7 @@ def add_labels_to_pr(repo: GithubRepository,
427558
"?access_token={}".format(repo.organization,
428559
repo.name,
429560
pull_id,
430-
repo.access_token))
561+
override_token or repo.access_token))
431562
response = requests.post(url, json=list(labels))
432563

433564
if response.status_code != 200:
@@ -517,11 +648,15 @@ def auto_merge_pull_request(repo: GithubRepository, pull_id: int) -> None:
517648
prev_pr = None
518649
fail_if_same = False
519650
while True:
651+
check_auto_merge_labeler(repo, pull_id)
652+
520653
print('Waiting for checks to succeed..', end='', flush=True)
521654
pr = wait_for_status(repo, pull_id, prev_pr, fail_if_same)
522655

656+
check_auto_merge_labeler(repo, pull_id)
657+
523658
print('\nChecks succeeded. Checking if synced...')
524-
if not pr.payload['mergeable']:
659+
if pr.payload['mergeable_state'] != 'clean':
525660
if sync_with_master(pr):
526661
print('Had to resync with master.')
527662
prev_pr = pr
@@ -548,14 +683,32 @@ def auto_merge_multiple_pull_requests(repo: GithubRepository,
548683
print('Automerging multiple PRs: {}'.format(pull_ids))
549684
for pull_id in pull_ids:
550685
try:
686+
leave_status_comment(repo,
687+
pull_id,
688+
None,
689+
'syncing and waiting...')
551690
auto_merge_pull_request(repo, pull_id)
691+
leave_status_comment(repo,
692+
pull_id,
693+
True,
694+
'merged successfully')
552695
except CurrentStuckGoToNextError as ex:
553696
print('#!\nPR#{} is stuck: {}'.format(pull_id, ex.args))
554697
print('Continuing to next.')
698+
leave_status_comment(repo,
699+
pull_id,
700+
False,
701+
ex.args[0])
702+
except Exception:
703+
leave_status_comment(repo, pull_id, False, 'Unexpected error.')
704+
raise
555705
finally:
556706
remove_label_from_pr(repo, pull_id, 'automerge')
557707
print('Finished attempting to automerge {}.'.format(pull_ids))
558708

709+
# Give some leeway for updates to propagate on github's side.
710+
time.sleep(5)
711+
559712

560713
def watch_for_auto_mergeable_pull_requests(repo: GithubRepository):
561714
while True:
@@ -570,10 +723,15 @@ def watch_for_auto_mergeable_pull_requests(repo: GithubRepository):
570723

571724

572725
def main():
726+
global cla_access_token
573727
pull_ids = [int(e) for e in sys.argv[1:]]
574-
access_token = os.getenv('CIRQ_GITHUB_PR_ACCESS_TOKEN')
728+
access_token = os.getenv('CIRQ_BOT_GITHUB_ACCESS_TOKEN')
729+
cla_access_token = os.getenv('CIRQ_BOT_ALT_CLA_GITHUB_ACCESS_TOKEN')
575730
if not access_token:
576-
print('CIRQ_GITHUB_PR_ACCESS_TOKEN not set.', file=sys.stderr)
731+
print('CIRQ_BOT_GITHUB_ACCESS_TOKEN not set.', file=sys.stderr)
732+
sys.exit(1)
733+
if not cla_access_token:
734+
print('CIRQ_BOT_ALT_CLA_GITHUB_ACCESS_TOKEN not set.', file=sys.stderr)
577735
sys.exit(1)
578736

579737
repo = GithubRepository(

0 commit comments

Comments
 (0)