#!/usr/bin/python
# Make sure you are using the python that has the roundup runtime used by the
# tracker. Requires Python 2.3 or later.
#
# Script to create a tracker summary by Richard Jones and Paul Dubois
import sys, math
# kludge
sys.path.insert(1,'/home/roundup/roundup/lib/python2.4/site-packages')
#
import roundup
import roundup.date, roundup.instance
import optparse , datetime
import cStringIO, MimeWriter, smtplib
from roundup.mailer import SMTPConnection
### CONFIGURATION
# List of statuses to treat as closed -- these don't have to all exist.
resolvedStatusDefault = 'resolved,done,done-cbb,closed,faq'
# Period of time for report. Uses roundup syntax for ranges.
defaultDates = '-1w;'
# Email address of recipient of report, if any.
defaultMailTo = ''
# Comma-delimited list of statuses not to include in report.
ignoredStatusesDefault = ''
# Number of most-discussed messages to display
discussionMax = 10
# Smallest number of messages to be eligible for 'most discussed'.
discussionThreshold = 3
# Date format per time.strftime
dateFormat="%F" # ISO 8601: yyyy-mm-dd
##### END CONFIGURATION; ALSO SEE CUSTOMIZATION BELOW
usage = """%prog trackerHome [--mail mailTo]
[--dates 'date1;date2']
[--brief]
[--text]
# less likely
[--resolved 'status1,status2']
[--status 'status1,status2']
[--output filename]
[--errors filename]
# for maintainers
[--DEBUG]
[--AUDIT recent|all]
dates is a roundup date range such as:
'-1w;' -- the last week (the default)
'-3m;' -- the last 3 months
'from 2006-10-25 to 2006-12-25' -- Thanksgiving to Christmas, 2006
mailTo is one or more email addresses, comma delimited.
resolved is a list of statuses to treat as 'closed'.
statuses are names of statuses to ignore.
Be sure to protect commas and semicolons from the shell with quotes!
Execute %prog --help for detailed help
"""
#### Options
parser = optparse.OptionParser(usage=usage)
parser.add_option('-b','--brief', dest='brief', action='store_true',
default=False,
help='Show summary only, no tables.')
parser.add_option('-a','--audit', dest='audit',
default='', metavar = 'all|recent',
help='Print journal for "all" or "recent" transactions, then halt.')
parser.add_option('-D','--DEBUG', dest='debug', action='store_true',
default=False,
help='Just print the result; if mailTo set, print email but do not send it.')
parser.add_option('-d','--dates', dest='dates',
default=defaultDates, metavar="'from;to'",
help="""Specification for range of dates, such as: \
'-1w;' -- previous week;
'-1y;' -- previous year;
'from 2006-11-1 to 2006-12-1' -- exact period""")
parser.add_option('-m','--mail', dest='mailTo',
default=defaultMailTo,
help='Mail the report to the address(es) mailTo; if not given, print report.')
parser.add_option('-s','--status', dest='ignore', metavar="STATUSES_TO_IGNORE",
default=ignoredStatusesDefault,
help="Comma-delimited list of statuses to ignore in report.")
parser.add_option('-r','--resolved', dest='resolved',
default=resolvedStatusDefault,
help="Comma-delimited list of statuses that corresponds to being 'closed'.")
parser.add_option('-t', '--text', dest='text', action='store_true',
default=False,
help="Write text report only, no HTML.")
parser.add_option('-o','--output', dest='output',
default='', metavar='FILENAME',
help='File name for output; default is stdout.')
parser.add_option('-e','--errors', dest='errors',
default='', metavar='FILENAME',
help='File name for error output; default is stderr.')
#### Get the command line args:
(options, args) = parser.parse_args()
if options.output:
sys.stdout = open(options.output, 'w')
if options.errors:
sys.stderr = open(options.errors, 'w')
if len(args) != 1:
parser.error("""Incorrect number of arguments;
you must supply a tracker home.""")
instanceHome = args[0]
# Open the instance
instance = roundup.instance.open(instanceHome)
db = instance.open('admin')
# CUSTOMIZATION
columnWidth = 72 #for text report
dateWidth = len(roundup.date.Date('2006-09-30').pretty(format=dateFormat))
titleWidth = columnWidth - dateWidth #zero means no limit
durationWidth = len('9999 days')
tableStyle = 'border="1" cellspacing="0" cellpadding="3"'
def formatDuration(duration):
"Change a duration into text."
return "%4.0f days" % (duration.as_seconds()/(3600.*24),)
def closedOrBlank(statusID):
"CLOSED or a blank"
if statusID in resolvedStatusIDS:
return "CLOSED"
else:
return ""
def F(**args):
"args is the dictionary to return, with additions if desired."
return args
# Set what fields show up in the different tables.
# May need to modify getIssueAttribute to match your schema or add things
# you want to compute.
# Each field has a title and a field width.
# In text mode:
# a title of '
, indent' means a new line is started for each
# record, and the integer indent indicates an indentation for the next line.
# If used, titles are omitted.
# In html mode,
is ignored.
### Text Tables
# The 'Created or Repopened' table, text mode.
createdTable = """
titles:
'Title', titleWidth
'Date', dateWidth
'
', 0
'Status', len('CLOSED')
'Link', linkWidth
'Action', actionWidth
'By', userNameWidth
'
', len('CLOSED')
'Keywords', columnWidth
records:
title
(reopenedDate or creation).pretty(format=dateFormat)
closedOrBlank(statusID)
link(issueNumber)
reopened or 'created'
reopener or creator
keywords
"""
# The table of closed issues, text mode.
closedTable = """
titles:
'Title', titleWidth
'Duration', durationWidth
'
', len('CLOSED')
'Link', linkWidth
'By', userNameWidth
'
', len('CLOSED')
'Keywords', columnWidth
records:
title
formatDuration(duration)
link(issueNumber)
actor
keywords
"""
# The table of most discussed issues, text mode.
discussedTable = """
titles:
'N', -3
'Title', columnWidth - durationWidth
'Duration', durationWidth
'
', 0
'Status', statusWidth
'Link', linkWidth
records:
numberOfMessages
title
formatDuration(duration)
status
link(issueNumber)
"""
### HTML Tables
# The 'Created or Repopened' table, html mode.
# Titles are links, use zero on width; limit is applied in hlink
htmlCreatedTable = """
titles:
'Title', 0
'Status', len('CLOSED')
'Date', dateWidth
'Action', actionWidth
'By', userNameWidth
'Keywords', 15
records:
hlink(issueNumber, title)
closedOrBlank(statusID)
(reopenedDate or creation).pretty(format=dateFormat)
reopened or 'created'
reopener or creator
keywords
"""
# The table of closed issues, html mode.
htmlClosedTable = """
titles:
'Title', 0
'By', userNameWidth
'Duration', durationWidth
'Keywords', 15
records:
hlink(issueNumber, title)
actor
formatDuration(duration)
keywords
"""
# The table of most discussed issues, text mode.
htmlDiscussedTable = """
titles:
'N', -3
'Title', 0
'Status', statusWidth
'Duration', durationWidth
records:
numberOfMessages
hlink(issueNumber, title)
status
formatDuration(duration)
"""
### END CUSTOMIZATION
# well, except you might want to add to this...locals in getIssueAttributes
# are usable in the 'records' section of tables.
# Keywords
patchID = db.keyword.lookup('patch')
keywordIDS = db.keyword.getnodeids(False)
keywordsDict = {}
for id in keywordIDS:
keywordsDict[id] = db.keyword.get(id, 'name')
def isPatch(issueID):
keywordIDS = db.issue.get(issueID, 'keywords')
return patchID in keywordIDS
def getKeywordString (issueID):
keywordIDS = db.issue.get(issueID, 'keywords')
sep = ''
keywords = ''
for id in keywordIDS:
try:
k = keywordsDict[id]
keywords += (sep + k)
sep = ', '
except KeyError: #retired kw
pass
return keywords
def getIssueAttributes (issueID):
"""Return dictionaries with the issue's title, etc.
Assumes issue was created no later than to_value
"""
issueNumber = issueID
reopened = ''
reopener = ''
reopenedDate = ''
numberOfMessages = 0
status2 = db.issue.get(issueID, 'status')
status1 = status2
actor2 = db.issue.get(issueID, 'actor')
activity2 = db.issue.get(issueID, 'activity')
statusID = None
actorID = None
activity = None
keywords = getKeywordString(issueID)
# programming note:
# the journal records the OLD value of the status in the data field
# So we know the end value (status2) and can walk backwards through it
# Want to set statusID = status in effect at to_value
# Also discover any activity that changed a resolved to non-resolved.
journal = db.issue.history(issueID)
journal.reverse()
# this trick catches the first time we are in the interval of interest
if activity2 < to_value:
statusID = statusID or status2
actorID = actorID or actor2
activity = activity or activity2
for x,ts,userid,act,data in journal:
inPeriod = ts >= from_value and ts < to_value
if inPeriod:
statusID = statusID or status2
actorID = actorID or actor2
activity = activity or activity2
if act == 'set':
if data.has_key('messages'):
if inPeriod:
numberOfMessages += 1
if data.has_key('status'):
status1 = data['status']
if ts < to_value:
if (status1 in resolvedStatusIDS) and \
(status2 not in resolvedStatusIDS):
if not reopened: # want the latest reopener only
if inPeriod and issueID not in recentlyCreatedIssueIDS:
reopenedIssueIDS.append(issueID)
reopened = 'reopened'
reopener = userMap[userid]
reopenedDate = ts
actor2 = userid
activity2 = ts
status2 = status1
messages.append((numberOfMessages, issueID))
# get these set if not done before
statusID = statusID or status2
activity = activity or activity2
actorID = actorID or actor2
# calculate the fields for the reports
status = statusMap[statusID]
actor = userMap[actorID]
title = db.issue.get(issueID, 'title')
creation = db.issue.get(issueID, 'creation')
creatorID = db.issue.get(issueID, 'creator')
creator = userMap[creatorID]
if statusID in resolvedStatusIDS:
duration = activity - (reopenedDate or creation)
else:
duration = to_value - (reopenedDate or creation)
if options.debug:
print >>bugfile, issueID, status, creator, actor, "%4.0f" %(duration.as_seconds()/(3600.24)), \
activity.pretty(dateFormat), reopenedDate or 'created', creation.pretty(dateFormat)
# out of a sense of neatness
del status1, status2, actor2, activity2
del x, ts, userid, act, data, journal
# return the dictionary of local values for use in evals of table values.
return locals()
### Utility
def link (issueID):
"url of issue whose ID is issueID"
return "%sissue%s" % (db.config.TRACKER_WEB, issueID)
def hlink (issueID, title):
"html link to url/title of issue whose ID is issueID"
return '''%s''' %(link(issueID), title[:titleWidth])
def statusCompare (x, y):
"""Compare two statusIDs by order."""
xs = db.status.get(x, 'order')
ys = db.status.get(y, 'order')
c = float(xs) - float(ys)
if c >= 0.0:
return int(c)
else:
return -int(abs(c))
def issueIdCompare (x, y):
"""Compare two issue ids numerically."""
return int(x) - int(y)
def issueStatus(issueID):
"""Get the status name from an issueID."""
sid = db.issue.get(issueID, 'status')
return db.status.get(sid, 'name')
def statusUsable (statusID):
"""Is this status is one we want to deal with?"""
if db.status.is_retired(statusID):
return False
if db.status.get(statusID, 'name') in ignoredStatuses:
return False
return True
#Table writers
class Table (object):
def __init__ (self, caption, issueIDS, tableSpec):
self.caption = caption + ' (%d)' % len(issueIDS)
self.issueIDS = issueIDS
self.breaks = []
self.spec = self.parse(tableSpec)
def parse(self, tableSpec):
"""Sample:
titles:
'ID', -6
'Title', 25
'Status', 30
records:
id
title
status
"""
titles = []
fields = []
fws = []
for line in tableSpec.split('\n'):
line = line.strip()
if not line: continue
if line.startswith("titles"):
inTitles = True
elif line.startswith("records"):
inTitles = False
elif inTitles:
title, fw = eval(line)
if title == '
':
self.breaks.append((len(titles), fw))
else:
titles.append(title)
if fw > 0:
fw = max(fw, len(title))
elif fw < 0:
fw = min(fw, -len(title))
fws.append(fw)
else:
fields.append(line)
self.titles = titles
self.fields = fields
self.fws = fws
if len(titles) != len(fields):
raise ValueError, 'Number of titles and fields do not match.'
if len(titles) == 0:
raise ValueError, 'No titles or fields given'
def write(self, device):
if not self.issueIDS:
return
fields = self.fields
fws = self.fws
self.prologue(device)
self.writeFields(device, fields, fws)
self.epilogue(device)
def writeTitles(self, device, titles, fws):
self.startTitles(device, titles, fws)
for i in range(len(titles)):
t = titles[i]
fw = fws[i]
self.startTitle(device, t, fw)
self.writeTitle(device, t, fw)
self.endTitle(device, t, fw)
self.endTitles(device, titles, fws)
def writeFields(self, device, fields, fws):
for issueID in self.issueIDS:
d = issueAttributes[issueID]
self.startFields(d, device, fields, fws)
for i in range(len(fields)):
f = fields[i]
fw = fws[i]
self.startField(d, device, f, fw)
self.writeField(d, device, f, fw)
self.endField(d, device, f, fw)
for count, indent in self.breaks:
if i+1 == count:
print >>device
if indent:
print >>device, ' '.ljust(indent),
self.endFields(d, device, fields, fws)
def prologue(self, device):
print >>device
print >>device, self.caption
print >>device, '_'*len(self.caption)
print >>device
if not self.breaks:
self.writeTitles(device, self.titles, self.fws)
def epilogue(self, device):
print >>device
def startTitles(self, device, titles, fws):
pass
def endTitles(self, device, titles, fws):
print >>device
def startTitle(self, device, title, fw):
pass
def writeTitle(self, device, title, fw):
fwa = abs(fw)
if fwa > 0:
print >>device, title[0:fwa].ljust(fwa),
elif fwa < 0:
print >>device, title[0:fwa].rjust(fwa),
else:
print >>device, title,
def endTitle(self, device, title, fw):
pass
def startFields(self, d, device, fields, fws):
pass
def endFields(self, d, device, fields, fws):
print >>device
if self.breaks: print >>device
def startField(self, d, device, field, fw):
pass
def writeField(self, d, device, field, fw):
fwa = abs(fw)
value = str(eval(field, d, globals()))
if fw > 0:
print >>device, value[0:fwa].ljust(fwa),
elif fw < 0:
print >>device, value[0:fwa].rjust(fwa),
else:
print >>device, value,
def endField(self, d, device, field, fw):
pass
class htmlTable (Table):
def __init__ (self, caption, issueIDS, tableSpec):
super(htmlTable, self).__init__(caption, issueIDS, tableSpec)
def prologue(self, device):
print >>device, """
' print >>body, '''To view or respond to any of the issues listed below, simply click on the issue ID. Do not respond to this message.''' print >>body, '
' print >>body, '' fmt = "%5d open (%+3d) / %5d closed (%+3d) / %5d total (%+3d)" print >>body, '
' print >>body, '' print >>body, fmt % \ (nOpen, nOpen-nOpenOld, nClosed, nClosed-nClosedOld, (nOpen+nClosed), (nOpen+nClosed)-(nOpenOld+nClosedOld)) print >>body, '
' print >>body, "" print >>body, "Open issues with patches: %5d" % nPatch print >>body, '
' print >>body, '' print >>body, "Average duration of open issues:", "%3.0f" % averageDuration, "days." print >>body, '
' print >>body, "" print >>body, "Median duration of open issues:", "%3.0f" % medianDuration, "days." print >>body, '
' print >>body, '' print >>body, '''
| STATUS | Number | Change |
|---|---|---|
| %s | %6d | %+6d |