fedora-infrastructure/scripts/review-stats/review-stats.py

615 lines
21 KiB
Python
Raw Normal View History

#!/usr/bin/python -t
VERSION = "3.1"
# $Id: review-stats.py,v 1.12 2010/01/15 05:14:10 tibbs Exp $
# Note: This script presently lives in internal git and external cvs. External
# cvs is:
# http://cvs.fedoraproject.org/viewvc/status-report-scripts/review-stats.py?root=fedora
# or check it out with
# CVSROOT=:pserver:anonymous@cvs.fedoraproject.org:/cvs/fedora cvs co status-report-scripts
#
# Internal is in the puppet configs repository on puppet1. It needs to be
# there so that puppet can distribute to the servers. I recommend doing the
# work in the public cvs first, then copying to puppet's git after.
import bugzilla
import datetime
import glob
2012-04-25 18:04:33 -05:00
import logging
import operator
import os
import string
import sys
import tempfile
2012-06-12 16:32:02 -05:00
import time
2012-08-15 17:40:42 -05:00
from configobj import ConfigObj, flatten_errors
from copy import deepcopy
from genshi.template import TemplateLoader
from optparse import OptionParser
2012-08-15 17:40:42 -05:00
from validate import Validator
# Red Hat's bugzilla
url = 'https://bugzilla.redhat.com/xmlrpc.cgi'
# Some magic bug numbers
2012-08-15 17:40:42 -05:00
ACCEPT = 163779
BUNDLED = 658489
FEATURE = 654686
GUIDELINES = 197974
LEGAL = 182235
NEEDSPONSOR = 177841
SCITECH = 505154
# These will show up in a query but aren't actual review tickets
trackers = set([ACCEPT, BUNDLED, FEATURE, NEEDSPONSOR, GUIDELINES, SCITECH])
2012-04-25 18:04:33 -05:00
# So the bugzilla module has some way to complain
logging.basicConfig()
2012-05-30 14:44:45 -05:00
#logging.basicConfig(level=logging.DEBUG)
2012-04-25 18:04:33 -05:00
def parse_commandline():
2012-08-15 17:40:42 -05:00
usage = "usage: %prog [options] -c <bugzilla_config> -d <dest_dir> -t <template_dir>"
parser = OptionParser(usage)
2012-08-15 17:40:42 -05:00
parser.add_option("-c", "--config", dest="configfile",
help="configuration file name")
parser.add_option("-d", "--destination", dest="dirname",
help="destination directory")
parser.add_option("-f", "--frequency", dest="frequency",
help="update frequency", default="60")
parser.add_option("-t", "--templatedir", dest="templdir",
help="template directory")
2013-06-12 12:06:33 -05:00
parser.add_option("-v", "--verbose", action="store_true", dest="verbose",
help="run verbosely")
(options, args) = parser.parse_args()
tst = str(options.dirname)
if str(options.dirname) == 'None':
parser.error("Please specify destination directory")
if not os.path.isdir(options.dirname):
parser.error("Please specify an existing destination directory")
if str(options.templdir) == 'None':
parser.error("Please specify templates directory")
if not os.path.isdir(options.templdir):
parser.error("Please specify an existing template directory")
return options
2012-08-15 17:40:42 -05:00
def parse_config(file):
v = Validator()
spec = '''
[global]
url = string(default='https://bugzilla.redhat.com/xmlrpc.cgi')
username = string()
password = string()
'''.splitlines()
cfg = ConfigObj(file, configspec=spec)
res = cfg.validate(v, preserve_errors=True)
for entry in flatten_errors(cfg, res):
section_list, key, error = entry
section_list.append(key)
section_string = ','.join(section_list)
if error == False:
error = 'Missing value or section.'
print ','.join(section_list), '=', error
sys.exit(1)
return cfg['global']
def nobody(str):
'''Shorten the long "nobody's working on it" string.'''
if (str == "Nobody's working on this, feel free to take it"
or str == "nobody@fedoraproject.org"):
return "(Nobody)"
return str
def nosec(str):
'''Remove the seconds from an hh:mm:ss format string.'''
return str[0:str.rfind(':')]
def human_date(t):
2012-08-15 17:40:42 -05:00
'''Turn an ISO date into something more human-friendly.'''
t = str(t)
return t[0:4] + '-' + t[4:6] + '-' + t[6:8]
def human_time(t):
'''Turn an ISO date into something more human-friendly, with time.'''
t = str(t)
2012-08-15 17:40:42 -05:00
return t[0:4] + '-' + t[4:6] + '-' + t[6:8] + ' ' + t[9:]
def to_unicode(object, encoding='utf8', errors='replace'):
if isinstance(object, basestring):
if isinstance(object, str):
return unicode(object, encoding, errors)
else:
return object
return u''
def reporter(bug):
'''Extract the reporter from a bug, replacing an empty value with "(none)".
Yes, bugzilla will return a blank reporter for some reason.'''
if (bug.reporter) == '':
return "(none)"
return bug.reporter
2012-08-15 17:40:42 -05:00
def yrmonth(d):
'''Turn a bugzilla date into Month YYYY string.'''
m = ['January', 'February', 'March', 'April', 'May', 'June', 'July',
'August', 'September', 'October', 'November', 'December']
2012-08-15 17:40:42 -05:00
#year = str.split('-')[0]
#month = int(str.split('-')[1])-1
str = d.value
year = str[0:4]
month = int(str[4:6])-1
return m[month] + ' ' + year
2013-06-12 12:06:33 -05:00
def dbprint(str):
'''Print string if verbosity is turned on.'''
if verbose:
print(str)
def seq_max_split(seq, max_entries):
""" Given a seq, split into a list of lists of length max_entries each. """
ret = []
num = len(seq)
seq = list(seq) # Trying to use a set/etc. here is bad
beg = 0
while num > max_entries:
end = beg + max_entries
ret.append(seq[beg:end])
beg += max_entries
num -= max_entries
ret.append(seq[beg:])
return ret
def run_query(bz):
querydata = {}
bugdata = {}
interesting = {}
alldeps = set([])
closeddeps = set([])
needinfo = set([])
usermap = {}
2012-08-15 17:40:42 -05:00
querydata['include_fields'] = ['id', 'creation_time', 'last_change_time', 'bug_severity',
'alias', 'assigned_to', 'product', 'creator', 'creator_id', 'status', 'resolution',
'component', 'blocks', 'depends_on', 'summary',
'whiteboard', 'flags']
#querydata['extra_values'] = []
2012-05-30 14:44:45 -05:00
querydata['bug_status'] = ['NEW', 'ASSIGNED', 'MODIFIED']
2012-06-12 20:02:05 -05:00
querydata['product'] = ['Fedora', 'Fedora EPEL']
querydata['component'] = ['Package Review']
2012-08-15 17:40:42 -05:00
querydata['query_format'] = 'advanced'
# Look up tickets with no fedora-review flag set
2012-08-15 17:40:42 -05:00
querydata['f1'] = 'flagtypes.name'
querydata['o1'] = 'notregexp'
querydata['v1'] = 'fedora-review[-+?]'
2013-06-12 12:06:33 -05:00
dbprint("Running main query.")
2013-06-14 16:31:25 -05:00
t = time.time()
2012-08-15 17:40:42 -05:00
bugs = filter(lambda b: b.id not in trackers, bz.query(querydata))
2013-06-14 16:31:25 -05:00
dbprint("Done, took {:.2f}.".format(time.time()-t))
for bug in bugs:
2012-08-15 17:40:42 -05:00
bugdata[bug.id] = {}
bugdata[bug.id]['hidden'] = []
2012-08-15 17:40:42 -05:00
bugdata[bug.id]['blocks'] = bug.blocks
bugdata[bug.id]['depends'] = bug.depends_on
bugdata[bug.id]['reviewflag'] = ' '
2012-08-15 17:40:42 -05:00
if bug.depends_on:
alldeps.update(bug.depends_on)
# Now have complete flag info; don't need to query it separately
2013-04-08 21:58:59 -05:00
for flag in bug.flags:
if (flag['name'] == 'needinfo'
and flag['status'] == '?'
and 'requestee' in flag
and flag['requestee'] == bug.creator):
bugdata[bug.id]['hidden'].append('needinfo')
2013-06-14 18:58:58 -05:00
# Find which of the dependencies are closed
2013-06-14 16:31:25 -05:00
dbprint("Looking up {} bug deps.".format(len(alldeps)))
2013-06-14 18:58:58 -05:00
t=time.time()
2013-06-14 16:31:25 -05:00
for bug in filter(None, bz.query(bz.build_query(bug_id=list(alldeps), status=["CLOSED"]))):
closeddeps.add(bug.id)
dbprint("Done; took {:.2f}.".format(time.time()-t))
# Hide tickets blocked by other bugs or those with various blockers and
# statuses.
def opendep(id): return id not in closeddeps
for bug in bugs:
2012-08-15 17:40:42 -05:00
wb = string.lower(bug.whiteboard)
if bug.bug_status != 'CLOSED':
if wb.find('notready') >= 0:
bugdata[bug.id]['hidden'].append('notready')
if wb.find('buildfails') >= 0:
bugdata[bug.id]['hidden'].append('buildfails')
if wb.find('stalledsubmitter') >= 0:
bugdata[bug.id]['hidden'].append('stalled')
if wb.find('awaitingsubmitter') >= 0:
bugdata[bug.id]['hidden'].append('stalled')
if BUNDLED in bugdata[bug.id]['blocks']:
bugdata[bug.id]['hidden'].append('bundled')
if LEGAL in bugdata[bug.id]['blocks']:
bugdata[bug.id]['hidden'].append('legal')
if filter(opendep, bugdata[bug.id]['depends']):
bugdata[bug.id]['hidden'].append('blocked')
# Now we need to look up the names of the users
for i in bugs:
if select_needsponsor(i, bugdata[i.id]):
usermap[i.reporter] = ''
2013-06-14 18:58:58 -05:00
dbprint("Looking up {} user names.".format(len(usermap)))
t=time.time()
for i in bz._proxy.User.get({'names': usermap.keys()})['users']:
usermap[i['name']] = i['real_name']
2013-06-14 18:58:58 -05:00
dbprint("Done; took {:.2f}.".format(time.time()-t))
# Now process the other three flags; not much special processing for them
2012-08-15 17:40:42 -05:00
querydata['o1'] = 'equals'
# for i in ['-', '+', '?']:
2012-06-12 20:40:18 -05:00
for i in ['-', '?']:
2012-08-15 17:40:42 -05:00
querydata['v1'] = 'fedora-review' + i
2013-06-14 18:58:58 -05:00
dbprint("Looking up tickets with flag {}.".format(i))
t=time.time()
b1 = bz.query(querydata)
2013-06-14 18:58:58 -05:00
dbprint("Done; took {:.2f}.".format(time.time()-t))
for bug in b1:
2012-08-15 17:40:42 -05:00
bugdata[bug.id] = {}
bugdata[bug.id]['hidden'] = []
2012-08-15 17:40:42 -05:00
bugdata[bug.id]['blocks'] = []
bugdata[bug.id]['depends'] = []
bugdata[bug.id]['reviewflag'] = i
bugs += b1
2012-08-15 17:40:42 -05:00
bugs.sort(key=operator.attrgetter('id'))
return [bugs, bugdata, usermap]
# Need to generate reports:
# "Accepted" and closed
# "Accepted" but still open
# "Accepted" means either fedora-review+ or blocking FE-ACCEPT
# fedora-review- and closed
# fedora-review- but still open
# fedora-review? and still optn
# fedora-review? but closed
# Tickets awaiting review but which were hidden for some reason
# That should be all tickets in the Package Review component
def write_html(loader, template, data, dir, fname):
'''Load and render the given template with the given data to the given
filename in the specified directory.'''
tmpl = loader.load(template)
output = tmpl.generate(**data)
path = os.path.join(dir, fname)
try:
f = open(path, "w")
except IOError, (err, strerr):
print 'ERROR: %s: %s' % (strerr, path)
sys.exit(1)
f.write(output.render())
f.close()
# Selection functions (should all be predicates)
def select_hidden(bug, bugd):
if len(bugd['hidden']) > 0:
return 1
return 0
def select_merge(bug, bugd):
if (bugd['reviewflag'] == ' '
and bug.bug_status != 'CLOSED'
and bug.short_desc.find('Merge Review') >= 0):
return 1
return 0
def select_needsponsor(bug, bugd):
2012-08-15 17:40:42 -05:00
wb = string.lower(bug.whiteboard)
if (bugd['reviewflag'] == ' '
and 'needinfo' not in bugd['hidden']
2012-08-15 17:40:42 -05:00
and NEEDSPONSOR in bugd['blocks']
and LEGAL not in bugd['blocks']
and bug.bug_status != 'CLOSED'
and nobody(bug.assigned_to) == '(Nobody)'
2012-08-15 17:40:42 -05:00
and wb.find('buildfails') < 0
and wb.find('notready') < 0
and wb.find('stalledsubmitter') < 0
and wb.find('awaitingsubmitter') < 0):
return 1
return 0
2012-06-12 21:04:09 -05:00
def select_review(bug, bugd):
if bugd['reviewflag'] == '?':
return 1
return 0
2012-06-12 20:34:39 -05:00
def select_trivial(bug, bugd):
if (bugd['reviewflag'] == ' '
and bug.bug_status != 'CLOSED'
and string.lower(bug.status_whiteboard).find('trivial') >= 0):
return 1
return 0
2012-06-12 20:02:05 -05:00
def select_epel(bug, bugd):
'''If someone assigns themself to a ticket, it's theirs regardless of
whether they set the flag properly or not.'''
if (bugd['reviewflag'] == ' '
and bug.product == 'Fedora EPEL'
and bug.bug_status != 'CLOSED'
and len(bugd['hidden']) == 0
2012-06-12 20:02:05 -05:00
and nobody(bug.assigned_to) == '(Nobody)'
and bug.short_desc.find('Merge Review') < 0):
return 1
return 0
def select_new(bug, bugd):
'''If someone assigns themself to a ticket, it's theirs regardless of
whether they set the flag properly or not.'''
if (bugd['reviewflag'] == ' '
2012-06-12 20:02:05 -05:00
and bug.product == 'Fedora'
and bug.bug_status != 'CLOSED'
and len(bugd['hidden']) == 0
and nobody(bug.assigned_to) == '(Nobody)'
and bug.short_desc.find('Merge Review') < 0):
return 1
return 0
2012-06-12 21:51:01 -05:00
def rowclass_plain(count):
rowclass = 'bz_row_even'
if count % 2 == 1:
rowclass = 'bz_row_odd'
# Yes, the even/odd classes look backwards, but it looks better this way
def rowclass_with_sponsor(bug, count):
rowclass = 'bz_row_odd'
2012-08-15 17:40:42 -05:00
if NEEDSPONSOR in bug['blocks']:
2012-06-12 21:51:01 -05:00
rowclass = 'bz_state_NEEDSPONSOR'
2012-08-15 17:40:42 -05:00
elif FEATURE in bug['blocks']:
2012-06-12 21:51:01 -05:00
rowclass = 'bz_state_FEATURE'
elif count % 2 == 1:
rowclass = 'bz_row_even'
return rowclass
# The data from a standard row in a bug list
def std_row(bug, rowclass):
2013-04-25 20:27:19 -05:00
alias = ''
if bug.alias:
alias = to_unicode(bug.alias[0])
2012-08-15 17:40:42 -05:00
return {'id': bug.id,
2013-04-25 20:27:19 -05:00
'alias': alias,
'assignee': nobody(to_unicode(bug.assigned_to)),
'class': rowclass,
2012-08-15 17:40:42 -05:00
'lastchange': human_time(bug.last_change_time),
'status': bug.bug_status,
'summary': to_unicode(bug.short_desc),
}
# Report generators
def report_hidden(bugs, bugdata, loader, tmpdir, subs):
data = deepcopy(subs)
data['description'] = 'This page lists all review tickets are hidden from the main review queues'
data['title'] = 'Hidden reviews'
curmonth = ''
for i in bugs:
2012-08-15 17:40:42 -05:00
if select_hidden(i, bugdata[i.id]):
rowclass = rowclass_with_sponsor(bugdata[i.id], data['count'])
data['bugs'].append(std_row(i, rowclass))
data['count'] +=1
write_html(loader, 'plain.html', data, tmpdir, 'HIDDEN.html')
return data['count']
2012-06-12 21:04:09 -05:00
def report_review(bugs, bugdata, loader, tmpdir, subs):
data = deepcopy(subs)
data['description'] = 'This page lists tickets currently under review'
data['title'] = 'Tickets under review'
for i in bugs:
2012-08-15 17:40:42 -05:00
if select_review(i, bugdata[i.id]):
2012-06-12 21:51:01 -05:00
rowclass = rowclass_plain(data['count'])
2012-06-12 21:04:09 -05:00
data['bugs'].append(std_row(i, rowclass))
data['count'] +=1
write_html(loader, 'plain.html', data, tmpdir, 'REVIEW.html')
return data['count']
2012-06-12 20:34:39 -05:00
def report_trivial(bugs, bugdata, loader, tmpdir, subs):
data = deepcopy(subs)
data['description'] = 'This page lists review tickets marked as trivial'
data['title'] = 'Trivial reviews'
for i in bugs:
2012-08-15 17:40:42 -05:00
if select_trivial(i, bugdata[i.id]):
2012-06-12 21:51:01 -05:00
rowclass = rowclass_plain(data['count'])
2012-06-12 20:34:39 -05:00
data['bugs'].append(std_row(i, rowclass))
data['count'] +=1
write_html(loader, 'plain.html', data, tmpdir, 'TRIVIAL.html')
return data['count']
def report_merge(bugs, bugdata, loader, tmpdir, subs):
data = deepcopy(subs)
data['description'] = 'This page lists all merge review tickets which need reviewers'
data['title'] = 'Merge reviews'
for i in bugs:
2012-08-15 17:40:42 -05:00
if select_merge(i, bugdata[i.id]):
2012-06-12 21:51:01 -05:00
rowclass = rowclass_plain(data['count'])
data['bugs'].append(std_row(i, rowclass))
data['count'] +=1
write_html(loader, 'plain.html', data, tmpdir, 'MERGE.html')
return data['count']
def report_needsponsor(bugs, bugdata, loader, usermap, tmpdir, subs):
data = deepcopy(subs)
data['description'] = 'This page lists all new NEEDSPONSOR tickets (those without the fedora-revlew flag set).'
data['title'] = 'NEEDSPONSOR tickets'
curreporter = ''
curcount = 0
oldest = {}
selected = []
for i in bugs:
2012-08-15 17:40:42 -05:00
if select_needsponsor(i, bugdata[i.id]):
selected.append(i)
# Determine the oldest reported bug
for i in selected:
if i.reporter not in oldest:
oldest[i.reporter] = i.creation_time
elif i.creation_time < oldest[i.reporter]:
oldest[i.reporter] = i.creation_time
selected.sort(key=reporter)
selected.sort(key=lambda a: oldest[a.reporter])
for i in selected:
2012-06-12 21:51:01 -05:00
rowclass = rowclass_plain(data['count'])
r = i.reporter;
if curreporter != r:
if (r in usermap and len(usermap[r])):
name = usermap[r]
else:
name = r
data['packagers'].append({'email': r, 'name': name, 'oldest': human_date(oldest[r]), 'bugs': []})
curreporter = r
curcount = 0
data['packagers'][-1]['bugs'].append(std_row(i, rowclass))
data['count'] +=1
curcount +=1
write_html(loader, 'needsponsor.html', data, tmpdir, 'NEEDSPONSOR.html')
return data['count']
2012-06-12 20:02:05 -05:00
def report_epel(bugs, bugdata, loader, tmpdir, subs):
data = deepcopy(subs)
2012-06-12 20:02:05 -05:00
data['description'] = 'This page lists new, reviewable EPEL package review tickets. Tickets colored green require a sponsor.'
data['title'] = 'New EPEL package review tickets'
curmonth = ''
curcount = 0
for i in bugs:
2012-08-15 17:40:42 -05:00
if select_epel(i, bugdata[i.id]):
if curmonth != yrmonth(i.creation_time):
2012-06-12 20:02:05 -05:00
if curcount > 0:
data['months'][-1]['month'] += (" (%d)" % curcount)
2012-08-15 17:40:42 -05:00
data['months'].append({'month': yrmonth(i.creation_time), 'bugs': []})
curmonth = yrmonth(i.creation_time)
2012-06-12 20:02:05 -05:00
curcount = 0
2012-08-15 17:40:42 -05:00
rowclass = rowclass_with_sponsor(bugdata[i.id], curcount)
2012-06-12 20:02:05 -05:00
data['months'][-1]['bugs'].append(std_row(i, rowclass))
data['count'] +=1
curcount +=1
if curcount > 0:
data['months'][-1]['month'] += (" (%d)" % curcount)
write_html(loader, 'bymonth.html', data, tmpdir, 'EPEL.html')
return data['count']
def report_new(bugs, bugdata, loader, tmpdir, subs):
data = deepcopy(subs)
data['description'] = 'This page lists new, reviewable Fedora package review tickets (excluding merge reviews). Tickets colored green require a sponsor.'
data['title'] = 'New package review tickets'
curmonth = ''
curcount = 0
for i in bugs:
2012-08-15 17:40:42 -05:00
if select_new(i, bugdata[i.id]):
if curmonth != yrmonth(i.creation_time):
if curcount > 0:
data['months'][-1]['month'] += (" (%d)" % curcount)
2012-08-15 17:40:42 -05:00
data['months'].append({'month': yrmonth(i.creation_time), 'bugs': []})
curmonth = yrmonth(i.creation_time)
curcount = 0
2012-08-15 17:40:42 -05:00
rowclass = rowclass_with_sponsor(bugdata[i.id], curcount)
data['months'][-1]['bugs'].append(std_row(i, rowclass))
data['count'] +=1
curcount +=1
if curcount > 0:
data['months'][-1]['month'] += (" (%d)" % curcount)
write_html(loader, 'bymonth.html', data, tmpdir, 'NEW.html')
return data['count']
if __name__ == '__main__':
options = parse_commandline()
2013-06-12 12:06:33 -05:00
verbose = options.verbose
2012-08-15 17:40:42 -05:00
config = parse_config(options.configfile)
bz = bugzilla.RHBugzilla(url=config['url'], cookiefile=None, user=config['username'], password=config['password'])
2012-06-12 16:32:02 -05:00
t = time.time()
(bugs, bugdata, usermap) = run_query(bz)
2012-06-12 16:32:02 -05:00
querytime = time.time() - t
# Don't bother running this stuff until the query completes, since it fails
# so often.
loader = TemplateLoader(options.templdir)
tmpdir = tempfile.mkdtemp(dir=options.dirname)
# The initial set of substitutions that's shared between the report functions
subs = {
'update': datetime.datetime.now().strftime('%Y-%m-%d %H:%M'),
2012-06-12 16:32:02 -05:00
'querytime': querytime,
'version': VERSION,
'count': 0,
'months': [],
'packagers': [],
'bugs': [],
}
args = {'bugs':bugs, 'bugdata':bugdata, 'loader':loader, 'tmpdir':tmpdir, 'subs':subs}
2012-06-12 21:04:09 -05:00
t = time.time()
subs['new'] = report_new(**args)
2012-06-12 20:02:05 -05:00
subs['epel'] = report_epel(**args)
2012-06-12 20:34:39 -05:00
subs['hidden'] = report_hidden(**args)
subs['merge'] = report_merge(**args)
subs['needsponsor'] = report_needsponsor(usermap=usermap, **args)
2012-06-12 21:04:09 -05:00
subs['review'] = report_review(**args)
2012-06-12 20:34:39 -05:00
subs['trivial'] = report_trivial(**args)
# data['accepted_closed'] = report_accepted_closed(bugs, bugdata, loader, tmpdir)
# data['accepted_open'] = report_accepted_open(bugs, bugdata, loader, tmpdir)
# data['rejected_closed'] = report_rejected_closed(bugs, bugdata, loader, tmpdir)
# data['rejected_open'] = report_rejected_open(bugs, bugdata, loader, tmpdir)
# data['review_closed'] = report_review_closed(bugs, bugdata, loader, tmpdir)
# data['review_open'] = report_review_open(bugs, bugdata, loader, tmpdir)
2012-06-12 21:04:09 -05:00
subs['outputtime'] = time.time() - t
write_html(loader, 'index.html', subs, tmpdir, 'index.html')
for filename in glob.glob(os.path.join(tmpdir, '*')):
newFilename = os.path.basename(filename)
os.rename(filename, os.path.join(options.dirname, newFilename))
os.rmdir(tmpdir)
sys.exit(0)