#!/usr/bin/python
#
# Perforce review daemon
#
# This script determines new changelists and jobs and emails interested users.
# Users express interest in reviewing changes and/or jobs by setting the
# "Reviews:" entry on their user form.  Users are notified of changes if they
# review any file involved in that change.  Users are notified of jobs if
# they review //depot/jobs (configurable - see jobpath, below).
# NOTE: the job review function requires a 98.2 or later server.
#
# If run directly with "repeat=1" (see below) the script will sleep for
# "sleeptime" seconds and then run again.  On UNIX you can run the script from
# cron by setting "repeat=0" and adding the following line with crontab -e:
# * * * * * /path/to/p4review.py
# This will run the script every minute.  Note that if you use cron you
# should be sure that the script will complete within the time allotted.
#
# See the CONFIGURATION VARIABLES section below for other values which may
# need to be customized for your site.  In particular, be sure to set
# administrator, mailhost, repeat, p4, P4PORT and P4USER.
#
#
# Common pitfalls and debugging tips
# - "command not found" (Windows) or "name: not found" (UNIX) errors
#   -> check that "p4" is on your PATH or set
#      p4='"c:/program files/perforce/p4"' or as appropriate (Windows)
#         (NOTE the use of " inside the string to prevent interpretation
#         of the command as "run c:/program with arguments files/perforce/p4...)
#      p4='/usr/local/bin/p4' or as appropriate (UNIX)
# - "You don't have permission for this operation"
#   -> check that the user you set os.environ['P4USER'] to (see below)
#      has "review" or "super" permission (use "p4 protect")
#      You should be able to run "p4 -u username review -c 42 -t test"
#      - this sets the value of a counter named "test" to 42
# - "Unable to connect to SMTP host"
#   -> check that the mailhost is set correctly - try "telnet mailhost 25"
#      and see if you connect to an SMTP server.  Type "quit" to exit
# - it seems to run but you don't get email
#   -> check the output of "p4 counters" - you should see a counter named
#      "review"
#   -> check the output of "p4 reviews -c changenum" for a recent change;
#      if no one is reviewing the change then no email will be sent.
#      Check the setting of "Reviews:" on the user form with "p4 user"
#   -> check that your email address is set correctly on the user form
#      (run "p4 reviews" to see email addresses for all reviewers,
#       run "p4 user" to set email address)
# - multiple job notifications are sent
#   -> the script should be run on the same machine as the Perforce server;
#      otherwise time differences between the two machines can cause problems
#      (the job review mechanism uses a timestamp; the change review mechanism
#       uses change numbers, so it's not affected by this problem)


import sys, os, string, re, time, smtplib

# CONFIGURATION VARIABLES
notify_changes = 1     # set to 0 to disable change notification completely
notify_jobs    = 0     # 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 = 1     # 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  = None  # If you set this to an email address then you will 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 = ''          # set to hostname of machine running an SMTP server
p4=''                  # set to path of p4 executable, or just 'p4' if the
                       # executable is on your path (use forward slashes even
                       # on Windows! Backslash has a special meaning in Python)
repeat = 0             # set to 0 to run just once (do this if running from
                       # cron!), set to 1 to repeat every sleeptime seconds
sleeptime = 30         # number of seconds to sleep between invocations
                       # (irrelevant if repeat == 0)
limit_emails = 10      # 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).
usep4db = 1            # users of the web based depot viewer (p4db) can
                       # set this variable. Setting the variable to 1 will
                       # place hyperlinks to the change list inside the email
                       # message
p4dburl=''             # where the P4DB web application is running
                       #e.g. http://www.webdomain.com/p4db

jobpath = '//depot/jobs' # send job review mail to users reviewing jobpath
os.environ['P4PORT'] = ''
os.environ['P4USER'] = '' # user must have Perforce review privileges
os.environ['P4PASSWD'] = ''
# END OF CONFIGURATION VARIABLES

bcc_admin = bcc_admin and administrator # don't Bcc: None!
if administrator and reply_to_admin:
  replyto_line='Reply-To: '+administrator+'\n'
else:
  replyto_line=''


