1010from dev_tools .github_repository import GithubRepository
1111
1212
13+ cla_access_token = None
14+
15+
1316class 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+
87214def 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
419549def 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 ('\n Checks 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 ('#!\n PR#{} 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
560713def 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
572725def 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