1 #! /usr/bin/env python3
4 # A script to run daily that looks through OpenSSL github PRs
5 # and creates stats, next actions, and makes comments and closes
8 # note that we'd use pyGithub but we can't as it doesn't fully handle the timeline objects
9 # as of Feb 2020 and we might want to parse timeline if we want to ignore certain things
10 # from resetting 'updated' date
12 # mark@openssl.org Feb 2020
16 from datetime import datetime, timezone
17 from optparse import OptionParser
18 from statistics import median
22 api_url = "https://api.github.com/repos/openssl/openssl"
24 def convertdate(date):
25 return datetime.strptime(date.replace('Z',"+0000"), "%Y-%m-%dT%H:%M:%S%z")
27 def addcommenttopr(issue,comment):
28 newcomment = {"body":comment}
29 url = api_url + "/issues/" + str(issue) + "/comments"
30 res = requests.post(url, data=json.dumps(newcomment), headers=headers)
31 if (res.status_code != 201):
32 print("Error adding comment", res.status_code, res.content)
35 # Note: Closing an issue doesn't add a comment by itself
37 def closepr(issue,comment):
38 newcomment = {"body":comment}
39 url = api_url + "/issues/" + str(issue) + "/comments"
40 res = requests.post(url, data=json.dumps(newcomment), headers=headers)
41 if (res.status_code != 201):
42 print("Error adding comment", res.status_code, res.content)
43 url = api_url + "/issues/" + str(issue)
44 res = requests.patch(url, data=json.dumps({"state":"closed"}), headers=headers)
45 if (res.status_code != 200):
46 print("Error closing pr", res.status_code, res.content)
49 # Get all the open pull requests, filtering by approval: done label
51 stale = collections.defaultdict(list)
52 now = datetime.now(timezone.utc)
54 def parsepr(pr, days):
56 print ("Getting timeline for ",pr['number'])
57 url = api_url + "/issues/" + str(pr['number']) + "/timeline?per_page=100&page=1"
58 res = requests.get(url, headers=headers)
60 while 'next' in res.links.keys():
61 res = requests.get(res.links['next']['url'], headers=headers)
62 repos.extend(res.json())
72 print (event['event'])
77 if (event['event'] == "commented"):
78 # we need to filter out any comments from OpenSSL Machine
79 if "openssl-machine" in event['actor']['login']:
81 print("For stats ignoring automated comment by openssl-machine")
82 commentsall.append(convertdate(event["updated_at"]))
84 eventdate = event["updated_at"]
85 elif (event['event'] == "committed"):
87 eventdate = event["author"]["date"]
88 elif (event['event'] == "labeled" or event['event'] == "unlabeled"):
89 eventdate = event['created_at']
90 elif (event['event'] == "reviewed"):
91 reviewed_state = "reviewed:"+event['state'] # replace with last review
92 eventdate = event['submitted_at']
93 elif (event['event'] == "review_requested"):
94 # If a review was requested after changes requested, remove changes requested label
95 reviewed_state = "reviewed:review pending";
96 eventdate = event['created_at']
98 comments.append(convertdate(eventdate))
100 print(reviewed_state)
102 return (repos['message'])
104 # We want to ignore any comments made by our automated machine when
105 # looking if something is stale, but keep a note of when those comments
106 # were made so we don't spam issues
108 dayssincelastupdateall = int((now - max(comments+commentsall)).total_seconds() / (3600*24))
109 dayssincelastupdate = int((now - max(comments)).total_seconds() / (3600*24))
110 if (dayssincelastupdate < days):
112 print("ignoring last event was",dayssincelastupdate,"days:",max(comments+commentsall))
117 labellist=[str(x['name']) for x in pr['labels']]
118 if 'milestone' in pr and pr['milestone']:
119 labellist.append("milestone:"+pr['milestone']['title'])
120 labellist.append(reviewed_state)
121 labels = ", ".join(labellist)
123 # Ignore anything "tagged" as work in progress, although we could do this earlier
124 # do it here as we may wish, in the future, to still ping stale WIP items
126 if ('title' in pr and 'WIP' in pr['title']):
129 data = {'pr':pr['number'],'days':dayssincelastupdate,'alldays':dayssincelastupdateall,'labels':labels}
130 stale["all"].append(data)
135 # The order of these matter, we drop out after the first one that
136 # matches. Try to guess which is the most important 'next action'
137 # for example if something is for after 1.1.1 but is waiting for a CLA
138 # then we've time to get the CLA later, it's deferred.
140 if ('stalled: awaiting contributor response' in labels):
141 stale["waiting for reporter"].append(data)
143 if ('hold: need omc' in labels or 'approval: omc' in labels):
144 stale["waiting for OMC"].append(data)
146 if ('hold: need otc' in labels or 'approval: otc' in labels):
147 stale["waiting for OTC"].append(data)
149 if ('hold: cla' in labels):
150 stale["cla required"].append(data)
152 if ('review pending' in labels):
153 stale["waiting for review"].append(data)
155 if ('reviewed:changes_requested' in labels):
156 stale["waiting for reporter"].append(data)
159 url = api_url + "/commits/" + sha + "/status"
160 res = requests.get(url, headers=headers)
161 if (res.status_code == 200):
163 if (ci['state'] != "success"):
164 stale["failed CI"].append(data)
167 stale["all other"].append(data)
171 def getpullrequests(days):
172 url = api_url + "/pulls?per_page=100&page=1" # defaults to open
173 res = requests.get(url, headers=headers)
176 while 'next' in res.links.keys():
177 res = requests.get(res.links['next']['url'], headers=headers)
178 repos.extend(res.json())
180 # In theory we can use the updated_at date here for filtering, but in practice
181 # things reset it --- like for example when we added the CLA bot, also any
182 # comments we make to ping the PR. So we have to actually parse the timeline
183 # for each event. This is much slower but more accurate for our metrics and
184 # we don't run this very often.
186 # we can ignore anything with a created date less than the number of days we
191 dayssincecreated = int((now - convertdate(pr['created_at'])).total_seconds() / (3600*24))
192 if (dayssincecreated >= days):
195 print("failed", repos['message'])
200 parser = OptionParser()
201 parser.add_option("-v","--debug",action="store_true",help="be noisy",dest="debug")
202 parser.add_option("-t","--token",help="file containing github authentication token for example 'token 18asdjada...'",dest="token")
203 parser.add_option("-d","--days",help="number of days for something to be stale",type=int, dest="days")
204 parser.add_option("-D","--closedays",help="number of days for something to be closed. Will commit and close issues even without --commit flag",type=int, dest="closedays")
205 parser.add_option("-c","--commit",action="store_true",help="actually add comments to issues",dest="commit")
206 parser.add_option("-o","--output",dest="output",help="write a csv file out")
207 parser.add_option("-p","--prs",dest="prs",help="instead of looking at all open prs just look at these comma separated ones")
209 (options, args) = parser.parse_args()
211 fp = open(options.token, "r")
212 git_token = fp.readline().strip('\n')
213 if not " " in git_token:
214 git_token = "token "+git_token
216 print("error: you really need a token or you will hit the API limit in one run\n")
219 debug = options.debug
220 # since timeline is a preview feature we have to enable access to it with an accept header
222 "Accept": "application/vnd.github.mockingbird-preview",
223 "Authorization": git_token
225 days = options.days or 31
227 outputfp = open(options.output,"a")
228 outputcsv = csv.writer(outputfp)
232 for prn in (options.prs).split(","):
234 pr['number']=int(prn)
239 print("Getting list of open PRs not created within last",days,"days")
240 prs = getpullrequests(days)
242 print("Open PRs we need to check", len(prs))
247 if ("waiting for OMC" in stale):
248 for item in stale["waiting for OMC"]:
249 if (item['alldays']>=days):
250 comment = "This PR is in a state where it requires action by @openssl/omc but the last update was "+str(item['days'])+" days ago"
251 print (" ",item['pr'],comment)
253 addcommenttopr(item['pr'],comment)
255 if ("waiting for OTC" in stale):
256 for item in stale["waiting for OTC"]:
257 if (item['alldays']>=days):
258 comment = "This PR is in a state where it requires action by @openssl/otc but the last update was "+str(item['days'])+" days ago"
259 print (" ",item['pr'],comment)
261 addcommenttopr(item['pr'],comment)
263 if ("waiting for review" in stale):
264 for item in stale["waiting for review"]:
265 if (item['alldays']>=days):
266 comment = "This PR is in a state where it requires action by @openssl/committers but the last update was "+str(item['days'])+" days ago"
267 print (" ",item['pr'],comment)
269 addcommenttopr(item['pr'],comment)
271 if ("waiting for reporter" in stale):
272 for item in stale["waiting for reporter"]:
273 if (options.closedays and item['days']>=options.closedays):
274 comment = "This PR has been closed. It was waiting for the creator to make requested changes but it has not been updated for "+str(item['days'])+" days."
275 print (" ",item['pr'],comment)
277 closepr(item['pr'],comment)
278 elif (item['alldays']>=days):
279 comment = "This PR is waiting for the creator to make requested changes but it has not been updated for "+str(item['days'])+" days. If you have made changes or commented to the reviewer please make sure you re-request a review (see icon in the 'reviewers' section)."
280 print (" ",item['pr'],comment)
282 addcommenttopr(item['pr'],comment)
284 if ("cla required" in stale):
285 for item in stale["cla required"]:
286 if (options.closedays and item['days']>=options.closedays):
287 comment = "This PR has been closed. It was waiting for a CLA for "+str(item['days'])+" days."
288 print (" ",item['pr'],comment)
290 closepr(item['pr'],comment)
291 elif (item['alldays']>=days):
292 comment = "This PR has the label 'hold: cla required' and is stale: it has not been updated in "+str(item['days'])+" days. Note that this PR may be automatically closed in the future if no CLA is provided. For CLA help see https://www.openssl.org/policies/cla.html"
293 print (" ",item['pr'],comment)
295 addcommenttopr(item['pr'],comment)
300 for item in stale[reason]:
301 days.append(item['days'])
302 if options.output and reason !="all":
303 outputcsv.writerow([now,reason,item['pr'],item['labels'],item['days']])
305 print ("\n", reason," (", len(stale[reason]),"issues, median ",median(days)," days)\n"),
306 if (reason == "all" or "deferred" in reason):
307 print (" list of prs suppressed")
309 for item in stale[reason]:
310 print (" ",item['pr'],item['labels'],"days:"+str(item['days']))