def complain(mailport,complaint):
  '''Send a plaintive message to the human looking after this script if we
  have any difficulties.  If no email address for such a human is given,
  send the complaint to stderr.
  '''
  complaint = complaint + '\n'
  if administrator:
    mailport.sendmail('From: p4review@ensoniq.com\n',[administrator],\
      'Subject: Perforce Review Daemon Problem\n\n' + complaint)
  else:
    sys.stderr.write(complaint)
    
    
def mailit(mailport, sender, recipients, message):
  '''Try to mail message from sender to list of recipients using SMTP object
  mailport.  complain() if there are any problems.
  '''
  try:
    failed = mailport.sendmail(sender, recipients, message)
  except:
    failed = 'Exception ' + repr(sys.exc_info()[0]) + ' raised.'
    
  if failed:
    complain( mailport, 'The following errors\n' +\
               repr(failed) +\
              '\noccurred while trying to email from\n' + repr(sender) + '\nto ' +\
               repr(recipients) + '\nwith body\n\n' + message)


def parse_p4_review(command,ignore_author=None):
  reviewers_email = []
  reviewers_email_and_fullname = []

  for line in os.popen(command,'r').readlines():
    # sample line: james <james@perforce.com> (James Strickland)
    #              user   email                fullname
    (user,email,fullname) = \
      re.match( r'^(\S+) <(\S+)> \(([^\)]+)\)', line).groups()

    if user != ignore_author:
      reviewers_email.append(email)
      reviewers_email_and_fullname.append(fullname + ' <' + email + '>')

  return reviewers_email,reviewers_email_and_fullname


def change_reviewers(change,ignore_author=None):
  """For a given change number (given as a string!), return list of
  reviewers email addresses, plus a list of email addresses + full names.
  If ignore_author is given then the given user will not be included
  in the lists. """
  return parse_p4_review(p4 + ' reviews -c ' + change,ignore_author)


def review_changes(mailport,limit_emails=100):
  '''For each change which has not been reviewed yet send email to users
  interested in reviewing the change.  Update the "review" counter to
  reflect the last change reviewed.  Note that the number of emails sent
  is limited by the variable "limit_emails"
  '''
  change = None

  for line in os.popen(p4 + ' review -t review','r').readlines():
    # sample line: Change 119424 james <james@perforce.com> (James Strickland)
    #                     change author email                fullname
    (change,author,email,fullname) = \
                                   re.match( r'^Change (\d+) (\S+) <(\S+)> \(([^\)]+)\)', line).groups()
    
    if send_to_author:
      (recipients,recipients_with_fullnames) = change_reviewers(change)
    else:
      (recipients,recipients_with_fullnames) = change_reviewers(change,author)
      
    if bcc_admin: recipients.append(administrator)

    if not recipients: continue  # no one is interested

    message_hdr = 'From: ' + fullname + ' <' + email + '>\n' +\
                  'To: ' + string.join(recipients_with_fullnames,', ') + '\n' +\
                  'Subject: PERFORCE change ' + change + ' for review\n' +\
                  replyto_line + '\n'
    
    #read in the change list
    temp  =  os.popen(p4 + ' describe -s ' + change,'r').read()

    #if the user specified P4DB, add a hyperlink to the
    #perforce database
    if usep4db==1:
      href_line = "P4DB reference: %s/chv.cgi?CH=%s \n"%(p4dburl,change)
      message = message_hdr+ href_line + temp
    else:
      message = message_hdr + temp
      
    mailit(mailport, email, recipients, message)
    limit_emails = limit_emails - 1
    if limit_emails <= 0:
      complain( mailport, 'email limit exceeded in job review - extra jobs dropped!')
      break

  # if there were change(s) reviewed in the above loop, update the counter
  if change:
    # NOTE: the use of "p4 review -c" is for backwards compatibility with
    # pre-99.1 servers; with 99.1 or later servers use "p4 counter"
    if os.system(p4 + ' review -c ' + change + ' -t review') !=0:
      complain(mailport,'Unable to set review counter - check user "' +\
        os.environ['P4USER'] + '" has review privileges\n(use p4 protect)')


