#!/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 try: import cPickle as pickle except ImportError: import 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()