Skip to content

Commit 7cfa610

Browse files
committed
wait-for-pr-ci-completion.py: wait for Github PR CI
As the script name implies, wait for a Github PR's CI to complete. This script does no notification; it just waits -- when the CI completes, this script completes. You'll typically want to execute another command after this script completes, for example: ``` $ ./wait-for-pr-ci-completion.py \ --pr open-mpi/ompi#5731; \ pushover CI for PR5731 is done ``` where `pushover` is a script I use to send push notifications to my phone. See the comments at the beginning of this script to see its requirements and how to use it. This script may get copied out of the Open MPI script repo, so I took the liberty of including the license in the file. Signed-off-by: Jeff Squyres <[email protected]>
1 parent 47e71f7 commit 7cfa610

File tree

1 file changed

+296
-0
lines changed

1 file changed

+296
-0
lines changed
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Copyright (c) 2018 Jeff Squyres. All rights reserved.
4+
#
5+
# Additional copyrights may follow
6+
#
7+
# $HEADER$
8+
#
9+
# Redistribution and use in source and binary forms, with or without
10+
# modification, are permitted provided that the following conditions are
11+
# met:
12+
#
13+
# - Redistributions of source code must retain the above copyright
14+
# notice, this list of conditions and the following disclaimer.
15+
#
16+
# - Redistributions in binary form must reproduce the above copyright
17+
# notice, this list of conditions and the following disclaimer listed
18+
# in this license in the documentation and/or other materials
19+
# provided with the distribution.
20+
#
21+
# - Neither the name of the copyright holders nor the names of its
22+
# contributors may be used to endorse or promote products derived from
23+
# this software without specific prior written permission.
24+
#
25+
# The copyright holders provide no reassurances that the source code
26+
# provided does not infringe any patent, copyright, or any other
27+
# intellectual property rights of third parties. The copyright holders
28+
# disclaim any liability to any recipient for claims brought against
29+
# recipient by any third party for infringement of that parties
30+
# intellectual property rights.
31+
#
32+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
33+
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
34+
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
35+
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
36+
# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
37+
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
38+
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
39+
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
40+
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
41+
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
42+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
43+
#
44+
45+
'''This script waits for all the CI on a given PR to complete.
46+
47+
You typically want to run some kind of notifier after this script
48+
completes to let you know that all the CI has completed. For example:
49+
50+
$ ./wait-for-pr-ci-completion.py \
51+
--pr https://github.com/open-mpi/ompi/pull/5731; \
52+
pushover CI for PR5731 is done
53+
54+
where "pushover" is a notifier script that I use to send a push
55+
notification to my phone. I.e., the 'pushover' script will execute
56+
when the CI for PR 5731 completes.
57+
58+
-----
59+
60+
# Requirements:
61+
62+
1. You need the PyGithub python module
63+
2. You need a GitHub personal access token to use the GitHub API
64+
(through the PyGithub python module)
65+
66+
-----
67+
68+
## Installing PyGithub:
69+
70+
$ pip3 install pygithub
71+
72+
Docs:
73+
74+
https://github.com/PyGithub/PyGithub
75+
http://pygithub.readthedocs.io/
76+
77+
## Getting a Github personal access token
78+
79+
Go to https://github.com/settings/tokens and make a personal access
80+
token with full permissions to the repo and org.
81+
82+
You can pass the oauth token to this script in one of 3 ways:
83+
84+
1. Name the file 'oauth-token.txt' and have it in the PWD when you run
85+
this script.
86+
2. Pass the filename of the token via --oauth-file CLI options.
87+
3. Set the env variable GITHUB_OAUTH_TOKEN with the filename of your
88+
oauth token (pro tip: if you set it to the absolute filename, it
89+
will be found no matter what directory you run this script from).
90+
91+
'''
92+
93+
import os
94+
import time
95+
import http
96+
import logging
97+
import argparse
98+
import requests
99+
100+
from github import Github
101+
from urllib.parse import urlparse
102+
from datetime import datetime
103+
104+
#--------------------------------------------------------------------
105+
106+
default_delay = 60
107+
real_default_oauth_file = 'oauth-token.txt'
108+
109+
if 'GITHUB_OAUTH_TOKEN' in os.environ:
110+
default_oauth_file = os.environ['GITHUB_OAUTH_TOKEN']
111+
else:
112+
default_oauth_file = real_default_oauth_file
113+
114+
#--------------------------------------------------------------------
115+
116+
# Parse the CLI options
117+
118+
parser = argparse.ArgumentParser(description='Github actions.')
119+
120+
parser.add_argument('--pr', help='URL of PR')
121+
parser.add_argument('--debug', action='store_true', help='Be really verbose')
122+
parser.add_argument('--delay', default=default_delay,
123+
help='Delay this many seconds between checking')
124+
parser.add_argument('--oauth-file', default=default_oauth_file,
125+
help='Filename containinig OAuth token to access Github (default is "{file}")'
126+
.format(file=default_oauth_file))
127+
128+
args = parser.parse_args()
129+
130+
# Sanity check the CLI args
131+
132+
if not args.pr:
133+
print("Must specify a PR URL via --pr")
134+
exit(1)
135+
136+
if not os.path.exists(args.oauth_file):
137+
print("Cannot find oauth token file: {filename}"
138+
.format(filename=args.oauth_file))
139+
exit(1)
140+
141+
#--------------------------------------------------------------------
142+
143+
delay = args.delay
144+
145+
# Read the oAuth token file.
146+
# (you will need to supply this file yourself -- see the comment at
147+
# the top of this file)
148+
with open(args.oauth_file, 'r') as f:
149+
token = f.read().strip()
150+
g = Github(token)
151+
152+
#--------------------------------------------------------------------
153+
154+
log = logging.getLogger('GithubPRwaiter')
155+
level = logging.INFO
156+
if args.debug:
157+
level = logging.DEBUG
158+
log.setLevel(level)
159+
160+
ch = logging.StreamHandler()
161+
ch.setLevel(level)
162+
163+
format = '%(asctime)s %(levelname)s: %(message)s'
164+
formatter = logging.Formatter(format)
165+
166+
ch.setFormatter(formatter)
167+
168+
log.addHandler(ch)
169+
170+
#--------------------------------------------------------------------
171+
172+
# Pick apart the URL
173+
parts = urlparse(args.pr)
174+
path = parts.path
175+
vals = path.split('/')
176+
org = vals[1]
177+
repo = vals[2]
178+
pull = vals[3]
179+
num = vals[4]
180+
181+
full_name = os.path.join(org, repo)
182+
log.debug("Getting repo {r}...".format(r=full_name))
183+
repo = g.get_repo(full_name)
184+
185+
log.debug("Getting PR {pr}...".format(pr=num))
186+
pr = repo.get_pull(int(num))
187+
188+
log.info("PR {num}: {title}".format(num=num, title=pr.title))
189+
log.info("PR {num} is {state}".format(num=num, state=pr.state))
190+
if pr.state != "open":
191+
log.info("Nothing to do!".format(num=num))
192+
exit(0)
193+
194+
log.debug("PR head is {sha}".format(sha=pr.head.sha))
195+
196+
log.debug("Getting commits...")
197+
commits = pr.get_commits()
198+
199+
# Find the HEAD commit -- that's where the most recent statuses will be
200+
head_commit = None
201+
for c in commits:
202+
if c.sha == pr.head.sha:
203+
log.debug("Found HEAD commit: {sha}".format(sha=c.sha))
204+
head_commit = c
205+
break
206+
207+
if not head_commit:
208+
log.error("Did not find HEAD commit (!)")
209+
log.error("That's unexpected -- I'm going to abort...")
210+
exit(1)
211+
212+
#--------------------------------------------------------------------
213+
214+
# Main loop
215+
216+
done = False
217+
succeeded = None
218+
failed = None
219+
statuses = dict()
220+
while not done:
221+
# There can be a bunch of statuses from the same context. Take
222+
# only the *chronologically-last* status from each context.
223+
224+
# Note: put both the "head_commit.get_statuses()" *and* the "for s
225+
# in github_statuses" in the try block because some empirical
226+
# testing shows that pygithub may be obtaining statuses lazily
227+
# during the for loop (i.e., not during .get_statuses()).
228+
try:
229+
github_statuses = head_commit.get_statuses()
230+
for s in github_statuses:
231+
save = False
232+
if s.context not in statuses:
233+
save = True
234+
log.info("Found new {state} CI: {context} ({desc})"
235+
.format(context=s.context, state=s.state,
236+
desc=s.description))
237+
else:
238+
# s.updated_at is a python datetime. Huzzah!
239+
if s.updated_at > statuses[s.context].updated_at:
240+
log.info("Found update {state} CI: {context} ({desc})"
241+
.format(context=s.context, state=s.state,
242+
desc=s.description))
243+
save = True
244+
245+
if save:
246+
statuses[s.context] = s
247+
248+
except ConnectionResetError:
249+
log.error("Got Connection Reset. Sleeping and trying again...")
250+
time.sleep(5)
251+
continue
252+
except requests.exceptions.ConnectionError:
253+
log.error("Got Connection error. Sleeping and trying again...")
254+
time.sleep(5)
255+
continue
256+
except http.client.RemoteDisconnected:
257+
log.error("Got http Remote Disconnected. Sleeping and trying again...")
258+
time.sleep(5)
259+
continue
260+
except requests.exceptions.RemotedDisconnected:
261+
log.error("Got requests Remote Disconnected. Sleeping and trying again...")
262+
time.sleep(5)
263+
continue
264+
265+
done = True
266+
succeeded = list()
267+
failed = list()
268+
for context,status in statuses.items():
269+
if status.state == 'success':
270+
succeeded.append(status)
271+
elif status.state == 'failure':
272+
failed.append(status)
273+
elif status.state == 'pending':
274+
log.debug("Still waiting for {context}: {desc}"
275+
.format(context=context,
276+
desc=status.description))
277+
done = False
278+
else:
279+
log.warning("Got unknown status state: {state}"
280+
.format(state=status.state))
281+
exit(1)
282+
283+
if not done:
284+
log.debug("Waiting {delay} seconds...".format(delay=delay))
285+
time.sleep(delay)
286+
287+
log.info("All CI statuses are complete:")
288+
for s in succeeded:
289+
log.info("PASSED {context}: {desc}"
290+
.format(context=s.context,
291+
desc=s.description.strip()))
292+
for s in failed:
293+
log.info("FAILED {context}: {desc}"
294+
.format(context=s.context,
295+
desc=s.description.strip()))
296+
exit(0)

0 commit comments

Comments
 (0)