def job_reviewers(jobname,ignore_author=None):
  '''For a given job, return list of reviewers' email addresses,
  plus a list of email addresses + full names.
  If ignore_author is given then the given user will not be included
  in the lists.
  '''
  return parse_p4_review(p4 + ' reviews ' + jobpath,ignore_author) # not the most efficient solution...


def review_jobs(mailport,limit_emails=100):
  '''For each job which hasn't been reviewed yet send email to users
  interested in reviewing the job.  Update the "jobreview" counter to
  reflect the last time this function was evaluated.  Note that the number
  of emails sent is limited by the variable "limit_emails" - ***currently
  this causes extra job notifications to be dropped...not optimal...
  '''
  start_time = 0
  for line in os.popen(p4 + ' counters').readlines():
    if line[:len('jobreview')] == 'jobreview':
      start_time = int(line[len('jobreview')+3:])
  query_time = int(time.time())
  query = datefield + '>' +\
          time.strftime('%Y/%m/%d:%H:%M:%S',time.localtime(start_time)) + '&' +\
          datefield + '<=' +\
          time.strftime('%Y/%m/%d:%H:%M:%S',time.localtime(query_time))

  for line in os.popen(p4 + ' jobs -e "' + query + '"','r').readlines():
    # sample line: job000001 on 1998/08/10 by james *closed* 'comment'
    #              jobname      date          author
    (jobname,author) = re.match( r'^(\S+) on \S+ by (\S+)', line).groups()
    (email,fullname) = re.match( r'^\S+ <(\S+)> \(([^\)]+)\)', \
                  os.popen(p4 + ' users ' + author,'r').read() ).groups()

    if send_to_author:
      (recipients,recipients_with_fullnames) = job_reviewers(jobname)
    else:
      (recipients,recipients_with_fullnames) = job_reviewers(jobname,author)

    if bcc_admin: recipients.append(administrator)

    if not recipients: continue  # no one is interested

    message = 'From: ' + fullname + ' <' + email + '>\n' +\
              'To: ' + string.join(recipients_with_fullnames,', ') + '\n' +\
              'Subject: PERFORCE job ' + jobname + ' for review\n' +\
              replyto_line +\
              '\n'
    for line in os.popen(p4 + ' job -o ' + jobname,'r').readlines():
      if line[0] != '#': message = message + line

    mailit(mailport, email, recipients, message)
    limit_emails = limit_emails - 1
    if limit_emails <= 0: break

  # NOTE: the use of "p4 review -c" is for backwards compatibility with
  # pre-99.1 servers; with 99.1 or later servers use "p4 counter"
  if os.system(p4 + ' review -c ' + repr(query_time) + ' -t jobreview') !=0:
    complain(mailport,'Unable to set jobreview counter - check user "' +\
      os.environ['P4USER'] + '" has review privileges\n(use p4 protect)')


def loop_body(mailhost):
  # Note: there's a try: wrapped around everything so that the program won't
  # halt.  Unfortunately, as a result you don't get the full traceback.
  # If you're debugging this script, strip off the special exception handlers
  # to get the real traceback, or try figuring out how to get a real traceback,
  # by importing the traceback module and defining a file object that
  # will take the output of traceback.print_exc(file=mailfileobject)
  # and mail it (see the example in cgi.py)
  try:
    mailport=smtplib.SMTP(mailhost)
  except:
    sys.stderr.write('Unable to connect to SMTP host "' + mailhost + '"!\n' +\
                     'Will try again in ' + repr(sleeptime) + ' seconds.\n')
  else:
    try:
      if notify_changes: review_changes(mailport,limit_emails)
      if notify_jobs: review_jobs(mailport,limit_emails)
    except:
      complain(mailport,'Exception ' + repr(sys.exc_info()[0]) + ' raised.')
    try:
      mailport.quit()
    except:
      sys.stderr.write('Error while doing SMTP quit command (ignore).\n')

if __name__ == '__main__':
  while(repeat):
    loop_body(mailhost)
    time.sleep(sleeptime)
  else:
    loop_body(mailhost)