#!/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 '' 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 '' 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 '' 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 = '' 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='', default=default_email, help='Filter on issue reviewer, default=%default') parser.add_option( '-b', '--begin', metavar='', help='Filter issues created after the date') parser.add_option( '-e', '--end', metavar='', 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='', 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)