blob: f89f58c866cc51b1362c84748688e2dc3768fdf3 [file] [log] [blame]
[email protected]c73e5162011-09-21 23:16:121#!/usr/bin/env python
[email protected]40b76872012-03-21 01:07:442# Copyright (c) 2012 The Chromium Authors. All rights reserved.
[email protected]c73e5162011-09-21 23:16:123# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
[email protected]0bccb922011-09-22 19:22:226"""Get rietveld stats about the review you done, or forgot to do.
[email protected]c73e5162011-09-21 23:16:127
8Example:
[email protected]af52a502011-09-22 20:54:379 - my_reviews.py -r [email protected] -Q for stats for last quarter.
[email protected]c73e5162011-09-21 23:16:1210"""
11import datetime
[email protected]34dd5eb2011-09-23 21:11:1812import math
[email protected]c73e5162011-09-21 23:16:1213import optparse
14import os
15import sys
16
[email protected]cf6a5d22015-04-09 22:02:0017import auth
[email protected]c73e5162011-09-21 23:16:1218import rietveld
19
Daniel Cheng4b37ce62017-09-07 19:00:0220try:
21 import dateutil # pylint: disable=import-error
22 import dateutil.parser
23 from dateutil.relativedelta import relativedelta
24except ImportError:
25 print 'python-dateutil package required'
26 exit(1)
27
[email protected]c73e5162011-09-21 23:16:1228
[email protected]0bccb922011-09-22 19:22:2229def username(email):
[email protected]34dd5eb2011-09-23 21:11:1830 """Keeps the username of an email address."""
[email protected]0bccb922011-09-22 19:22:2231 return email.split('@', 1)[0]
[email protected]c73e5162011-09-21 23:16:1232
[email protected]0bccb922011-09-22 19:22:2233
[email protected]34dd5eb2011-09-23 21:11:1834def to_datetime(string):
35 """Load UTC time as a string into a datetime object."""
36 try:
37 # Format is 2011-07-05 01:26:12.084316
38 return datetime.datetime.strptime(
39 string.split('.', 1)[0], '%Y-%m-%d %H:%M:%S')
40 except ValueError:
41 return datetime.datetime.strptime(string, '%Y-%m-%d')
42
43
44def to_time(seconds):
45 """Convert a number of seconds into human readable compact string."""
46 prefix = ''
47 if seconds < 0:
48 prefix = '-'
49 seconds *= -1
50 minutes = math.floor(seconds / 60)
51 seconds -= minutes * 60
52 hours = math.floor(minutes / 60)
53 minutes -= hours * 60
54 days = math.floor(hours / 24)
55 hours -= days * 24
56 out = []
57 if days > 0:
58 out.append('%dd' % days)
59 if hours > 0 or days > 0:
60 out.append('%02dh' % hours)
61 if minutes > 0 or hours > 0 or days > 0:
62 out.append('%02dm' % minutes)
63 if seconds > 0 and not out:
64 # Skip seconds unless there's only seconds.
65 out.append('%02ds' % seconds)
66 return prefix + ''.join(out)
67
68
69class Stats(object):
70 def __init__(self):
71 self.total = 0
72 self.actually_reviewed = 0
[email protected]40b76872012-03-21 01:07:4473 self.latencies = []
[email protected]34dd5eb2011-09-23 21:11:1874 self.lgtms = 0
75 self.multiple_lgtms = 0
76 self.drive_by = 0
77 self.not_requested = 0
[email protected]2e36aad2011-09-25 00:50:4978 self.self_review = 0
[email protected]34dd5eb2011-09-23 21:11:1879
[email protected]34dd5eb2011-09-23 21:11:1880 self.percent_lgtm = 0.
81 self.percent_drive_by = 0.
82 self.percent_not_requested = 0.
[email protected]2e36aad2011-09-25 00:50:4983 self.days = 0
[email protected]34dd5eb2011-09-23 21:11:1884
[email protected]40b76872012-03-21 01:07:4485 @property
86 def average_latency(self):
87 if not self.latencies:
88 return 0
89 return sum(self.latencies) / float(len(self.latencies))
90
91 @property
92 def median_latency(self):
93 if not self.latencies:
94 return 0
95 length = len(self.latencies)
96 latencies = sorted(self.latencies)
97 if (length & 1) == 0:
[email protected]fbb00c42013-07-17 00:15:3998 return (latencies[length/2] + latencies[length/2-1]) / 2.
[email protected]40b76872012-03-21 01:07:4499 else:
100 return latencies[length/2]
101
102 @property
103 def percent_done(self):
104 if not self.total:
105 return 0
106 return self.actually_reviewed * 100. / self.total
107
108 @property
109 def review_per_day(self):
110 if not self.days:
111 return 0
112 return self.total * 1. / self.days
113
114 @property
115 def review_done_per_day(self):
116 if not self.days:
117 return 0
118 return self.actually_reviewed * 1. / self.days
[email protected]34dd5eb2011-09-23 21:11:18119
120 def finalize(self, first_day, last_day):
[email protected]34dd5eb2011-09-23 21:11:18121 if self.actually_reviewed:
[email protected]17f52822013-06-03 23:40:10122 assert self.actually_reviewed > 0
[email protected]34dd5eb2011-09-23 21:11:18123 self.percent_lgtm = (self.lgtms * 100. / self.actually_reviewed)
124 self.percent_drive_by = (self.drive_by * 100. / self.actually_reviewed)
125 self.percent_not_requested = (
126 self.not_requested * 100. / self.actually_reviewed)
[email protected]17f52822013-06-03 23:40:10127 assert bool(first_day) == bool(last_day)
[email protected]34dd5eb2011-09-23 21:11:18128 if first_day and last_day:
[email protected]dc33eae2014-12-11 17:15:26129 assert first_day <= last_day
[email protected]34dd5eb2011-09-23 21:11:18130 self.days = (to_datetime(last_day) - to_datetime(first_day)).days + 1
[email protected]17f52822013-06-03 23:40:10131 assert self.days > 0
[email protected]34dd5eb2011-09-23 21:11:18132
133
134def _process_issue_lgtms(issue, reviewer, stats):
135 """Calculates LGTMs stats."""
136 stats.actually_reviewed += 1
137 reviewer_lgtms = len([
138 msg for msg in issue['messages']
139 if msg['approval'] and msg['sender'] == reviewer])
140 if reviewer_lgtms > 1:
141 stats.multiple_lgtms += 1
142 return ' X '
143 if reviewer_lgtms:
144 stats.lgtms += 1
145 return ' x '
146 else:
147 return ' o '
148
149
150def _process_issue_latency(issue, reviewer, stats):
151 """Calculates latency for an issue that was actually reviewed."""
152 from_owner = [
153 msg for msg in issue['messages'] if msg['sender'] == issue['owner_email']
154 ]
155 if not from_owner:
156 # Probably requested by email.
157 stats.not_requested += 1
158 return '<no rqst sent>'
159
160 first_msg_from_owner = None
161 latency = None
162 received = False
163 for index, msg in enumerate(issue['messages']):
164 if not first_msg_from_owner and msg['sender'] == issue['owner_email']:
165 first_msg_from_owner = msg
166 if index and not received and msg['sender'] == reviewer:
167 # Not first email, reviewer never received one, reviewer sent a mesage.
168 stats.drive_by += 1
169 return '<drive-by>'
170 received |= reviewer in msg['recipients']
171
172 if first_msg_from_owner and msg['sender'] == reviewer:
173 delta = msg['date'] - first_msg_from_owner['date']
174 latency = delta.seconds + delta.days * 24 * 3600
175 break
176
177 if latency is None:
178 stats.not_requested += 1
179 return '<no rqst sent>'
180 if latency > 0:
[email protected]40b76872012-03-21 01:07:44181 stats.latencies.append(latency)
[email protected]34dd5eb2011-09-23 21:11:18182 else:
183 stats.not_requested += 1
184 return to_time(latency)
185
186
187def _process_issue(issue):
188 """Preprocesses the issue to simplify the remaining code."""
189 issue['owner_email'] = username(issue['owner_email'])
190 issue['reviewers'] = set(username(r) for r in issue['reviewers'])
191 # By default, hide commit-bot.
192 issue['reviewers'] -= set(['commit-bot'])
193 for msg in issue['messages']:
194 msg['sender'] = username(msg['sender'])
195 msg['recipients'] = [username(r) for r in msg['recipients']]
196 # Convert all times to datetime instances.
197 msg['date'] = to_datetime(msg['date'])
198 issue['messages'].sort(key=lambda x: x['date'])
199
200
201def print_issue(issue, reviewer, stats):
202 """Process an issue and prints stats about it."""
203 stats.total += 1
204 _process_issue(issue)
[email protected]2e36aad2011-09-25 00:50:49205 if issue['owner_email'] == reviewer:
206 stats.self_review += 1
207 latency = '<self review>'
208 reviewed = ''
209 elif any(msg['sender'] == reviewer for msg in issue['messages']):
[email protected]34dd5eb2011-09-23 21:11:18210 reviewed = _process_issue_lgtms(issue, reviewer, stats)
211 latency = _process_issue_latency(issue, reviewer, stats)
212 else:
213 latency = 'N/A'
214 reviewed = ''
215
216 # More information is available, print issue.keys() to see them.
217 print '%7d %10s %3s %14s %-15s %s' % (
218 issue['issue'],
219 issue['created'][:10],
220 reviewed,
221 latency,
222 issue['owner_email'],
223 ', '.join(sorted(issue['reviewers'])))
224
225
[email protected]cf6a5d22015-04-09 22:02:00226def print_reviews(
227 reviewer, created_after, created_before, instance_url, auth_config):
[email protected]34dd5eb2011-09-23 21:11:18228 """Prints issues |reviewer| received and potentially reviewed."""
[email protected]cf6a5d22015-04-09 22:02:00229 remote = rietveld.Rietveld(instance_url, auth_config)
[email protected]34dd5eb2011-09-23 21:11:18230
231 # The stats we gather. Feel free to send me a CL to get more stats.
232 stats = Stats()
233
[email protected]34dd5eb2011-09-23 21:11:18234 # Column sizes need to match print_issue() output.
235 print >> sys.stderr, (
236 'Issue Creation Did Latency Owner Reviewers')
[email protected]c73e5162011-09-21 23:16:12237
238 # See def search() in rietveld.py to see all the filters you can use.
[email protected]17f52822013-06-03 23:40:10239 issues = []
[email protected]c73e5162011-09-21 23:16:12240 for issue in remote.search(
[email protected]c73e5162011-09-21 23:16:12241 reviewer=reviewer,
242 created_after=created_after,
243 created_before=created_before,
[email protected]34dd5eb2011-09-23 21:11:18244 with_messages=True):
[email protected]17f52822013-06-03 23:40:10245 issues.append(issue)
[email protected]34dd5eb2011-09-23 21:11:18246 print_issue(issue, username(reviewer), stats)
[email protected]17f52822013-06-03 23:40:10247
248 issues.sort(key=lambda x: x['created'])
249 first_day = None
250 last_day = None
251 if issues:
252 first_day = issues[0]['created'][:10]
253 last_day = issues[-1]['created'][:10]
[email protected]34dd5eb2011-09-23 21:11:18254 stats.finalize(first_day, last_day)
[email protected]c73e5162011-09-21 23:16:12255
[email protected]34dd5eb2011-09-23 21:11:18256 print >> sys.stderr, (
[email protected]2e36aad2011-09-25 00:50:49257 '%s reviewed %d issues out of %d (%1.1f%%). %d were self-review.' %
258 (reviewer, stats.actually_reviewed, stats.total, stats.percent_done,
259 stats.self_review))
[email protected]34dd5eb2011-09-23 21:11:18260 print >> sys.stderr, (
261 '%4.1f review request/day during %3d days (%4.1f r/d done).' % (
262 stats.review_per_day, stats.days, stats.review_done_per_day))
263 print >> sys.stderr, (
264 '%4d were drive-bys (%5.1f%% of reviews done).' % (
265 stats.drive_by, stats.percent_drive_by))
266 print >> sys.stderr, (
267 '%4d were requested over IM or irc (%5.1f%% of reviews done).' % (
268 stats.not_requested, stats.percent_not_requested))
269 print >> sys.stderr, (
270 ('%4d issues LGTM\'d (%5.1f%% of reviews done),'
271 ' gave multiple LGTMs on %d issues.') % (
272 stats.lgtms, stats.percent_lgtm, stats.multiple_lgtms))
273 print >> sys.stderr, (
274 'Average latency from request to first comment is %s.' %
275 to_time(stats.average_latency))
[email protected]40b76872012-03-21 01:07:44276 print >> sys.stderr, (
277 'Median latency from request to first comment is %s.' %
278 to_time(stats.median_latency))
[email protected]c73e5162011-09-21 23:16:12279
280
[email protected]cf6a5d22015-04-09 22:02:00281def print_count(
282 reviewer, created_after, created_before, instance_url, auth_config):
283 remote = rietveld.Rietveld(instance_url, auth_config)
[email protected]b7704322011-09-22 15:22:05284 print len(list(remote.search(
[email protected]b7704322011-09-22 15:22:05285 reviewer=reviewer,
286 created_after=created_after,
287 created_before=created_before,
[email protected]0bccb922011-09-22 19:22:22288 keys_only=True)))
[email protected]b7704322011-09-22 15:22:05289
290
[email protected]c73e5162011-09-21 23:16:12291def get_previous_quarter(today):
292 """There are four quarters, 01-03, 04-06, 07-09, 10-12.
293
294 If today is in the last month of a quarter, assume it's the current quarter
295 that is requested.
296 """
[email protected]5e6868b2011-09-22 20:15:07297 end_year = today.year
298 end_month = today.month - (today.month % 3) + 1
299 if end_month <= 0:
300 end_year -= 1
301 end_month += 12
302 if end_month > 12:
303 end_year += 1
304 end_month -= 12
305 end = '%d-%02d-01' % (end_year, end_month)
306 begin_year = end_year
307 begin_month = end_month - 3
308 if begin_month <= 0:
309 begin_year -= 1
310 begin_month += 12
311 begin = '%d-%02d-01' % (begin_year, begin_month)
312 return begin, end
[email protected]c73e5162011-09-21 23:16:12313
314
315def main():
[email protected]b7704322011-09-22 15:22:05316 # Silence upload.py.
317 rietveld.upload.verbosity = 0
[email protected]70e91c02011-09-22 19:14:51318 today = datetime.date.today()
[email protected]5e6868b2011-09-22 20:15:07319 begin, end = get_previous_quarter(today)
[email protected]dc33eae2014-12-11 17:15:26320 default_email = os.environ.get('EMAIL_ADDRESS')
321 if not default_email:
322 user = os.environ.get('USER')
323 if user:
324 default_email = user + '@chromium.org'
325
326 parser = optparse.OptionParser(description=__doc__)
[email protected]b7704322011-09-22 15:22:05327 parser.add_option(
328 '--count', action='store_true',
329 help='Just count instead of printing individual issues')
[email protected]70e91c02011-09-22 19:14:51330 parser.add_option(
[email protected]dc33eae2014-12-11 17:15:26331 '-r', '--reviewer', metavar='<email>', default=default_email,
[email protected]0bccb922011-09-22 19:22:22332 help='Filter on issue reviewer, default=%default')
[email protected]70e91c02011-09-22 19:14:51333 parser.add_option(
[email protected]5e6868b2011-09-22 20:15:07334 '-b', '--begin', metavar='<date>',
[email protected]70e91c02011-09-22 19:14:51335 help='Filter issues created after the date')
336 parser.add_option(
[email protected]5e6868b2011-09-22 20:15:07337 '-e', '--end', metavar='<date>',
338 help='Filter issues created before the date')
[email protected]b7704322011-09-22 15:22:05339 parser.add_option(
340 '-Q', '--last_quarter', action='store_true',
[email protected]dc33eae2014-12-11 17:15:26341 help='Use last quarter\'s dates, e.g. %s to %s' % (begin, end))
[email protected]70e91c02011-09-22 19:14:51342 parser.add_option(
343 '-i', '--instance_url', metavar='<host>',
344 default='http://codereview.chromium.org',
345 help='Host to use, default is %default')
[email protected]cf6a5d22015-04-09 22:02:00346 auth.add_auth_options(parser)
[email protected]c73e5162011-09-21 23:16:12347 # Remove description formatting
[email protected]14e37ad2011-11-30 20:26:16348 parser.format_description = (
Quinten Yearsleyb2cc4a92016-12-15 21:53:26349 lambda _: parser.description) # pylint: disable=no-member
[email protected]c73e5162011-09-21 23:16:12350 options, args = parser.parse_args()
[email protected]cf6a5d22015-04-09 22:02:00351 auth_config = auth.extract_auth_config_from_options(options)
[email protected]c73e5162011-09-21 23:16:12352 if args:
353 parser.error('Args unsupported')
[email protected]dc33eae2014-12-11 17:15:26354 if options.reviewer is None:
355 parser.error('$EMAIL_ADDRESS and $USER are not set, please use -r')
356
[email protected]0bccb922011-09-22 19:22:22357 print >> sys.stderr, 'Searching for reviews by %s' % options.reviewer
[email protected]c73e5162011-09-21 23:16:12358 if options.last_quarter:
[email protected]5e6868b2011-09-22 20:15:07359 options.begin = begin
360 options.end = end
[email protected]b7704322011-09-22 15:22:05361 print >> sys.stderr, 'Using range %s to %s' % (
[email protected]5e6868b2011-09-22 20:15:07362 options.begin, options.end)
[email protected]dc33eae2014-12-11 17:15:26363 else:
364 if options.begin is None or options.end is None:
365 parser.error('Please specify either --last_quarter or --begin and --end')
[email protected]16c319d2014-03-11 20:04:20366
367 # Validate dates.
368 try:
Daniel Cheng4b37ce62017-09-07 19:00:02369 options.begin = dateutil.parser.parse(options.begin).strftime('%Y-%m-%d')
370 options.end = dateutil.parser.parse(options.end).strftime('%Y-%m-%d')
[email protected]16c319d2014-03-11 20:04:20371 except ValueError as e:
372 parser.error('%s: %s - %s' % (e, options.begin, options.end))
373
[email protected]b7704322011-09-22 15:22:05374 if options.count:
375 print_count(
[email protected]0bccb922011-09-22 19:22:22376 options.reviewer,
[email protected]5e6868b2011-09-22 20:15:07377 options.begin,
378 options.end,
[email protected]cf6a5d22015-04-09 22:02:00379 options.instance_url,
380 auth_config)
[email protected]b7704322011-09-22 15:22:05381 else:
382 print_reviews(
[email protected]0bccb922011-09-22 19:22:22383 options.reviewer,
[email protected]5e6868b2011-09-22 20:15:07384 options.begin,
385 options.end,
[email protected]cf6a5d22015-04-09 22:02:00386 options.instance_url,
387 auth_config)
[email protected]c73e5162011-09-21 23:16:12388 return 0
389
390
391if __name__ == '__main__':
[email protected]013731e2015-02-26 18:28:43392 try:
393 sys.exit(main())
394 except KeyboardInterrupt:
395 sys.stderr.write('interrupted\n')
396 sys.exit(1)