Include the original HEAD in the porcelain output
[tools.git] / github-tools / stale.py
1 #! /usr/bin/env python3
2 # requires python 3
3 #
4 # A script to run daily that looks through OpenSSL github PRs
5 # and creates stats, next actions, and makes comments and closes
6 # stale issues.
7 #
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
11 #
12 # mark@openssl.org Feb 2020
13 #
14 import requests
15 import json
16 from datetime import datetime, timezone
17 from optparse import OptionParser
18 from statistics import median
19 import collections
20 import csv
21
22 api_url = "https://api.github.com/repos/openssl/openssl"
23
24 def convertdate(date):
25     return datetime.strptime(date.replace('Z',"+0000"), "%Y-%m-%dT%H:%M:%S%z")
26
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)
33     return
34
35 # Note: Closing an issue doesn't add a comment by itself
36
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)
47     return
48
49 # Get all the open pull requests, filtering by approval: done label
50
51 stale = collections.defaultdict(list)
52 now = datetime.now(timezone.utc)
53
54 def parsepr(pr, days):
55     if (debug):
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)
59     repos = res.json()
60     while 'next' in res.links.keys():
61         res = requests.get(res.links['next']['url'], headers=headers)
62         repos.extend(res.json())
63
64     comments = []
65     commentsall = []
66     readytomerge = 0
67     reviewed_state = ""
68     sha = ""
69
70     for event in repos:
71         if (debug):
72             print (event['event'])
73             print (event)
74             print ()
75         try:
76             eventdate = ""
77             if (event['event'] == "commented"):
78                 # we need to filter out any comments from OpenSSL Machine
79                 if "openssl-machine" in event['actor']['login']:
80                     if (debug):
81                         print("For stats ignoring automated comment by openssl-machine")
82                     commentsall.append(convertdate(event["updated_at"]))
83                 else:
84                     eventdate = event["updated_at"]
85             elif (event['event'] == "committed"):
86                 sha = event["sha"]                
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']                
97             if (eventdate != ""):
98                 comments.append(convertdate(eventdate))
99             if (debug):
100                 print(reviewed_state)
101         except:
102             return (repos['message'])
103
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
107         
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):
111         if (debug):
112             print("ignoring last event was",dayssincelastupdate,"days:",max(comments+commentsall))
113         return
114
115     labellist = []
116     if 'labels' in pr:
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)
122
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
125     
126     if ('title' in pr and 'WIP' in pr['title']):
127         return
128     
129     data = {'pr':pr['number'],'days':dayssincelastupdate,'alldays':dayssincelastupdateall,'labels':labels}
130     stale["all"].append(data)
131
132     if debug:
133         print (data)
134
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.  
139
140     if ('stalled: awaiting contributor response' in labels):
141         stale["waiting for reporter"].append(data)
142         return        
143     if ('hold: need omc' in labels or 'approval: omc' in labels):
144         stale["waiting for OMC"].append(data)
145         return
146     if ('hold: need otc' in labels or 'approval: otc' in labels):
147         stale["waiting for OTC"].append(data)
148         return
149     if ('hold: cla' in labels):
150         stale["cla required"].append(data)
151         return
152     if ('review pending' in labels):
153         stale["waiting for review"].append(data)
154         return
155     if ('reviewed:changes_requested' in labels):
156         stale["waiting for reporter"].append(data)
157         return
158
159     url = api_url + "/commits/" + sha + "/status"
160     res = requests.get(url, headers=headers)
161     if (res.status_code == 200):
162         ci = res.json()
163         if (ci['state'] != "success"): 
164             stale["failed CI"].append(data)
165             return
166
167     stale["all other"].append(data)    
168     return
169     
170
171 def getpullrequests(days):
172     url = api_url + "/pulls?per_page=100&page=1"  # defaults to open
173     res = requests.get(url, headers=headers)
174     repos = res.json()
175     prs = []
176     while 'next' in res.links.keys():
177         res = requests.get(res.links['next']['url'], headers=headers)
178         repos.extend(res.json())
179
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.
185     
186     # we can ignore anything with a created date less than the number of days we
187     # care about though
188     
189     try:
190         for pr in repos:
191             dayssincecreated = int((now - convertdate(pr['created_at'])).total_seconds() / (3600*24))            
192             if (dayssincecreated >= days):
193                 prs.append(pr)
194     except:
195         print("failed", repos['message'])
196     return prs
197
198 # main
199
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")
208
209 (options, args) = parser.parse_args()
210 if (options.token):
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
215 else:
216     print("error: you really need a token or you will hit the API limit in one run\n")
217     parser.print_help()
218     exit()
219 debug = options.debug
220 # since timeline is a preview feature we have to enable access to it with an accept header
221 headers = {
222     "Accept": "application/vnd.github.mockingbird-preview",
223     "Authorization": git_token
224 }
225 days = options.days or 31
226 if (options.output):
227     outputfp = open(options.output,"a")
228     outputcsv = csv.writer(outputfp)
229
230 prs = []
231 if (options.prs):
232     for prn in (options.prs).split(","):
233         pr = {}
234         pr['number']=int(prn)
235         prs.append(pr)
236
237 if (not prs):
238     if debug:
239         print("Getting list of open PRs not created within last",days,"days")
240     prs = getpullrequests(days)
241 if debug:
242     print("Open PRs we need to check", len(prs))
243
244 for pr in prs:
245     parsepr(pr, days)
246
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)
252             if (options.commit):
253                 addcommenttopr(item['pr'],comment)
254
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)
260             if (options.commit):
261                 addcommenttopr(item['pr'],comment)
262
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)
268             if (options.commit):
269                 addcommenttopr(item['pr'],comment)
270
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)
276             if (options.commit):
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)
281             if (options.commit):
282                 addcommenttopr(item['pr'],comment)                            
283
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)
289             if (options.commit):
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)
294             if (options.commit):
295                 addcommenttopr(item['pr'],comment)
296
297                 
298 for reason in stale:
299     days = []
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']])
304             
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")
308     else:
309         for item in stale[reason]:
310             print ("   ",item['pr'],item['labels'],"days:"+str(item['days']))