|
|
|
#!/usr/bin/env python
|
|
|
|
# Copyright (c) 2012 The Chromium Authors. All rights reserved.
|
|
|
|
# Use of this source code is governed by a BSD-style license that can be
|
|
|
|
# found in the LICENSE file.
|
|
|
|
|
|
|
|
"""Get rietveld stats about the review you done, or forgot to do.
|
|
|
|
|
|
|
|
Example:
|
|
|
|
- my_reviews.py -r me@chromium.org -Q for stats for last quarter.
|
|
|
|
"""
|
|
|
|
import datetime
|
|
|
|
import math
|
|
|
|
import optparse
|
|
|
|
import os
|
|
|
|
import sys
|
|
|
|
|
|
|
|
import auth
|
|
|
|
import rietveld
|
|
|
|
|
|
|
|
|
|
|
|
def username(email):
|
|
|
|
"""Keeps the username of an email address."""
|
|
|
|
return email.split('@', 1)[0]
|
|
|
|
|
|
|
|
|
|
|
|
def to_datetime(string):
|
|
|
|
"""Load UTC time as a string into a datetime object."""
|
|
|
|
try:
|
|
|
|
# Format is 2011-07-05 01:26:12.084316
|
|
|
|
return datetime.datetime.strptime(
|
|
|
|
string.split('.', 1)[0], '%Y-%m-%d %H:%M:%S')
|
|
|
|
except ValueError:
|
|
|
|
return datetime.datetime.strptime(string, '%Y-%m-%d')
|
|
|
|
|
|
|
|
|
|
|
|
def to_time(seconds):
|
|
|
|
"""Convert a number of seconds into human readable compact string."""
|
|
|
|
prefix = ''
|
|
|
|
if seconds < 0:
|
|
|
|
prefix = '-'
|
|
|
|
seconds *= -1
|
|
|
|
minutes = math.floor(seconds / 60)
|
|
|
|
seconds -= minutes * 60
|
|
|
|
hours = math.floor(minutes / 60)
|
|
|
|
minutes -= hours * 60
|
|
|
|
days = math.floor(hours / 24)
|
|
|
|
hours -= days * 24
|
|
|
|
out = []
|
|
|
|
if days > 0:
|
|
|
|
out.append('%dd' % days)
|
|
|
|
if hours > 0 or days > 0:
|
|
|
|
out.append('%02dh' % hours)
|
|
|
|
if minutes > 0 or hours > 0 or days > 0:
|
|
|
|
out.append('%02dm' % minutes)
|
|
|
|
if seconds > 0 and not out:
|
|
|
|
# Skip seconds unless there's only seconds.
|
|
|
|
out.append('%02ds' % seconds)
|
|
|
|
return prefix + ''.join(out)
|
|
|
|
|
|
|
|
|
|
|
|
class Stats(object):
|
|
|
|
def __init__(self):
|
|
|
|
self.total = 0
|
|
|
|
self.actually_reviewed = 0
|
|
|
|
self.latencies = []
|
|
|
|
self.lgtms = 0
|
|
|
|
self.multiple_lgtms = 0
|
|
|
|
self.drive_by = 0
|
|
|
|
self.not_requested = 0
|
|
|
|
self.self_review = 0
|
|
|
|
|
|
|
|
self.percent_lgtm = 0.
|
|
|
|
self.percent_drive_by = 0.
|
|
|
|
self.percent_not_requested = 0.
|
|
|
|
self.days = 0
|
|
|
|
|
|
|
|
@property
|
|
|
|
def average_latency(self):
|
|
|
|
if not self.latencies:
|
|
|
|
return 0
|
|
|
|
return sum(self.latencies) / float(len(self.latencies))
|
|
|
|
|
|
|
|
@property
|
|
|
|
def median_latency(self):
|
|
|
|
if not self.latencies:
|
|
|
|
return 0
|
|
|
|
length = len(self.latencies)
|
|
|
|
latencies = sorted(self.latencies)
|
|
|
|
if (length & 1) == 0:
|
|
|
|
return (latencies[length/2] + latencies[length/2-1]) / 2.
|
|
|
|
else:
|
|
|
|
return latencies[length/2]
|
|
|
|
|
|
|
|
@property
|
|
|
|
def percent_done(self):
|
|
|
|
if not self.total:
|
|
|
|
return 0
|
|
|
|
return self.actually_reviewed * 100. / self.total
|
|
|
|
|
|
|
|
@property
|
|
|
|
def review_per_day(self):
|
|
|
|
if not self.days:
|
|
|
|
return 0
|
|
|
|
return self.total * 1. / self.days
|
|
|
|
|
|
|
|
@property
|
|
|
|
def review_done_per_day(self):
|
|
|
|
if not self.days:
|
|
|
|
return 0
|
|
|
|
return self.actually_reviewed * 1. / self.days
|
|
|
|
|
|
|
|
def finalize(self, first_day, last_day):
|
|
|
|
if self.actually_reviewed:
|
|
|
|
assert self.actually_reviewed > 0
|
|
|
|
self.percent_lgtm = (self.lgtms * 100. / self.actually_reviewed)
|
|
|
|
self.percent_drive_by = (self.drive_by * 100. / self.actually_reviewed)
|
|
|
|
self.percent_not_requested = (
|
|
|
|
self.not_requested * 100. / self.actually_reviewed)
|
|
|
|
assert bool(first_day) == bool(last_day)
|
|
|
|
if first_day and last_day:
|
|
|
|
assert first_day <= last_day
|
|
|
|
self.days = (to_datetime(last_day) - to_datetime(first_day)).days + 1
|
|
|
|
assert self.days > 0
|
|
|
|
|
|
|
|
|
|
|
|
def _process_issue_lgtms(issue, reviewer, stats):
|
|
|
|
"""Calculates LGTMs stats."""
|
|
|
|
stats.actually_reviewed += 1
|
|
|
|
reviewer_lgtms = len([
|
|
|
|
msg for msg in issue['messages']
|
|
|
|
if msg['approval'] and msg['sender'] == reviewer])
|
|
|
|
if reviewer_lgtms > 1:
|
|
|
|
stats.multiple_lgtms += 1
|
|
|
|
return ' X '
|
|
|
|
if reviewer_lgtms:
|
|
|
|
stats.lgtms += 1
|
|
|
|
return ' x '
|
|
|
|
else:
|
|
|
|
return ' o '
|
|
|
|
|
|
|
|
|
|
|
|
def _process_issue_latency(issue, reviewer, stats):
|
|
|
|
"""Calculates latency for an issue that was actually reviewed."""
|
|
|
|
from_owner = [
|
|
|
|
msg for msg in issue['messages'] if msg['sender'] == issue['owner_email']
|
|
|
|
]
|
|
|
|
if not from_owner:
|
|
|
|
# Probably requested by email.
|
|
|
|
stats.not_requested += 1
|
|
|
|
return '<no rqst sent>'
|
|
|
|
|
|
|
|
first_msg_from_owner = None
|
|
|
|
latency = None
|
|
|
|
received = False
|
|
|
|
for index, msg in enumerate(issue['messages']):
|
|
|
|
if not first_msg_from_owner and msg['sender'] == issue['owner_email']:
|
|
|
|
first_msg_from_owner = msg
|
|
|
|
if index and not received and msg['sender'] == reviewer:
|
|
|
|
# Not first email, reviewer never received one, reviewer sent a mesage.
|
|
|
|
stats.drive_by += 1
|
|
|
|
return '<drive-by>'
|
|
|
|
received |= reviewer in msg['recipients']
|
|
|
|
|
|
|
|
if first_msg_from_owner and msg['sender'] == reviewer:
|
|
|
|
delta = msg['date'] - first_msg_from_owner['date']
|
|
|
|
latency = delta.seconds + delta.days * 24 * 3600
|
|
|
|
break
|
|
|
|
|
|
|
|
if latency is None:
|
|
|
|
stats.not_requested += 1
|
|
|
|
return '<no rqst sent>'
|
|
|
|
if latency > 0:
|
|
|
|
stats.latencies.append(latency)
|
|
|
|
else:
|
|
|
|
stats.not_requested += 1
|
|
|
|
return to_time(latency)
|
|
|
|
|
|
|
|
|
|
|
|
def _process_issue(issue):
|
|
|
|
"""Preprocesses the issue to simplify the remaining code."""
|
|
|
|
issue['owner_email'] = username(issue['owner_email'])
|
|
|
|
issue['reviewers'] = set(username(r) for r in issue['reviewers'])
|
|
|
|
# By default, hide commit-bot.
|
|
|
|
issue['reviewers'] -= set(['commit-bot'])
|
|
|
|
for msg in issue['messages']:
|
|
|
|
msg['sender'] = username(msg['sender'])
|
|
|
|
msg['recipients'] = [username(r) for r in msg['recipients']]
|
|
|
|
# Convert all times to datetime instances.
|
|
|
|
msg['date'] = to_datetime(msg['date'])
|
|
|
|
issue['messages'].sort(key=lambda x: x['date'])
|
|
|
|
|
|
|
|
|
|
|
|
def print_issue(issue, reviewer, stats):
|
|
|
|
"""Process an issue and prints stats about it."""
|
|
|
|
stats.total += 1
|
|
|
|
_process_issue(issue)
|
|
|
|
if issue['owner_email'] == reviewer:
|
|
|
|
stats.self_review += 1
|
|
|
|
latency = '<self review>'
|
|
|
|
reviewed = ''
|
|
|
|
elif any(msg['sender'] == reviewer for msg in issue['messages']):
|
|
|
|
reviewed = _process_issue_lgtms(issue, reviewer, stats)
|
|
|
|
latency = _process_issue_latency(issue, reviewer, stats)
|
|
|
|
else:
|
|
|
|
latency = 'N/A'
|
|
|
|
reviewed = ''
|
|
|
|
|
|
|
|
# More information is available, print issue.keys() to see them.
|
|
|
|
print '%7d %10s %3s %14s %-15s %s' % (
|
|
|
|
issue['issue'],
|
|
|
|
issue['created'][:10],
|
|
|
|
reviewed,
|
|
|
|
latency,
|
|
|
|
issue['owner_email'],
|
|
|
|
', '.join(sorted(issue['reviewers'])))
|
|
|
|
|
|
|
|
|
|
|
|
def print_reviews(
|
|
|
|
reviewer, created_after, created_before, instance_url, auth_config):
|
|
|
|
"""Prints issues |reviewer| received and potentially reviewed."""
|
|
|
|
remote = rietveld.Rietveld(instance_url, auth_config)
|
|
|
|
|
|
|
|
# The stats we gather. Feel free to send me a CL to get more stats.
|
|
|
|
stats = Stats()
|
|
|
|
|
|
|
|
# Column sizes need to match print_issue() output.
|
|
|
|
print >> sys.stderr, (
|
|
|
|
'Issue Creation Did Latency Owner Reviewers')
|
|
|
|
|
|
|
|
# See def search() in rietveld.py to see all the filters you can use.
|
|
|
|
issues = []
|
|
|
|
for issue in remote.search(
|
|
|
|
reviewer=reviewer,
|
|
|
|
created_after=created_after,
|
|
|
|
created_before=created_before,
|
|
|
|
with_messages=True):
|
|
|
|
issues.append(issue)
|
|
|
|
print_issue(issue, username(reviewer), stats)
|
|
|
|
|
|
|
|
issues.sort(key=lambda x: x['created'])
|
|
|
|
first_day = None
|
|
|
|
last_day = None
|
|
|
|
if issues:
|
|
|
|
first_day = issues[0]['created'][:10]
|
|
|
|
last_day = issues[-1]['created'][:10]
|
|
|
|
stats.finalize(first_day, last_day)
|
|
|
|
|
|
|
|
print >> sys.stderr, (
|
|
|
|
'%s reviewed %d issues out of %d (%1.1f%%). %d were self-review.' %
|
|
|
|
(reviewer, stats.actually_reviewed, stats.total, stats.percent_done,
|
|
|
|
stats.self_review))
|
|
|
|
print >> sys.stderr, (
|
|
|
|
'%4.1f review request/day during %3d days (%4.1f r/d done).' % (
|
|
|
|
stats.review_per_day, stats.days, stats.review_done_per_day))
|
|
|
|
print >> sys.stderr, (
|
|
|
|
'%4d were drive-bys (%5.1f%% of reviews done).' % (
|
|
|
|
stats.drive_by, stats.percent_drive_by))
|
|
|
|
print >> sys.stderr, (
|
|
|
|
'%4d were requested over IM or irc (%5.1f%% of reviews done).' % (
|
|
|
|
stats.not_requested, stats.percent_not_requested))
|
|
|
|
print >> sys.stderr, (
|
|
|
|
('%4d issues LGTM\'d (%5.1f%% of reviews done),'
|
|
|
|
' gave multiple LGTMs on %d issues.') % (
|
|
|
|
stats.lgtms, stats.percent_lgtm, stats.multiple_lgtms))
|
|
|
|
print >> sys.stderr, (
|
|
|
|
'Average latency from request to first comment is %s.' %
|
|
|
|
to_time(stats.average_latency))
|
|
|
|
print >> sys.stderr, (
|
|
|
|
'Median latency from request to first comment is %s.' %
|
|
|
|
to_time(stats.median_latency))
|
|
|
|
|
|
|
|
|
|
|
|
def print_count(
|
|
|
|
reviewer, created_after, created_before, instance_url, auth_config):
|
|
|
|
remote = rietveld.Rietveld(instance_url, auth_config)
|
|
|
|
print len(list(remote.search(
|
|
|
|
reviewer=reviewer,
|
|
|
|
created_after=created_after,
|
|
|
|
created_before=created_before,
|
|
|
|
keys_only=True)))
|
|
|
|
|
|
|
|
|
|
|
|
def get_previous_quarter(today):
|
|
|
|
"""There are four quarters, 01-03, 04-06, 07-09, 10-12.
|
|
|
|
|
|
|
|
If today is in the last month of a quarter, assume it's the current quarter
|
|
|
|
that is requested.
|
|
|
|
"""
|
|
|
|
end_year = today.year
|
|
|
|
end_month = today.month - (today.month % 3) + 1
|
|
|
|
if end_month <= 0:
|
|
|
|
end_year -= 1
|
|
|
|
end_month += 12
|
|
|
|
if end_month > 12:
|
|
|
|
end_year += 1
|
|
|
|
end_month -= 12
|
|
|
|
end = '%d-%02d-01' % (end_year, end_month)
|
|
|
|
begin_year = end_year
|
|
|
|
begin_month = end_month - 3
|
|
|
|
if begin_month <= 0:
|
|
|
|
begin_year -= 1
|
|
|
|
begin_month += 12
|
|
|
|
begin = '%d-%02d-01' % (begin_year, begin_month)
|
|
|
|
return begin, end
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
# Silence upload.py.
|
|
|
|
rietveld.upload.verbosity = 0
|
|
|
|
today = datetime.date.today()
|
|
|
|
begin, end = get_previous_quarter(today)
|
|
|
|
default_email = os.environ.get('EMAIL_ADDRESS')
|
|
|
|
if not default_email:
|
|
|
|
user = os.environ.get('USER')
|
|
|
|
if user:
|
|
|
|
default_email = user + '@chromium.org'
|
|
|
|
|
|
|
|
parser = optparse.OptionParser(description=__doc__)
|
|
|
|
parser.add_option(
|
|
|
|
'--count', action='store_true',
|
|
|
|
help='Just count instead of printing individual issues')
|
|
|
|
parser.add_option(
|
|
|
|
'-r', '--reviewer', metavar='<email>', default=default_email,
|
|
|
|
help='Filter on issue reviewer, default=%default')
|
|
|
|
parser.add_option(
|
|
|
|
'-b', '--begin', metavar='<date>',
|
|
|
|
help='Filter issues created after the date')
|
|
|
|
parser.add_option(
|
|
|
|
'-e', '--end', metavar='<date>',
|
|
|
|
help='Filter issues created before the date')
|
|
|
|
parser.add_option(
|
|
|
|
'-Q', '--last_quarter', action='store_true',
|
|
|
|
help='Use last quarter\'s dates, e.g. %s to %s' % (begin, end))
|
|
|
|
parser.add_option(
|
|
|
|
'-i', '--instance_url', metavar='<host>',
|
|
|
|
default='http://codereview.chromium.org',
|
|
|
|
help='Host to use, default is %default')
|
|
|
|
auth.add_auth_options(parser)
|
|
|
|
# Remove description formatting
|
|
|
|
parser.format_description = (
|
|
|
|
lambda _: parser.description) # pylint: disable=E1101
|
|
|
|
options, args = parser.parse_args()
|
|
|
|
auth_config = auth.extract_auth_config_from_options(options)
|
|
|
|
if args:
|
|
|
|
parser.error('Args unsupported')
|
|
|
|
if options.reviewer is None:
|
|
|
|
parser.error('$EMAIL_ADDRESS and $USER are not set, please use -r')
|
|
|
|
|
|
|
|
print >> sys.stderr, 'Searching for reviews by %s' % options.reviewer
|
|
|
|
if options.last_quarter:
|
|
|
|
options.begin = begin
|
|
|
|
options.end = end
|
|
|
|
print >> sys.stderr, 'Using range %s to %s' % (
|
|
|
|
options.begin, options.end)
|
|
|
|
else:
|
|
|
|
if options.begin is None or options.end is None:
|
|
|
|
parser.error('Please specify either --last_quarter or --begin and --end')
|
|
|
|
|
|
|
|
# Validate dates.
|
|
|
|
try:
|
|
|
|
to_datetime(options.begin)
|
|
|
|
to_datetime(options.end)
|
|
|
|
except ValueError as e:
|
|
|
|
parser.error('%s: %s - %s' % (e, options.begin, options.end))
|
|
|
|
|
|
|
|
if options.count:
|
|
|
|
print_count(
|
|
|
|
options.reviewer,
|
|
|
|
options.begin,
|
|
|
|
options.end,
|
|
|
|
options.instance_url,
|
|
|
|
auth_config)
|
|
|
|
else:
|
|
|
|
print_reviews(
|
|
|
|
options.reviewer,
|
|
|
|
options.begin,
|
|
|
|
options.end,
|
|
|
|
options.instance_url,
|
|
|
|
auth_config)
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
try:
|
|
|
|
sys.exit(main())
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
sys.stderr.write('interrupted\n')
|
|
|
|
sys.exit(1)
|