56cc948e8e
I'm about to bring in regular project infrastructure, this is a prelude to that. Change-Id: I58031c196ee4105a4f51c99bf7a9ad3a29d84c6b
327 lines
13 KiB
Python
Executable File
327 lines
13 KiB
Python
Executable File
#!/usr/bin/env python
|
|
# -*- 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
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|