#!/usr/bin/env python # # Copyright (C) 2011 - Soren Hansen # Copyright (C) 2013 - Red Hat, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import calendar import datetime import getpass import optparse import os import os.path import sys import utils def sec_to_period_string(seconds): days = seconds / (3600 * 24) hours = (seconds / 3600) - (days * 24) minutes = (seconds / 60) - (days * 24 * 60) - (hours * 60) return '%d days, %d hours, %d minutes' % (days, hours, minutes) def get_age_of_patch(patch, now_ts): approvals = patch.get('approvals', []) approvals.sort(key=lambda a:a['grantedOn']) # The createdOn timestamp on the patch isn't what we want. # It's when the patch was written, not submitted for review. # The next best thing in the data we have is the time of the # first review. When all is working well, jenkins or smokestack # will comment within the first hour or two, so that's better # than the other timestamp, which may reflect that the code # was written many weeks ago, even though it was just recently # submitted for review. if approvals: return now_ts - approvals[0]['grantedOn'] else: return now_ts - patch['createdOn'] def average_age(changes, key='age'): if not changes: return 0 total_seconds = 0 for change in changes: total_seconds += change[key] avg_age = total_seconds / len(changes) return sec_to_period_string(avg_age) def median_age(changes, key='age'): if not changes: return 0 changes = sorted(changes, key=lambda change: change[key]) median_age = changes[len(changes)/2][key] return sec_to_period_string(median_age) def number_waiting_more_than(changes, seconds, key='age'): index = 0 for change in changes: if change[key] > seconds: return len(changes) - index index += 1 return 0 def format_url(url, options): return '%s%s%s' % ('<a href="' if options.html else '', url, ('">%s</a>' % url) if options.html else '') def gen_stats(projects, waiting_on_reviewer, waiting_on_submitter, options): age_sorted = sorted(waiting_on_reviewer, key=lambda change: change['age'], reverse=True) age2_sorted = sorted(waiting_on_reviewer, key=lambda change: change['age2'], reverse=True) age3_sorted = sorted(waiting_on_reviewer, key=lambda change: change['age3'], reverse=True) result = [] result.append(('Projects', '%s' % [project['name'] for project in projects])) stats = [] stats.append(('Total Open Reviews', '%d' % ( len(waiting_on_reviewer) + len(waiting_on_submitter)))) stats.append(('Waiting on Submitter', '%d' % len(waiting_on_submitter))) stats.append(('Waiting on Reviewer', '%d' % len(waiting_on_reviewer))) latest_rev_stats = [] latest_rev_stats.append(('Average wait time', '%s' % ( average_age(waiting_on_reviewer)))) latest_rev_stats.append(('Median wait time', '%s' % ( median_age(waiting_on_reviewer)))) latest_rev_stats.append(('Number waiting more than %i days' % options.waiting_more, '%i' % (number_waiting_more_than( age_sorted, 60 * 60 * 24 * options.waiting_more)))) stats.append(('Stats since the latest revision', latest_rev_stats)) first_rev_stats = [] first_rev_stats.append(('Average wait time', '%s' % ( average_age(waiting_on_reviewer, key='age2')))) first_rev_stats.append(('Median wait time', '%s' % ( median_age(waiting_on_reviewer, key='age2')))) stats.append(('Stats since the first revision', first_rev_stats)) last_without_nack_stats = [] last_without_nack_stats.append(('Average wait time', '%s' % ( average_age(waiting_on_reviewer, key='age3')))) last_without_nack_stats.append(('Median wait time', '%s' % ( median_age(waiting_on_reviewer, key='age3')))) stats.append(('Stats since the last revision without -1 or -2 (ignoring jenkins)', last_without_nack_stats)) changes = [] for change in age_sorted[:options.longest_waiting]: changes.append('%s %s (%s)' % (sec_to_period_string(change['age']), format_url(change['url'], options), change['subject'])) stats.append(('Longest waiting reviews (based on latest revision)', changes)) changes = [] for change in age2_sorted[:options.longest_waiting]: changes.append('%s %s (%s)' % (sec_to_period_string(change['age2']), format_url(change['url'], options), change['subject'])) stats.append(('Longest waiting reviews (based on first revision)', changes)) changes = [] for change in age3_sorted[:options.longest_waiting]: changes.append('%s %s (%s)' % (sec_to_period_string(change['age3']), format_url(change['url'], options), change['subject'])) stats.append(('Longest waiting reviews (based on oldest rev without nack, ignoring jenkins)', changes)) result.append(stats) return result def print_stats_txt(stats, f=sys.stdout): def print_list_txt(l, level): for item in l: if not isinstance(item, list): f.write('%s> ' % ('--' * level)) print_item_txt(item, level) def print_item_txt(item, level): if isinstance(item, basestring): f.write('%s\n' % item) elif isinstance(item, list): print_list_txt(item, level + 1) elif isinstance(item, tuple): f.write('%s: ' % item[0]) if isinstance(item[1], list): f.write('\n') print_item_txt(item[1], level) else: raise Exception('Unhandled type') print_list_txt(stats, 0) def print_stats_html(stats, f=sys.stdout): def print_list_html(l, level): if level: f.write('<%s>\n' % ('ul' if level == 1 else 'ol')) for item in l: if level: f.write('%s<li>' % (' ' * level)) print_item_html(item, level) if level: f.write('</li>\n') if level: f.write('</%s>\n' % ('ul' if level == 1 else 'ol')) def print_item_html(item, level): if isinstance(item, basestring): f.write('%s' % item) elif isinstance(item, list): print_list_html(item, level + 1) elif isinstance(item, tuple): f.write('%s: ' % item[0]) if isinstance(item[1], list): f.write('\n') print_item_html(item[1], level) else: raise Exception('Unhandled type') f.write('<html>\n') f.write('<head><title>Open Reviews for %s</title></head>\n' % stats[0][1]) print_list_html(stats, 0) f.write('</html>\n') def find_oldest_no_nack(change): last_patch = None for patch in reversed(change['patchSets']): nacked = False for review in patch.get('approvals', []): if review['type'] != 'CRVW': continue if review['value'] in ('-1', '-2'): nacked = True break if nacked: break last_patch = patch return last_patch def main(argv=None): if argv is None: argv = sys.argv optparser = optparse.OptionParser() optparser.add_option('-p', '--project', default='projects/nova.json', help='JSON file describing the project to generate stats for') optparser.add_option('-a', '--all', action='store_true', help='Generate stats across all known projects (*.json)') optparser.add_option('-u', '--user', default=getpass.getuser(), help='gerrit user') optparser.add_option('-k', '--key', default=None, help='ssh key for gerrit') optparser.add_option('-s', '--stable', action='store_true', help='Include stable branch commits') optparser.add_option('-l', '--longest-waiting', type='int', default=5, help='Show n changesets that have waited the longest)') optparser.add_option('-m', '--waiting-more', type='int', default=7, help='Show number of changesets that have waited more than n days)') optparser.add_option('-H', '--html', action='store_true', help='Use HTML output instead of plain text') options, args = optparser.parse_args() projects = utils.get_projects_info(options.project, options.all) if not projects: print "Please specify a project." sys.exit(1) changes = utils.get_changes(projects, options.user, options.key, only_open=True) waiting_on_submitter = [] waiting_on_reviewer = [] now = datetime.datetime.utcnow() now_ts = calendar.timegm(now.timetuple()) for change in changes: if 'rowCount' in change: continue if not options.stable and 'stable' in change['branch']: continue if change['status'] != 'NEW': # Filter out WORKINPROGRESS continue latest_patch = change['patchSets'][-1] waiting_for_review = True approvals = latest_patch.get('approvals', []) approvals.sort(key=lambda a:a['grantedOn']) for review in approvals: if review['type'] not in ('CRVW', 'VRIF'): continue if review['value'] in ('-1', '-2'): waiting_for_review = False break change['age'] = get_age_of_patch(latest_patch, now_ts) change['age2'] = get_age_of_patch(change['patchSets'][0], now_ts) patch = find_oldest_no_nack(change) change['age3'] = get_age_of_patch(patch, now_ts) if patch else 0 if waiting_for_review: waiting_on_reviewer.append(change) else: waiting_on_submitter.append(change) stats = gen_stats(projects, waiting_on_reviewer, waiting_on_submitter, options) if options.html: print_stats_html(stats) else: print_stats_txt(stats) if __name__ == '__main__': sys.exit(main())