reviewstats/reviewstats/cmd/reviewers.py

322 lines
13 KiB
Python
Executable File

# -*- coding: utf-8 -*-
#
# 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 csv
import datetime
import getpass
import optparse
import prettytable
import sys
import utils
def round_to_day(ts):
SECONDS_PER_DAY = 60 * 60 * 24
return (ts / (SECONDS_PER_DAY)) * SECONDS_PER_DAY
def set_defaults(reviewer, reviewers):
reviewers.setdefault(
reviewer, {'votes': {'-2': 0, '-1': 0, '1': 0, '2': 0, 'A': 0}})
reviewers[reviewer].setdefault('disagreements', 0)
reviewers[reviewer].setdefault('total', 0)
reviewers[reviewer].setdefault('received', 0)
def process_patchset(project, patchset, reviewers, ts):
latest_core_neg_vote = 0
latest_core_pos_vote = 0
submitter = patchset['uploader'].get('username', 'unknown')
for review in patchset.get('approvals', []):
if review['type'] != 'CRVW':
# Only count code reviews. Don't add another for Approved, which
# is type 'APRV'
continue
if review['by'].get('username', 'unknown') not in project['core-team']:
# Only checking for disagreements from core team members
continue
if int(review['value']) > 0:
latest_core_pos_vote = max(latest_core_pos_vote,
int(review['grantedOn']))
else:
latest_core_neg_vote = max(latest_core_neg_vote,
int(review['grantedOn']))
for review in patchset.get('approvals', []):
if review['grantedOn'] < ts:
continue
if review['type'] not in ('CRVW', 'APRV'):
continue
reviewer = review['by'].get('username', 'unknown')
set_defaults(reviewer, reviewers)
if review['type'] == 'APRV':
cur = reviewers[reviewer]['votes']['A']
reviewers[reviewer]['votes']['A'] = cur + 1
else:
cur_total = reviewers[reviewer].get('total', 0)
reviewers[reviewer]['total'] = cur_total + 1
set_defaults(submitter, reviewers)
reviewers[submitter]['received'] += 1
cur = reviewers[reviewer]['votes'][review['value']]
reviewers[reviewer]['votes'][review['value']] = cur + 1
if (review['value'] in ('1', '2')
and int(review['grantedOn']) < latest_core_neg_vote):
# A core team member gave a negative vote after this person
# gave a positive one
cur = reviewers[reviewer]['disagreements']
reviewers[reviewer]['disagreements'] = cur + 1
if (review['value'] in ('-1', '-2')
and int(review['grantedOn']) < latest_core_pos_vote):
# A core team member gave a positive vote after this person
# gave a negative one
cur = reviewers[reviewer]['disagreements']
reviewers[reviewer]['disagreements'] = cur + 1
def write_csv(reviewer_data, file_obj):
"""Write out reviewers using CSV."""
writer = csv.writer(file_obj)
writer.writerow(
('Reviewer', 'Reviews', '-2', '-1', '+1', '+2', '+A', '+/- %',
'Disagreements', 'Disagreement%', 'Received'))
for (name, r_data, d_data, s_data) in reviewer_data:
row = (name,) + r_data + d_data + s_data
writer.writerow(row)
def write_pretty(reviewer_data, file_obj):
"""Write out reviewers using PrettyTable."""
table = prettytable.PrettyTable(
('Reviewer',
'Reviews -2 -1 +1 +2 +A +/- %',
'Disagreements*',
'Received***'))
for (name, r_data, d_data, s_data) in reviewer_data:
r = '%7d %3d %3d %3d %3d %3d %s' % r_data
d = '%3d (%s)' % d_data
s = '%3d (%s)' % s_data
table.add_row((name, r, d, s))
file_obj.write("%s\n" % table)
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(
'-o', '--output', default='-',
help='Where to write output. If - stdout is used and only one output'
'format may be given. Otherwise the output format is appended to'
'the output parameter to generate file names.')
optparser.add_option(
'--outputs', default=['txt'], action='append',
help='Select what outputs to generate. (txt,csv).')
optparser.add_option(
'-d', '--days', type='int', default=14,
help='Number of days to consider')
optparser.add_option(
'-u', '--user', default=getpass.getuser(), help='gerrit user')
optparser.add_option(
'-k', '--key', default=None, help='ssh key for gerrit')
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)
reviewers = {}
now = datetime.datetime.utcnow()
cut_off = now - datetime.timedelta(days=options.days)
ts = calendar.timegm(cut_off.timetuple())
now_ts = calendar.timegm(now.timetuple())
patches_created = 0
changes_created = 0
changes_involved = 0
changes_merged = 0
changes_abandoned = 0
changes_wip = 0
for project in projects:
changes = utils.get_changes([project], options.user, options.key)
for change in changes:
patch_for_change = False
first_patchset = True
for patchset in change.get('patchSets', []):
process_patchset(project, patchset, reviewers, ts)
age = utils.get_age_of_patch(patchset, now_ts)
if (now_ts - age) > ts:
patches_created += 1
patch_for_change = True
if first_patchset:
changes_created += 1
first_patchset = False
if patch_for_change:
changes_involved += 1
if change['status'] == 'MERGED':
changes_merged += 1
elif change['status'] == 'ABANDONED':
changes_abandoned += 1
elif change['status'] == 'WORKINPROGRESS':
changes_wip += 1
reviewers = [(v, k) for k, v in reviewers.iteritems()
if k.lower() not in ('jenkins', 'smokestack')]
reviewers.sort(reverse=True, key=lambda r: r[0]['total'])
# Do logical processing of reviewers.
reviewer_data = []
total = 0
core_total = 0
for k, v in reviewers:
in_core_team = False
for project in projects:
if v in project['core-team']:
in_core_team = True
break
name = '%s%s' % (v, ' **' if in_core_team else '')
plus = float(k['votes']['2'] + k['votes']['1'])
minus = float(k['votes']['-2'] + k['votes']['-1'])
all_reviews = plus + minus
ratio = ((plus / (all_reviews)) * 100) if all_reviews > 0 else 0
r = (k['total'], k['votes']['-2'],
k['votes']['-1'], k['votes']['1'],
k['votes']['2'], k['votes']['A'], "%5.1f%%" % ratio)
dratio = (((float(k['disagreements']) / all_reviews) * 100)
if all_reviews else 0.0)
d = (k['disagreements'], "%5.1f%%" % dratio)
sratio = ((float(k['total']) / k['received']) * 100
if k['received'] else 0)
s = (k['received'], "%5.1f%%" % sratio if k['received'] else 'inf')
reviewer_data.append((name, r, d, s))
total += k['total']
if in_core_team:
core_total += k['total']
# And output.
writers = {
'csv': write_csv,
'txt': write_pretty,
}
if options.output == '-':
if len(options.outputs) != 1:
raise Exception("Can only output one format to stdout.")
for output in options.outputs:
if options.output == '-':
file_obj = sys.stdout
on_done = None
else:
file_obj = open(options.output + '.' + output, 'wt')
on_done = file_obj.close
try:
writer = writers[output]
if options.all:
file_obj.write(
'Reviews for the last %d days in projects: %s\n' %
(options.days, [project['name'] for project in projects]))
else:
file_obj.write(
'Reviews for the last %d days in %s\n'
% (options.days, projects[0]['name']))
if options.all:
file_obj.write(
'** -- Member of at least one core reviewer team\n')
else:
file_obj.write(
'** -- %s-core team member\n' % projects[0]['name'])
writer(reviewer_data, file_obj)
file_obj.write(
'\nTotal reviews: %d (%.1f/day)\n' % (
total, float(total) / options.days))
file_obj.write(
'Total reviewers: %d (avg %.1f reviews/day)\n' % (
len(reviewers), float(total) / options.days / len(reviewers)))
file_obj.write('Total reviews by core team: %d (%.1f/day)\n' % (
core_total, float(core_total) / options.days))
core_team_size = sum([len(project['core-team'])
for project in projects])
file_obj.write('Core team size: %d (avg %.1f reviews/day)\n' % (
core_team_size,
float(core_total) / options.days / core_team_size))
file_obj.write(
'New patch sets in the last %d days: %d (%.1f/day)\n'
% (options.days, patches_created,
float(patches_created) / options.days))
file_obj.write(
'Changes involved in the last %d days: %d (%.1f/day)\n'
% (options.days, changes_involved,
float(changes_involved) / options.days))
file_obj.write(
' New changes in the last %d days: %d (%.1f/day)\n'
% (options.days, changes_created,
float(changes_created) / options.days))
file_obj.write(
' Changes merged in the last %d days: %d (%.1f/day)\n'
% (options.days, changes_merged,
float(changes_merged) / options.days))
file_obj.write(
' Changes abandoned in the last %d days: %d (%.1f/day)\n'
% (options.days, changes_abandoned,
float(changes_abandoned) / options.days))
file_obj.write(
(' Changes left in state WIP in the last %d days: %d '
'(%.1f/day)\n')
% (options.days, changes_wip,
float(changes_wip) / options.days))
queue_growth = (changes_created - changes_merged -
changes_abandoned - changes_wip)
file_obj.write(
(' Queue growth in the last %d days: %d '
'(%.1f/day)\n')
% (options.days, queue_growth,
float(queue_growth) / options.days))
file_obj.write(
' Average number of patches per changeset: %.1f\n'
% (float(patches_created) / changes_involved))
file_obj.write(
'\n(*) Disagreements are defined as a +1 or +2 vote on a '
'patch where a core team member later gave a -1 or -2 vote'
', or a negative vote overridden with a positive one '
'afterwards.\n')
file_obj.write(
'\n(***) Received - the number of reviews that this person '
'received on their patches in this time period. The given '
'ratio is the number of reviews given over the number '
'received.\n')
finally:
if on_done:
on_done()
return 0