#!/usr/local/bin/python """Review daemon for Perforce, using the P4Client module. This script should be run at regular intervals or as a daemon. It goes through the changes submitted to the perforce server - including any changes to jobs - and emails a summary to users who are reviewing the appropriate parts of the depot. Copyright 1999, Mike Meyer. All rights reserved. See the comment in the source for redistribution permission. """ # License: # This file and any derivatives or translations of it may be freely # copied and redistributed so long as: # 1) This license and copyright notice are not changed. # 2) Any fixes or enhancements are reported back to either the # author (mwm@phone.net). # and any of: # a) The source is redistributed with it. # b) The source is compiled unmodified, and instructions for finding # the original source are included. # c) The source is made available by some other means. import re, string, time, syslog, smtplib, traceback, cStringIO import P4Client class config: "A namespace to hold the script configuration information." notify_changes = 1 # set to 0 to disable change notification completely notify_jobs = 1 # set to 0 to disable job notification completely bcc_admin = 0 # set to 0 to disable Bcc: of all email to # administrator send_to_author = 0 # set to 1 to have mail sent to author of change or # job reply_to_admin = 0 # set to 1 to enable Reply-To: administrator administrator = 'mwm@phone.net' # The senders address, and the address to # be notified of problems with the script (e.g. # invalid email addresses for users) and, if # bcc_admin is set, you will get a copy of all # email the script generates mailhost = 'localhost' # set to hostname of machine running an SMTP server repeat = 1 # set to 0 to run just once (do this if running from # cron!), set to 1 to repeat every sleeptime seconds sleeptime = 60 # number of seconds to sleep between invocations # (irrelevant if repeat == 0) limit_emails = 100 # don't send more than this many emails of each type # (job and change) at a time datefield = 'Date' # field used to determine job updates # ***Currently you must set this to a field which # has the last modified date rather than creation # date (which means you'll be notified of changed # jobs as well as new ones), since Perforce sets # the creation date when the editor is launched, # not when the job is stored. (Hopefully this will # be fixed in future). jobpath = '//depot/jobs' # send job review mail to users reviewing jobpath facility = syslog.LOG_DAEMON # facility to log complaints to for syslog # This is only used if the designated smtp daemon # can't be opened, or if repeat is set and # administrator is not. Otherwise, complaints get # sent to stdout (repeat not set) or mailed to # administrator user = 'mwm' # perforce user name; must have review priveleges port = '1666' # perforce port # This set the reply-to line for email. Don't change it. if administrator and reply_to_admin: reply_to = "Reply-to: %s\n" % administrator else: reply_to = "" class DictMaker: """DictMaker objects map from re's (with ID's) to dictionaries. The object is created with the re, and then called with the text to be matched against that pattern. The call returns the resulting dictionary object, or None if the search fails.""" def __init__(my, pattern): "Save the (compiled) pattern that this dictionary maker uses." my.pattern = re.compile(pattern) def __call__(my, text): "Return the dictionary associated with parsing text with my pattern" result = my.pattern.search(text) if result: return result.groupdict() return None # JobParser - parses output of "jobs" JobParser = DictMaker(r'^(?P\S+) on \S+ by (?P\S+)') # ChangeParser - parses the output of "review -t review" ChangeParser = DictMaker(r'^Change (?P\d+) (?P\S+) ' r'<(?P\S+)> \((?P[^\)]+)\)') # User - parses output of "users " and "reviews" User = DictMaker(r'^(?P\S+) <(?P\S+)> \((?P[^\)]+)\)') # Ident - pass the line on unchanged Ident = lambda x: x class P4Handler: """Class to wrap P4Client to parse various Output* output formats. The review daemon gets all it's information via the various callback. This class provides callbacks to invoke a user-provided parser, and a method to set itself up, invoke the P4 Client class that calls it, and return the parsed results.""" def __init__(my, **options): "Create the P4Client object I'm wrapping." my.p4 = apply(P4Client.P4Client, (my,), options) my.p4.Init() def Dropped(my): "Check to see if the P4Client connection has been dropped." return my.p4.Dropped() def Final(my): "Close the connection to the P4Client." return my.p4.Final() def Run(my, parser, command, *args): "Run the given command, parsing output with the named parser." my.data = [] my.parser = parser my.p4.SetArgv(args) my.p4.Run(command) return my.data def OutputInfo(my, data, level): "Parse the given data, adding it to my data." for line in string.split(data, '\n'): my.data.append(my.parser(line)) def OutputText(my, text): "Adds text lines to the output data." for line in string.split(text, '\n'): my.data.append(line) def HandleError(my, text, severity = None): "Raise an error from P4." raise P4Client.error(text) OutputError = HandleError class Change: """Class to review a single change, and send email notices to interested parties.""" def __init__(my, text): "Parse the text of the change to create my basic data." my.dict = ChangeParser(text) # Since we're sending email, we have a collection of methods for # setting parts of the email. def To(my): """Returns the list of email addresses to send the mail to. As a side effect, sets instance variables with the same list (recipients), and the list to put on the To: line (with_names)""" recipients, with_names = [], [] for reviewer in my.Reviewers(): if my.dict['author'] != reviewer['user'] or \ not config.send_to_author: recipients.append(reviewer['email']) with_names.append('%(fullname)s < %(email)s >' % reviewer) if config.bcc_admin: recipients.append(config.administrator) my.recipients = recipients my.with_names = with_names return recipients def From(my): "Return a from address for this change" return "%(fullname)s <%(email)s>" % my.dict def Subject(my): "Return a subject line for the email for this change." return "PERFORCE change %s for review" % my.dict['number'] def Body(my): "Return the body of the email for this change." return string.join(my.handler.Run(Ident, "describe", "-s", my.dict['number']), '\n') # The method the outside world uses to make things happen def Run(my, handler, mailer, limit_emails = None): "Build and send the email for this change." my.handler = handler my.mailer = mailer if not my.To(): return message = "From: %s\nSender: %s\nTo: %s\nSubject: %s\n%s\n%s\n" % \ (my.From(), config.administrator, "%s" % string.join(my.with_names, ", "), my.Subject() , config.reply_to , my.Body()) # Need to actually send email.. my.SendMail(config.administrator, my.recipients, message) if limit_emails: limit_emails = limit_emails - 1 if not limit_emails: Complain(mailer, 'Email limit exceeded in review daemon') # Something for the outside world to get information about the change def __getitem__(my, key): "Returns the value of key in my dictionary." return my.dict[key] # and a couple of utility routines def Reviewers(my): "Returns a list of reviewers for this change." return my.handler.Run(User, 'reviews', '-c', my.dict['number']) def SendMail(my, sender, tolist, message): "Send the message, checking for errors." result = my.mailer.sendmail(sender, tolist, message) if result: out = [] for key, value in result.items(): out.append("To: %s, Result: %s" % (key, value)) Complain(mailer, "Mail to\n%s\n\nFailed message --\n%s\n" % (string.join(out, '\n'), message)) class ChangeReviewer: "Class to review Perforce changes and deal with them as needed." def __init__(my, handler, mailer): "Save my handler for later use" my.handler = handler my.mailer = mailer def Run(my): "Run the review process for all changes this object handles." change = None for change in my.Changes(): change.Run(my.handler, my.mailer, config.limit_emails) if change: my.Update(change) def Changes(my): "Get my list of changes we haven't reviewed yet." return my.handler.Run(Change, "review", "-t", "review") def Update(my, change): "Update this review counter to include the things we've reviewed." my.handler.Run(Ident, 'counter', 'review', change['number']) class Job(Change): "Review a single job, and email interested parties about it." def __init__(my, text): "Parse the text to produce the basic job dictionary." my.dict = JobParser(text) def From(my): """Set the From line for this job's author. While we're at it, add the author info to the job dictionary.""" my.dict.update(my.handler.Run(User, 'users', my.dict['author'])[0]) return "%(fullname)s <%(email)s>" % my.dict def Subject(my): "Generate my subject line for this job." return 'Subject: PERFORCE job %s for review' % my.dict['jobname'] def Body(my): "Generate the body for the email for this job." out = [] for line in my.handler.Run(Ident, 'job', '-o', my.dict['jobname']): if line and line[0] != '#': out.append(line) return string.join(out, '\n') reviewers = None def Reviewers(my): "Build the list of job reviewers for this change." if not my.reviewers: my.reviewers = my.handler.Run(User, 'reviews', config.jobpath) return my.reviewers class JobReviewer(ChangeReviewer): "A class for reviewing jobs." def Changes(my): "Get my list of jobs (aka changes)" start_time = my.handler.Run(string.atoi, 'counter', 'jobreview')[0] my.query_time = int(time.time()) query = '%s > %s & %s <= %s' % ( config.datefield , time.strftime('%Y/%m/%d:%H:%M:%S',time.localtime(start_time)) , config.datefield , time.strftime('%Y/%m/%d:%H:%M:%S',time.localtime(my.query_time))) return my.handler.Run(Job, 'jobs', '-e', query) def Update(my, change): "Update the job review counter." my.handler.Run(Ident, 'counter', 'jobreview', repr(my.query_time)) def Run(reviewers, mailer): "Do one review run, running all the reviewers passed in." handler = P4Handler(Port = config.port, User = config.user) for reviewer in reviewers: reviewer(handler, mailer).Run() handler.Final() def Complain(mailer, message = None): "Log an error, either to admin, or to syslog" if not config.repeat: # Not running as a daemon, so just print the message, otherwise # reraise the exception if message: sys.stderr.write(message + '\n') else: raise return # We are running as a daemon; mail/log the message or traceback if not message: file = cStringIO.StringIO() traceback.print_exc(file = file) message = file.getvalue() msg = "To: %s\nFrom: %s\nSubject: P4 Review error\n\n%s\n" % \ (config.administrator, config.administrator, message) if config.administrator: try: mailer.sendmail(config.administrator, [config.administrator], msg) return except: pass syslog.syslog(syslog.LOG_ERR, message) if __name__ == '__main__': reviewers = (ChangeReviewer, JobReviewer) if not config.repeat: Run(reviewers, smtplib.SMTP(config.mailhost)) else: syslog.openlog("P4Review", syslog.LOG_CONS, config.facility) while 1: try: mailer = smtplib.SMTP(config.mailhost) except: syslog.syslog(syslog.LOG_CRIT, "Could not connect to SMTP " \ "server " + config.mailhost) else: try: Run(reviewers, mailer) except: Complain(mailer) mailer.close() time.sleep(config.sleeptime)