1686cbb009
When checking if a bug is closed we go through all of its affecting project statuses, and if a status is Won't Fix the bug will stay on the recheckwatch list. But if a bug is marked as won't fix its safe to assume the root cause of the issue is fixed. Otherwise the bug will happen again and someone will set the status back to open. For example: https://bugs.launchpad.net/nova/+bug/1186867 Change-Id: Iaddc4990062d5a80e188f10c14e91625b7605165
235 lines
7.3 KiB
Python
Executable File
235 lines
7.3 KiB
Python
Executable File
#!/usr/bin/env python
|
|
# Copyright 2012 OpenStack Foundation
|
|
#
|
|
# 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 ConfigParser
|
|
import datetime
|
|
import re
|
|
import sys
|
|
import threading
|
|
import traceback
|
|
import cPickle as pickle
|
|
import os
|
|
|
|
from genshi.template import TemplateLoader
|
|
from launchpadlib.launchpad import Launchpad
|
|
from launchpadlib.uris import LPNET_SERVICE_ROOT
|
|
import daemon
|
|
|
|
CLOSED_STATUSES = ['Fix Released', 'Invalid', 'Fix Committed', 'Won\'t Fix']
|
|
|
|
try:
|
|
import daemon.pidlockfile
|
|
pid_file_module = daemon.pidlockfile
|
|
except:
|
|
# as of python-daemon 1.6 it doesn't bundle pidlockfile anymore
|
|
# instead it depends on lockfile-0.9.1
|
|
import daemon.pidfile
|
|
pid_file_module = daemon.pidfile
|
|
|
|
|
|
class Hit(object):
|
|
def __init__(self, project, change):
|
|
self.project = project
|
|
self.change = change
|
|
self.ts = datetime.datetime.utcnow()
|
|
|
|
class Bug(object):
|
|
def __init__(self, number):
|
|
self.number = number
|
|
self.hits = []
|
|
self.projects = []
|
|
self.changes = []
|
|
self.last_seen = None
|
|
self.first_seen = None
|
|
self.duplicate_of = None
|
|
self.update()
|
|
|
|
def update(self):
|
|
launchpad = Launchpad.login_anonymously('recheckwatch',
|
|
'production')
|
|
lpitem = launchpad.bugs[self.number]
|
|
self.title = lpitem.title
|
|
self.status = map(lambda x: x.status,
|
|
lpitem.bug_tasks)
|
|
if lpitem.duplicate_of:
|
|
self.duplicate_of = lpitem.duplicate_of.id
|
|
|
|
def is_closed(self):
|
|
closed = True
|
|
for status in self.status:
|
|
if status not in CLOSED_STATUSES:
|
|
closed = False
|
|
return closed
|
|
|
|
def addHit(self, hit):
|
|
self.hits.append(hit)
|
|
if not self.first_seen:
|
|
self.first_seen = hit.ts
|
|
if hit.project not in self.projects:
|
|
self.projects.append(hit.project)
|
|
if hit.change not in self.changes:
|
|
self.changes.append(hit.change)
|
|
self.last_seen = hit.ts
|
|
|
|
def addHits(self, hits):
|
|
for hit in hits:
|
|
self.addHit(hit)
|
|
|
|
class Scoreboard(threading.Thread):
|
|
def __init__(self, config):
|
|
threading.Thread.__init__(self)
|
|
self.scores = {}
|
|
|
|
server = config.get('gerrit', 'host')
|
|
username = config.get('gerrit', 'user')
|
|
port = config.getint('gerrit', 'port')
|
|
keyfile = config.get('gerrit', 'key', None)
|
|
|
|
self.pickle_dir = config.get('recheckwatch', 'pickle_dir')
|
|
self.pickle_file = os.path.join(self.pickle_dir, 'scoreboard.pickle')
|
|
self.template_dir = config.get('recheckwatch', 'template_dir')
|
|
self.output_file = config.get('recheckwatch', 'output_file')
|
|
self.age = config.getint('recheckwatch', 'age')
|
|
self.closed_age = config.getint('recheckwatch', 'closed_age')
|
|
self.regex = re.compile(config.get('recheckwatch', 'regex'))
|
|
|
|
if os.path.exists(self.pickle_file):
|
|
out = open(self.pickle_file, 'rb')
|
|
self.scores = pickle.load(out)
|
|
out.close()
|
|
|
|
# Import here because it needs to happen after daemonization
|
|
import gerritlib.gerrit
|
|
self.gerrit = gerritlib.gerrit.Gerrit(server, username, port, keyfile)
|
|
self._update_bug_format()
|
|
self.update()
|
|
|
|
def _update_bug_format(self):
|
|
for bugno, bug in self.scores.items():
|
|
if not hasattr(bug, 'duplicate_of'):
|
|
bug.duplicate_of = None
|
|
bug.update()
|
|
|
|
def _read(self, data):
|
|
if data.get('type', '') != 'comment-added':
|
|
return
|
|
comment = data.get('comment', '')
|
|
m = self.regex.match(comment.strip())
|
|
if not m:
|
|
return
|
|
change_record = data.get('change', {})
|
|
change = change_record.get('number')
|
|
project = change_record.get('project')
|
|
bugno = int(m.group('bugno'))
|
|
hit = Hit(project, change)
|
|
bug = self._get_bug(bugno)
|
|
bug.addHit(hit)
|
|
self.scores[bugno] = bug
|
|
self.update()
|
|
|
|
def _get_bug(self, bugno):
|
|
""""Get latest bug information and create bug if not in score."""
|
|
bug = self.scores.get(bugno)
|
|
if not bug:
|
|
bug = Bug(bugno)
|
|
else:
|
|
bug.update()
|
|
return bug
|
|
|
|
def update(self):
|
|
# Check for duplicate bugs
|
|
dupes = []
|
|
for bugno, bug in self.scores.items():
|
|
if bug.duplicate_of:
|
|
dupes.append(bugno)
|
|
for bugno in dupes:
|
|
dupno = self.scores[bugno].duplicate_of
|
|
bug = self._get_bug(dupno)
|
|
bug.addHits(self.scores[bugno].hits)
|
|
self.scores[dupno] = bug
|
|
del self.scores[bugno]
|
|
|
|
# Remove bugs that haven't been seen in ages
|
|
# Or closed bugs older then self.closed_age
|
|
to_remove = []
|
|
now = datetime.datetime.utcnow()
|
|
for bugno, bug in self.scores.items():
|
|
if (bug.last_seen < now-datetime.timedelta(days=self.age) or
|
|
(bug.is_closed() and
|
|
bug.last_seen < now-datetime.timedelta(days=self.closed_age))):
|
|
to_remove.append(bugno)
|
|
for bugno in to_remove:
|
|
del self.scores[bugno]
|
|
|
|
def impact(bug):
|
|
"""Golf rules for bugs, smaller the more urgent."""
|
|
age = (now - bug.last_seen).total_seconds()
|
|
|
|
if bug.is_closed():
|
|
age = age + (5.0 * 86400.0)
|
|
|
|
return age
|
|
|
|
# Get the bugs reverse sorted by impact
|
|
bugs = self.scores.values()
|
|
# freshen to get lp bug status
|
|
for bug in bugs:
|
|
bug.update()
|
|
bugs.sort(lambda a,b: cmp(impact(a), impact(b)))
|
|
|
|
loader = TemplateLoader([self.template_dir], auto_reload=True)
|
|
tmpl = loader.load('scoreboard.html')
|
|
out = open(self.output_file, 'w')
|
|
out.write(tmpl.generate(bugs = bugs).render('html', doctype='html'))
|
|
|
|
out = open(self.pickle_file, 'wb')
|
|
pickle.dump(self.scores, out, -1)
|
|
out.close()
|
|
|
|
def run(self):
|
|
self.gerrit.startWatching()
|
|
while True:
|
|
event = self.gerrit.getEvent()
|
|
try:
|
|
self._read(event)
|
|
except:
|
|
traceback.print_exc()
|
|
|
|
def _main(daemonize=True):
|
|
config = ConfigParser.ConfigParser()
|
|
config.read(sys.argv[1])
|
|
|
|
s = Scoreboard(config)
|
|
if daemonize:
|
|
s.start()
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
print "Usage: %s CONFIGFILE" % sys.argv[0]
|
|
sys.exit(1)
|
|
|
|
if '-n' in sys.argv:
|
|
_main(daemonize=False)
|
|
elif '-d' in sys.argv:
|
|
_main()
|
|
else:
|
|
pid = pid_file_module.TimeoutPIDLockFile(
|
|
"/var/run/recheckwatch/recheckwatch.pid", 10)
|
|
with daemon.DaemonContext(pidfile=pid):
|
|
_main()
|
|
|
|
if __name__ == "__main__":
|
|
main()
|