# Perforce Defect Tracking Integration Project # # # REPLICATOR.PY -- P4DTI REPLICATOR # # Gareth Rees, Ravenbrook Limited, 2000-08-09 # # # 1. INTRODUCTION # # This Python module implements the P4DTI replicator: the component of # the P4DTI that copies data from the defect tracker to Perforce and # vice versa [RB 2000-08-10], in order to keep the defect tracker state # consistent with the Perforce state [Requirements, 1] and to provide # the ability to ask questions involving both the defect tracking system # and Perforce [Requirements, 5]. # # The replicator is independent of any particular defect tracker: it # interacts with the defect tracker through the abstract interfaces # declared in the dt_interface module and documented in [GDR 2000-10-16, # 7]. This is to make it possible to integrate Perforce with new defect # tracking systems [Requirements, 20, 21] and to simplify the design of # the replicator so that it is modifiable [Requirements, 25], stable # [Requirements, 27] and maintainable [Requirements, 30]. # # See [GDR 2000-09-13] for the design of the replicator, its algorithms, # and the specification of the data it stores in Perforce. # # The intended readership of this document is project developers. # # This document is not confidential. import catalog import dt_interface import message import p4i import re import smtplib import string import sys import time import stacktrace import types import p4dti_exceptions # 2. CURSOR WRAPPER FOR LISTS # # list_cursor is a class that wraps up a list as a cursor with a # fetchone() method. # # This class is used because the specification of the 'all_issues' and # 'changed_entities' methods in the defect_tracker class has changed # since P4DTI 1.1.1 was released. In release 1.1.1 they were documented # to return lists of issues. Now they are documented to return cursors. # We still want to support people who wrote code that returned lists, so # this module examines the results of these methods and wraps lists with # this class. class list_cursor: def __init__(self, list): self.list = list def fetchone(self): if self.list: result = self.list[0] self.list = self.list[1:] return result else: return None # 3. DEFECT TRACKER INTERFACE TO PERFORCE # # The replicator attempts to be as symmetric as possible, for simplicity # of design. It treats Perforce as much as possible in the same way as # the defect tracker. # # It should be possible to develop a dt_perforce module that implements # a full defect tracker interface to Perforce. However, there will need # to be some changes to the abstract interface [GDR 2000-10-16, 7] # because the situation is not 100% symmetric (for example, changelists # are only replicated in one direction). # # We haven't had the time to develop the revised interface and the # implementation, so for the moment the dt_perforce class is a # placeholder. Eventually it will be fully functional and take over all # Perforce operations from the replicator, which can then be simplified # and made fully symmetric. class dt_perforce(dt_interface.defect_tracker): error = "Perforce interface error" p4 = None def __init__(self, p4_interface, config): assert isinstance(p4_interface, p4i.p4) self.p4 = p4_interface # 4. REPLICATOR class replicator: # 4.1. Data # Configuration module. config = None # Defect tracker. dt = None # Defect tracker interface to Perforce. This is a placeholder; at # the moment the implementation is incomplete, but eventually it # will be there. dt_p4 = None # Replicator identifier. rid = None # Interface to Perforce. p4 = None # The number of columns to format e-mail messages to. columns = 80 # The replicator's counter on the Perforce server. counter = None # Error object for fatal errors raised by the replicator. error = 'P4DTI Replicator error' # Map from feature name to whether the defect tracker supports it. # See [GDR 2000-10-16, 3.5] for names of features. feature = {} # 4.2. Initialization def __init__(self, dt, p4_interface, config): assert isinstance(dt, dt_interface.defect_tracker) assert isinstance(p4_interface, p4i.p4) self.job_updates = {} self.dt = dt self.config = config self.rid = config.rid # Set the log failure hook to send a message to the P4DTI # administrator. def log_failed_hook(err, context, r=self): # Log the failure anyway; you never know if some other # logger may be able to handle it. r.config.logger.log(context) # "Error in P4DTI logger: %s" r.mail_report(catalog.msg(913, err), [context]) self.config.logger.set_log_failed_hook(log_failed_hook) # Provide hook for emailing self.config.mail_report = self.mail_report # This is incomplete. Eventually there will be a real defect # tracker interface to Perforce. self.dt_p4 = dt_perforce(p4_interface, config) self.p4 = p4_interface # Replicator ids must match. if self.rid != self.dt.rid: # "The replicator's RID ('%s') doesn't match the defect # tracker's RID ('%s')." raise self.error, catalog.msg(833, (self.rid, self.dt.rid)) # Make a counter name for this replicator. if not self.counter: self.counter = 'P4DTI-%s' % self.rid # Make a client for the replicator. self.create_client() # Initialize the defect tracking system. self.dt.init() self.determine_supported_features() # create_client(). Creates a client if one does not exist, or if # the existing client is broken. def create_client(self): client = self.p4.run('client -o')[0] try: self.p4.run('client -i', client) except p4i.error, message: # "Can't use Perforce client %s." self.log(927, self.p4.client) self.log(message) # "Attempting to make working Perforce client %s." self.log(928, self.p4.client) # Strip out all non-essential entries in the client record. for k in client.keys(): if k[0:4] == 'View' or k in ['LineEnd', 'Options', 'Host', 'code']: del client[k] client['Description'] = 'Client created and used by P4DTI replicator.' self.p4.run('client -i', client) # determine_supported_features(). Determine which optional features # are supported by the defect tracker interface. def determine_supported_features(self): self.feature = {} if hasattr(self.dt, 'supports'): # Query the defect tracker's supports() method if available. for feature in ['filespecs', 'fixes', 'migrate_issues', 'new_issues', 'new_users']: self.feature[feature] = self.dt.supports(feature) else: # Otherwise check to see if the defect tracker and the # configuration offer the required interface, as promised in # [GDR 2000-10-16, 13.4]. self.feature['filespecs'] = 1 self.feature['fixes'] = 1 self.feature['migrate_issues'] = 1 for m in ['new_issue', 'new_issues_start', 'new_issues_end']: if not hasattr(self.dt, m): self.feature['migrate_issues'] = 0 if not hasattr(self.config, 'prepare_issue_advanced'): self.feature['migrate_issues'] = 0 self.feature['new_issues'] = self.feature['migrate_issues'] if not hasattr(self.config, 'translate_jobspec_advanced'): self.feature['migrate_issues'] = 0 self.feature['new_users'] = hasattr(self.dt, 'add_user') # check_first_time(). Take a look at the old jobspec. If it has no # P4DTI fields, then we assume that this is the first time the P4DTI # has been run. If so, check for the existence of jobs; if there # are any, don't go ahead with the change to the jobspec but instead # warn the administrator. See job000219. def check_first_time(self): if (self.config.jobspec and not self.p4.jobspec_has_p4dti_fields( self.p4.get_jobspec(), warn = 0) and self.p4.run('jobs')): # "You must delete your Perforce jobs before running the # P4DTI for the first time. See section 5.2.3 of the # Administrator's Guide." raise self.error, catalog.msg(914) # update_and_check_jobspec(). If keep_jobspec is set, check the # current jobspec against the one we want to install. Otherwise, # just go ahead and install the jobspec. Advanced configurations # can turn this off by clearing config.jobspec [IG, 8.6]. # # Subsequently, the installed jobspec is checked to ensure that we #can run the P4DTI with it. def update_and_check_jobspec(self): if self.config.jobspec and not self.config.keep_jobspec: self.p4.install_jobspec(self.config.jobspec) self.check_jobspec() def check_jobspec(self): self.p4.check_jobspec(self.config.jobspec) def extend_jobspec(self, force=0): self.p4.extend_jobspec(self.config.jobspec, force) # start_logger(). Has the logger been started? If not, start it. # (We must be careful not to set the logger counter to 0 more than # once; this will confuse Perforce [Seiwald 2000-09-11].) def start_logger(self): counters = self.p4.run('counters') for c in counters: if c.get('counter') == 'logger': return self.p4.run('counter logger 0') # 4.3. Logging # log(msg, args = ()). Write the message to the replicator's log. # The msg argument can be a message instance, the number of a # message in the catalog (with the remaining arguments used to fill # in the message parameters), or a string. def log(self, msg, args = ()): if isinstance(msg, message.message): self.config.logger.log(msg) elif isinstance(msg, types.IntType): self.config.logger.log(catalog.msg(msg, args)) else: # "%s" self.config.logger.log(catalog.msg(910, str(msg))) # 4.4. Perforce interface # # These methods provide the replicator with an interface to # Perforce. In time they can be moved to the dt_perforce class # (section 3). # all_jobs(). Return a list of all jobs. def all_jobs(self): return self.p4.run('jobs') # changed_entities(). Return a 3-tuple consisting of (a) changed # jobs, (b) changed changelists, and (c) the last log entry that # was considered. The changed jobs are those that are due for # replication by this replicator (that is, the P4DTI-rid field of # the job matches the replicator id), or new jobs which pass the # replicate_job_p check. The last log entry will be passed to # mark_changes_done. def changed_entities(self): # Get all entries from the log since the last time we updated # the counter. log_entries = self.p4.run('logger -t %s' % self.counter) jobs = {} changelists = [] last_log_entry = None # The last entry number in the log. for e in log_entries: last_log_entry = int(e['sequence']) if e['key'] == 'job': jobname = e['attr'] # Can we account for this log entry on the basis of # updates we made in the previous poll? If so, ignore # the entry. if self.job_updates.get(jobname): n_updates = self.job_updates[jobname] self.job_updates[jobname] = n_updates - 1 elif jobname == 'new': # "Perforce has a job called 'new', which is # illegal and will stop the P4DTI from working." raise self.error, catalog.msg(896) elif not jobs.has_key(jobname): job = self.job(jobname) p4dti_rid = job.get('P4DTI-rid', 'None') if (p4dti_rid == self.rid or (p4dti_rid == 'None' and self.config.replicate_job_p(job))): jobs[jobname] = job elif e['key'] == 'change': # Collect new and updated changelists here. A # changelist can change (using p4 change -f) without any # related jobs changing, so we need to replicate # changelists as well as replicating the fixes of # changed jobs. change_number = e['attr'] try: changelist = self.p4.run('change -o %s' % change_number)[0] changelists.append(changelist) except p4i.error: # The changelist might not exist any more: it might # have been a pending changelist that's been # renumbered. So don't replicate it. Should it be # deleted from the defect tracker? GDR 2000-11-02. pass self.job_updates = {} return jobs, changelists, last_log_entry # mark_changes_done(log_entry). Update the Perforce database to # record the fact that the replicator has replicated all changes up # to log_entry. def mark_changes_done(self, log_entry): assert log_entry == None or isinstance(log_entry, types.IntType) # Update counter to last entry number in the log that we've # replicated. If this is the last entry in the log, it has the # side-effect of deleting the log (see "p4 help undoc"). if log_entry: self.p4.run('logger -t %s -c %d' % (self.counter, log_entry)) # clear_logger(). Clear the logger. def clear_logger(self): last_log_entry = self.p4.run('counter logger')[0] self.p4.run('logger -t %s -c %s' % (self.counter, last_log_entry)) # job(jobname). Return the Perforce job with the given name if it # exists, or an empty job specification (otherwise). def job(self, jobname): assert isinstance(jobname, types.StringType) jobs = self.p4.run('job -o %s' % jobname) if len(jobs) != 1 or not jobs[0].has_key('Job'): # "Expected a job but found %s." raise self.error, catalog.msg(837, str(jobs)) # Compare job names case-insensitively (see job000313). elif string.lower(jobs[0]['Job']) != string.lower(jobname): # "Asked for job '%s' but got job '%s'." raise self.error, catalog.msg(838, (jobname, jobs[0]['Job'])) else: return jobs[0] # job_filespecs(job). Return a list of filespecs for the given job. # Each element of the list is a filespec, as a string. def job_filespecs(self, job): assert isinstance(job, types.DictType) # if no P4DTI-filespecs field, do the right thing: job_filespecs = job.get('P4DTI-filespecs', '') filespecs = string.split(job_filespecs, '\n') # Since Perforce text fields are terminated with a newline, the # last item of the list must be an empty string. Remove it. if filespecs[-1] != '': # "P4DTI-filespecs field has value '%s': this should end # in a newline." raise self.error, catalog.msg(839, job_filespecs) return filespecs[:-1] # job_fixes(job). Return a list of fixes for the given job. Each # element of the list is a dictionary with keys Change, Client, # User, Job, and Status. def job_fixes(self, job): assert isinstance(job, types.DictType) return self.p4.run('fixes -j %s' % job['Job']) # job_format(job). Format a job so that people can read it. Also, # indent the first line of the job so that it can be included in the # body of a mail message without being wrapped; see mail(). def job_format(self, job): def format_item(i): key, value = i if '\n' in value: if value[-1] == '\n': value = value[0:-1] value = string.join(string.split(value,'\n'),'\n\t') return "%s:\n\t%s" % (key, value) else: return "%s: %s" % (key, value) items = job.items() # Remove special Perforce system fields. items = filter(lambda i: i[0] not in ['code','specdef'], items) # Sort into lexical order. items.sort() return string.join(map(format_item, items), '\n') # job_mail_recipients(job). Work out the people associated with the # job who should receive e-mail when there's a problem with that job # (namely the job's owner and the last person to edit the job, # unless either of these is the replicator). def job_mail_recipients(self, job): recipients = [] # Owner of the job, if any. owner = job.get(self.config.job_owner_field, None) if owner: owner_address = self.user_email_address(owner) if owner_address: # "Job owner" comment = catalog.msg(925) recipients.append((comment, owner_address)) # Last person to change the job, if neither replicator nor # owner. changer = job.get('P4DTI-user', None) if changer and changer not in [self.config.p4_user, owner]: changer_address = self.user_email_address(changer) if changer_address: # "Job changer" comment = catalog.msg(926) recipients.append((comment, changer_address)) return recipients # job_modifier(job). Return our best guess at who last modified the # job. # # If the Perforce server supports the 'fix_update' feature, then # this is easy: just return the P4DTI-user field (for safety in the # case of race conditions -- user changes interleaved with P4DTI # changes -- we substitute the job owner if P4DTI-user is the # replicator). # # However, in Perforce servers that don't support this feature, the # "always" fields in a job don't get modified when a job is fixed. # This means that the P4DTI-user field may not be accurate, since # there may have been fixes added later. # # So our strategy for finding the owner is as follows: # # 1. Is there a fix record, submitted more recently than the job # has been modified, by someone other than the replicator? If so, # take the person who submitted the most recent such fix as the # modifier. # # 2. If not, does the P4DTI-user field contain a user other than # the replicator? If so, take them as the modifier. # # 3. If not, take the job owner as the modifier. # # Note that this doesn't give a 100% accurate answer (for example, # if you fix a job and then delete the fix), but it's right in all # but a few exceptional cases. # # See job000133 and job000270 for the motivation. def job_modifier(self, job): modifier = job.get('P4DTI-user', self.config.p4_user) if modifier == self.config.p4_user: modifier = job.get(self.config.job_owner_field, modifier) # Perforce 2002.1 updates 'always' fields when someone modifies # a job by making a fix. So the modifier is accurate. if self.p4.supports('fix_update'): return modifier # Dates in job fields look like 2000/12/31 23:59:59, but dates # in fixes are seconds since 1970-01-01 00:00:00, so convert the # job modification time to an integer for comparison. match = re.match('^(\d{4})/(\d{2})/(\d{2}) ' '(\d{2}):(\d{2}):(\d{2})$', job.get(self.config.job_date_field, '1970/01/01 00:00:00')) if not match: # "Job '%s' has a date field in the wrong format: %s." raise self.error, catalog.msg(889, (job['Job'], job)) date = time.mktime(tuple(map(int, match.groups()) + [0,0,-1])) fixes = self.job_fixes(job) for f in fixes: if (int(f['Date']) > date and f['User'] != self.config.p4_user): modifier = f['User'] date = int(f['Date']) return modifier # Map from Perforce job name to the number of times we've updated # the job in this poll. Used by changed_entities and updated by # update_job and replicate_fixes_dt_to_p4. job_updates = {} def record_job_update(self, job): jobname = job['Job'] self.job_updates[jobname] = self.job_updates.get(jobname, 0) + 1 # update_job(job, changes). Update the job in Perforce by applying # the given changes. Also update the "job" dictionary to reflect # these changes, and also any changes made by Perforce, such as # picking up the new jobname (if job['Job'] is 'new'). update_job_re = re.compile('^Job ([^ ]+) (.*)') def update_job(self, job, changes = {}): assert isinstance(job, types.DictType) assert isinstance(changes, types.DictType) for key, value in changes.items(): job[key] = value # When using P4Python need to ensure we have just done job -o to set # spec data etc. tmpjob = self.job(job['Job']) results = self.p4.run('job -i', job) # Check that the results of the 'job -i' command are as # expected: Perforce should say something like 'Job job012345 # saved.' or 'Job job012345 not changed.' If the jobname was # 'new', then record the jobname that Perforce gave the new # job so that we can call setup_for_replication() in # replicate_many(). if len(results) == 1: match = self.update_job_re.match(results[0]) if not match or match.group(1) == 'new': # "Expected Perforce output of 'job -i' to say 'Job # jobname ...', but found '%s'." raise self.error, catalog.msg(897, results[0]) elif job['Job'] == 'new': job['Job'] = match.group(1) elif job['Job'] != match.group(1): # "Tried to update job '%s', but Perforce replied '%s'." raise self.error, catalog.msg(899, (job['Job'], results[0])) if match.group(2) == 'saved.': self.record_job_update(job) else: # "Unexpected output from Perforce command 'job -i': %s." raise self.error, catalog.msg(898, results) # Return the e-mail address of a Perforce user, or None if the # address can't be found. def user_email_address(self, user): assert isinstance(user, types.StringType) # Even though "p4 user -o foo" doesn't actually create a user, # it does fail if the user foo doesn't exist and there are no # spare licenses. So trap that case. See job000204. try: u = self.p4.run('user -o %s' % user) except: return None # Existing users have Access and Update fields in the returned # structure; non-existing users don't. if (len(u) == 1 and u[0].has_key('Access') and u[0].has_key('Update') and u[0].has_key('Email')): return u[0]['Email'] else: return None # 4.5. Entry points # Check if we can read valid data. # For testing purposes only def check_all_data(self): bugs = self.dt.tracker.debug_all_bugs_since(self.config.start_date) # check_consistency(). Run a consistency check on the two # databases, reporting any inconsistencies. def check_consistency(self, doupdate=None): # "Checking consistency for replicator '%s'." self.log(871, self.rid) self.check_jobspec() n = 0 # Number of inconsistencies found. # Get issues and jobs. issues_cursor = self.dt.all_issues() # Support old all_issues specification [GDR 2000-10-16, 13.1]. if not hasattr(issues_cursor, 'fetchone'): issues_cursor = list_cursor(issues_cursor) issue_id_to_job = {} jobs = {} for j in self.p4.run('jobs -e P4DTI-rid=%s' % self.rid): jobs[j['Job']] = j count = 0 while 1: issue = issues_cursor.fetchone() if issue == None: break # Fix for Tracker - logout and log back in every so often to avoid resource problems count = count + 1 #if count % 100 == 0 and self.config.dt_name == "Tracker": # self.dt.poll_end() # self.dt.poll_start() id = issue.id() jobname = issue.corresponding_id() # "Checking issue '%s' against job '%s'." self.log(890, (id, jobname)) # Report if issue has no corresponding job. if issue.rid() != self.rid: if self.config.replicate_p(issue): # "Issue '%s' should be replicated but is not." self.log(872, id) n = n + 1 continue issue_id_to_job[id] = jobname if not jobs.has_key(jobname): # "Issue '%s' should be replicated to job '%s' but that # job either does not exist or is not replicated." self.log(873, (id, jobname)) n = n + 1 continue # Get corresponding job. job = jobs[jobname] del jobs[jobname] # Report if mapping is in error. job_issue_id = job.get('P4DTI-issue-id', 'None') if job_issue_id != id: # "Issue '%s' is replicated to job '%s' but that job is # replicated to issue '%s'." self.log(874, (id, jobname, job_issue_id)) n = n + 1 # Report if job and issue contents don't match. changes = self.translate_issue_dt_to_p4(issue, job, 1) if changes: # "Job '%s' would need the following set of changes in # order to match issue '%s': %s." self.log(875, (jobname, id, str(changes))) n = n + 1 if doupdate: self.log(826, changes) self.update_job(job, changes) # Report if filespecs don't match. n = n + self.check_filespecs(issue, job) # Report if fixes don't match. n = n + self.check_fixes(issue, job, doupdate) # There should be no remaining jobs, so any left are in error. for job in jobs.values(): job_issue_id = job.get('P4DTI-issue-id', 'None') if issue_id_to_job.has_key(job_issue_id): # "Job '%s' is marked as being replicated to issue '%s' # but that issue is being replicated to job '%s'." self.log(881, (job['Job'], job_issue_id, issue_id_to_job[job_issue_id])) n = n + 1 else: # "Job '%s' is marked as being replicated to issue '%s' # but that issue either doesn't exist or is not being # replicated by this replicator." self.log(882, (job['Job'], job_issue_id)) n = n + 1 # Report on success/failure. if len(issue_id_to_job) == 1: # "Consistency check completed. 1 issue checked." self.log(883) else: # "Consistency check completed. %d issues checked." self.log(884, len(issue_id_to_job)) if n == 0: # "Looks all right to me." self.log(885) elif n == 1: # "1 inconsistency found." self.log(886) else: # "%d inconsistencies found." self.log(887, n) # check_filespecs(issue, job). Report if the sets of filespecs # differ between the issue and the job. Return the number of # inconsistencies found. def check_filespecs(self, issue, job): if not self.feature['filespecs']: return 0 n = 0 # Number of inconsistencies found. issuename = issue.readable_name() jobname = job['Job'] p4_filespecs = self.job_filespecs(job) dt_filespecs = issue.filespecs() diffs = self.filespecs_differences(dt_filespecs, p4_filespecs) for p4_filespec, dt_filespec in diffs: if p4_filespec and not dt_filespec: # "Job '%s' has associated filespec '%s' but there is no # corresponding filespec for issue '%s'." self.log(876, (jobname, p4_filespec, issuename)) n = n + 1 elif not p4_filespec and dt_filespec: # "Issue '%s' has associated filespec '%s' but there is # no corresponding filespec for job '%s'." self.log(877, (issuename, dt_filespec.name(), jobname)) n = n + 1 else: # Corresponding filespecs can't differ (since their only # attribute is their name). assert 0 return n # check_fixes(issue, job). Report if the sets of fixes differ # between the issue and the job. Return the number of # inconsistencies found. def check_fixes(self, issue, job, doupdate=None): if not self.feature['fixes']: return 0 n = 0 # Number of inconsistencies found. issuename = issue.readable_name() jobname = job['Job'] p4_fixes = self.job_fixes(job) dt_fixes = issue.fixes() diffs = self.fixes_differences(dt_fixes, p4_fixes) for p4_fix, dt_fix in diffs: if p4_fix and not dt_fix: # "Change %s fixes job '%s' but there is no # corresponding fix for issue '%s'." self.log(878, (p4_fix['Change'], jobname, issuename)) if doupdate: (change, client, date, status, user) = self.translate_fix_p4_to_dt(p4_fix) try: issue.add_fix(change, client, date, status, user) except: issue.add_fix(change, client, date, status, self.config.tracker_user) # "-- Added fix for change %s with status %s." self.log(820, (p4_fix['Change'], p4_fix['Status'])) n = n + 1 elif not p4_fix and dt_fix: # "Change %d fixes issue '%s' but there is no # corresponding fix for job '%s'." self.log(879, (dt_fix.change(), issuename, jobname)) n = n + 1 else: # "Change %s fixes job '%s' with status '%s', but change # %d fixes issue '%s' with status '%s'." self.log(880, (p4_fix['Change'], jobname, p4_fix['Status'], dt_fix.change(), issuename, dt_fix.status())) n = n + 1 return n # migrate_users() ensures that there is a defect tracker user # corresponding to each Perforce user. def migrate_users(self): if not self.feature['new_users']: # "Defect tracker '%s' does not support migration of # Perforce users." raise self.error, catalog.msg(906, self.config.dt_name) self.dt.add_replicator_user() p4_users = self.p4.run("users") for user in p4_users: self.dt.add_user(user['User'], user['Email'], user['FullName']) # migrate() migrates all existing Perforce jobs to the DT. Note # that we can't just call replicate_new_issue_p4_to_dt here because # that method assumes we have the new jobspec in place (so that we # can replicate backwards) which we don't until migration is # finished. Instead, we replicate backwards in a bunch after # migration succeeds. def migrate(self, starting_with = None): if not self.feature['migrate_issues']: # "Defect tracker '%s' does not support migration of # Perforce jobs." raise self.error, catalog.msg(905, self.config.dt_name) jobs = self.all_jobs() try: self.dt.new_issues_start() for job in jobs: if starting_with and job['Job'] != starting_with: continue else: starting_with = None if job.get('P4DTI-rid', 'None') != 'None': # "Not migrating job '%s' (already replicated)." self.log(916, job['Job']) elif not self.config.migrate_p(job): # "Not migrating job '%s' (migrate_p returned 0)." self.log(917, job['Job']) else: try: # "Before translating jobspec, job '%s' is %s" self.log(915, (job['Job'], job)) job = self.config.translate_jobspec_advanced( self.config, self.dt, self.dt_p4, job) if not isinstance(job, types.DictType): # "Expected translate_jobspec to return a # dictionary, but instead it returned %s." raise self.error, catalog.msg(924, job) # "After translating jobspec, job '%s' is %s" self.log(918, (job['Job'], job)) issue = self.create_issue(job) except: # "Migrating job '%s'..." self.log(921, job['Job']) raise # "Migrated job '%s' to issue '%s'." self.log(892, (job['Job'], issue.readable_name())) # Replicate filespecs, fixes and changelists. self.replicate_filespecs_p4_to_dt(issue, job) self.replicate_fixes_p4_to_dt(issue, job) finally: self.dt.new_issues_end() # "Migration completed." self.log(895) # poll(). Poll the defect tracker and Perforce, replicate changes, # then stop. def poll(self): self.check_first_time() self.update_and_check_jobspec() self.start_logger() self.poll_databases() # refresh_perforce_jobs(). Replicate all issues from the defect # tracker. Note: does not delete jobs first. def refresh_perforce_jobs(self): self.update_and_check_jobspec() self.replicate_all_dt_to_p4() self.start_logger() self.clear_logger() # carefully_poll_databases(). Poll once, handling exceptions def carefully_poll_databases(self): try: self.poll_databases() # Reset poll period when the poll was successful. self.poll_period = self.config.poll_period except AssertionError: # Assertions indicate severe bugs in the replicator. It # might cause serious data corruption if we continue. # We also want these failures to be reported, and they # might go unreported if the replicator carried on # going. raise except KeyboardInterrupt: # Allow people to stop the replicator with Control-C. raise except p4dti_exceptions.RecordLockedError: # "The replicator failed to poll successfully." self.log(catalog.msg(863)) # "The replicator failed to poll successfully, # because of the following problem:" self.log(catalog.msg(864)) # "Defect Tracker Record locked by a user and can't be updated. # Will try again next poll." self.log(catalog.msg(929)) if self.poll_period < self.config.poll_period * 16: self.poll_period = self.poll_period * 2 except p4dti_exceptions.P4ServerDown: # "The replicator failed to poll successfully." self.log(catalog.msg(863)) # "The replicator failed to poll successfully, # because of the following problem:" self.log(catalog.msg(864)) exc_info = sys.exc_info() msg = self.exception_message(exc_info) self.log(msg) self.mail_report( # "The replicator failed to poll successfully." catalog.msg(863), # "The replicator failed to poll successfully, # because of the following problem:" [catalog.msg(864), msg], error=0) if self.poll_period < self.config.poll_period * 16: self.poll_period = self.poll_period * 2 except: self.mail_report( # "The replicator failed to poll successfully." catalog.msg(863), # "The replicator failed to poll successfully, # because of the following problem:" [catalog.msg(864)]) # The poll failed; it's likely that it will fail again # for the same reason the next time we poll. Back off # exponentially so as not to mail bomb the admin. See # job000215 and job000135. self.poll_period = self.poll_period * 2 # prepare_to_run(). Invoked once when run() is called, to preform # startup tasks. def prepare_to_run(self): self.check_first_time() self.update_and_check_jobspec() self.start_logger() self.poll_period = self.config.poll_period self.mail_startup_message() # run(). Repeatedly (handling exceptions) poll and replicate # changes. def run(self): self.prepare_to_run() while 1: self.carefully_poll_databases() time.sleep(self.poll_period) # 4.6. E-mail # mail(recipients, subject, body). Send e-mail to the given # recipients (pls the administrator) with the given subject and # body. The recipients argument is a list of pairs (role, address). # The body argument is a list of paragraphs. Paragraphs belonging # to the message.message class will be wrapped to 80 columns. # Ordinary strings will be left alone. Log the contents of the # message. def mail(self, recipients, subject, body): assert isinstance(recipients, types.ListType) assert isinstance(subject, message.message) assert isinstance(body, types.ListType) # Always e-mail the administrator recipients.append(('P4DTI administrator', self.config.administrator_address)) # Build the contents of the RFC822 To: header. to = string.join(map(lambda r: "%s <%s>" % r, recipients), ', ') # "Mailing '%s'." self.log(800, to) self.log(subject) map(self.log, body) # Don't send e-mail if administrator_address or smtp_server is # None. if (self.config.administrator_address == None or self.config.smtp_server == None): return smtp = smtplib.SMTP(self.config.smtp_server) message_paragraphs = [ ("From: %s\n" "To: %s\n" "Subject: %s" % (self.config.replicator_address, to, subject)), # "This is an automatically generated e-mail from the # Perforce Defect Tracking Integration replicator '%s'." catalog.msg(865, self.rid), ] + body def fmt(s, columns = self.columns): if isinstance(s, message.message): return s.wrap(columns) else: return str(s) message_text = string.join(map(fmt, message_paragraphs), "\n\n") smtp.sendmail(self.config.replicator_address, map(lambda r: r[1], recipients), message_text) smtp.quit() # exception_message(exc_info). Return a message object describing # the given exception, or None if there was no exception. The # exc_info argument must be the results of calling sys.exc_info(). def exception_message(self, exc_info): exc_type, exc_value = exc_info[0:2] if isinstance(exc_value, message.message): return exc_value elif exc_type is not None: # "Error (%s): %s" return catalog.msg(891, (exc_type, exc_value)) else: # We're not in the context of an exception, so there's # nothing to report. return None def stacktrace(self, exc_info): return string.join(apply(stacktrace.format_exception, exc_info), '') # mail_report(subject, intro, extra=[], job=None, error=1). Compose # and send e-mail when something's gone wrong. If a job argument is # supplied, it's the job to which the mail applies, and is used to # deduce who to send the e-mail to. If no job argument is supplied, # then mail is to the administrator (only). Iff error is 1, the # mail includes an error message and traceback. def mail_report(self, subject, intro, extra=[], job=None, error=1): assert isinstance(subject, message.message) assert isinstance(intro, types.ListType) assert isinstance(extra, types.ListType) for m in intro + extra: assert (isinstance(m, types.StringType) or isinstance(m, message.message)) assert job is None or isinstance(job, types.DictType) if error: try: exc_info = sys.exc_info() msg = self.exception_message(exc_info) body = intro + [ msg ] + extra + [ # "Here's a full Python traceback:" catalog.msg(852), self.stacktrace(exc_info), ] finally: # Break circular reference. See [van Rossum 2000-03-22, # 3.1] and rule code/python/compatible. del exc_info else: body = intro + extra if job is None: self.mail([], subject, body) else: # "If you are having continued problems, please contact # your P4DTI administrator <%s>." m = catalog.msg(853, self.config.administrator_address) body.append(m) self.mail(self.job_mail_recipients(job), subject, body) # mail_startup_message(self). Send a message to the administrator # when the replicator starts to run. It exercises the SMTP server, # which is the only way we can really test that part of the # configuration. This is very important, because the replicator may # often be run unattended, so we can't rely on log messages being # read. # # Also this is a good time to tell the administrator about any # unmatched and duplicate user records, as he may wish to take # action to fix them. def mail_startup_message(self): unmatches = self.config.user_translator.unmatched_users( self.dt, self.dt_p4) (unmatched_dt_users, unmatched_p4_users, dt_user_msg, p4_user_msg) = unmatches[0:4] if len(unmatches) >= 8: (duplicate_dt_users, duplicate_p4_users, duplicate_dt_msg, duplicate_p4_msg) = unmatches[4:8] else: duplicate_dt_users = None duplicate_p4_users = None # "The P4DTI replicator has started." subject = catalog.msg(866) body = [ subject ] if unmatched_p4_users: body = body + [ # "The following Perforce users do not correspond to # defect tracker users. The correspondence is based on # the e-mail addresses in the defect tracker and # Perforce user records." catalog.msg(867), p4_user_msg, self.format_email_table(unmatched_p4_users), ] if unmatched_dt_users: body = body + [ # "The following defect tracker users do not correspond # to Perforce users. The correspondence is based on the # e-mail addresses in the defect tracker and Perforce # user records." catalog.msg(870), dt_user_msg, self.format_email_table(unmatched_dt_users), ] if duplicate_p4_users: body = body + [ duplicate_p4_msg, self.format_email_table(duplicate_p4_users), ] if duplicate_dt_users: body = body + [ duplicate_dt_msg, self.format_email_table(duplicate_dt_users), ] self.mail([], subject, body) # format_email_table(self, user_dict). Format a table of users and # e-mail addresses. The users argument is a dictoinary mapping # userid to e-mail address. Return a string containing the table. def format_email_table(self, user_dict): # "User" user_header = catalog.msg(868).text # "E-mail address" email_header = catalog.msg(869).text longest_user = len(user_header) longest_email = len(email_header) users = user_dict.keys() users.sort() for u in users: if len(u) > longest_user: longest_user = len(u) if len(user_dict[u]) > longest_email: longest_email = len(user_dict[u]) spaces = longest_user + 2 - len(user_header) table = [ " %s%s%s" % (user_header, ' ' * spaces, email_header), " " + "-" * (longest_user + 2 + longest_email) ] for u in users: email = user_dict[u] if email == '': email = '' spaces = longest_user + 2 - len(u) table.append(" %s%s%s" % (u, ' ' * spaces, email)) return string.join(table, "\n") # 4.7. Replication # fixes_differences(dt_fixes, p4_fixes). Each argument is a list of # fixes for the same job/issue. Return list of pairs (p4_fix, # dt_fix) of corresponding fixes which differ. Elements of pairs # are None where there is no corresponding fix. def fixes_differences(self, dt_fixes, p4_fixes): assert isinstance(dt_fixes, types.ListType) assert isinstance(p4_fixes, types.ListType) # Make hash from change number to p4 fix. p4_fix_by_change = {} for p4_fix in p4_fixes: assert isinstance(p4_fix, types.DictType) p4_fix_by_change[int(p4_fix['Change'])] = p4_fix # Make pairs (dt fix, corresponding p4 fix or None). pairs = [] for dt_fix in dt_fixes: assert isinstance(dt_fix, dt_interface.defect_tracker_fix) if not p4_fix_by_change.has_key(dt_fix.change()): pairs.append((None, dt_fix)) else: p4_fix = p4_fix_by_change[dt_fix.change()] del p4_fix_by_change[dt_fix.change()] if dt_fix.status() != p4_fix['Status']: pairs.append((p4_fix, dt_fix)) # Remaining p4 fixes are unpaired. for p4_fix in p4_fix_by_change.values(): pairs.append((p4_fix, None)) return pairs # filespecs_differences(dt_filespecs, p4_filespecs). Each argument # is a list of filespecs for the same job/issue. Return list of # pairs (p4_filespec, dt_filespec) of filespecs which differ. # Elements of pairs are None where there is no corresponding # filespec (this is always the case since there is no associated # information with a filespec; the function is like this for # consistency with fixes_differences, and so that it is easy to # extend if there is ever a way to associate information with a # filespec, for example the nature of the association -- see # requirement 55). def filespecs_differences(self, dt_filespecs, p4_filespecs): assert isinstance(dt_filespecs, types.ListType) assert isinstance(p4_filespecs, types.ListType) # Make hash from name to p4 filespec. p4_filespec_by_name = {} for p4_filespec in p4_filespecs: assert isinstance(p4_filespec, types.StringType) p4_filespec_by_name[p4_filespec] = p4_filespec # Make pairs (dt filespec, None). pairs = [] for dt_filespec in dt_filespecs: assert isinstance(dt_filespec, dt_interface.defect_tracker_filespec) if not p4_filespec_by_name.has_key(dt_filespec.name()): pairs.append((None, dt_filespec)) else: del p4_filespec_by_name[dt_filespec.name()] # Make pairs (None, p4 filespec). for p4_filespec in p4_filespec_by_name.values(): pairs.append((p4_filespec, None)) return pairs # conflict_policy(issue, job). This method is called when both the # issue and the corresponding job have changed since the last time # they were consistent. Return 'p4' if the Perforce job is correct # and should be replicated to the defect tracker. Return 'dt' if # the defect tracking issue is correct and should be replicated to # Perforce. Return anything else to indicate that the replicator # should take no further action. # # The default policy is to return 'dt'. This is because we're # treating the Perforce jobs database as a scratch copy of the real # data in the defect tracker. So when there's a conflict the defect # tracker is correct. See job000102 for details. def conflict_policy(self, issue, job): assert isinstance(issue, dt_interface.defect_tracker_issue) assert isinstance(job, types.DictType) return 'none' # poll_databases(). Poll the DTS for changed issues. Poll Perforce # for changed jobs and changelists. Replicate all of these # entities. def poll_databases(self): # "Poll starting." self.log(911) if hasattr(self.dt, 'poll_start'): self.dt.poll_start() self.p4.run('login', self.config.p4_password) try: # Get the changed issues (ignore changed changelists if any # since we only replicate changelists from Perforce to the # defect tracker). changed_issues, _, dt_marker = self.dt.changed_entities() # Support old changed_entities specification [GDR # 2000-10-16, 13.1]. if not hasattr(changed_issues, 'fetchone'): changed_issues = list_cursor(changed_issues) changed_jobs, changelists, p4_marker = self.changed_entities() # Replicate the issues and the jobs. self.replicate_many(changed_issues, changed_jobs) # Replicate the affected changelists. if self.feature['fixes']: for c in changelists: self.replicate_changelist_p4_to_dt(c) # Tell the defect tracker and Perforce that we've finished # replicating these changes. self.dt.mark_changes_done(dt_marker) self.mark_changes_done(p4_marker) finally: if hasattr(self.dt, 'poll_end'): self.dt.poll_end() # "Poll finished." self.log(912) # replicate_all_dt_to_p4(). Go through all the issues in the defect # tracker, set them up for replication if necessary, and replicate # them to Perforce. def replicate_all_dt_to_p4(self, bug_list=[]): if hasattr(self.dt, 'poll_start'): self.dt.poll_start() all_issues_cursor = self.dt.all_issues(bug_list) # Support old all_issues specification [GDR 2000-10-16, 13.1]. if not hasattr(all_issues_cursor, 'fetchone'): all_issues_cursor = list_cursor(all_issues_cursor) while 1: issue = all_issues_cursor.fetchone() if not issue: break if issue.rid(): # only replicate issues which we replicate if issue.rid() != self.rid: continue else: # only start replicating issues which we should replicate if not self.config.replicate_p(issue): continue jobname = self.issue_jobname(issue) job = self.job(jobname) self.replicate(issue, job, 'dt') if hasattr(self.dt, 'poll_end'): self.dt.poll_end() def replicate_changelist_p4_to_dt(self, changelist): assert isinstance(changelist, types.DictType) change = int(changelist['Change']) client = changelist['Client'] date = self.config.date_translator.translate_p4_to_dt( changelist['Date'], self.dt, self.dt_p4) description = self.config.text_translator.translate_p4_to_dt( changelist['Description'], self.dt, self.dt_p4) status = changelist['Status'] user = self.config.user_translator.translate_p4_to_dt( changelist['User'], self.dt, self.dt_p4) if self.dt.replicate_changelist(change, client, date, description, status, user): # "Replicated changelist %d." self.log(802, change) # issue_jobname(issue). Return the name of the job to which the # issue should be replicated. If we've replicated this issue before # (that is, if issue.rid() is not the empty string), then the defect # tracker already knows the jobname. If the administrator has # specified 'use_perforce_jobnames', use 'new'. Otherwise, ask the # defect tracker to suggest a name. def issue_jobname(self, issue): if not issue.rid() and self.config.use_perforce_jobnames: return 'new' else: return issue.corresponding_id() # replicate_many(issues_cursor, jobs). Replicate the issues and # jobs. The issues argument is a list of issues (which must belong # to a subclass of defect_tracker_issue; the jobs list is a hash # from jobname to job). # # The reason why the arguments have different conventions (list vs # hash) is that the algorithm for getting the changed jobs from the # p4 logger outpt involves constructing a hash from jobname to job, # and it seems silly to turn this hash back into a list only to # immediately turn it back into a hash again. def replicate_many(self, issues_cursor, jobs): assert hasattr(issues_cursor, 'fetchone') assert isinstance(jobs, types.DictType) while 1: issue = issues_cursor.fetchone() if issue == None: break assert isinstance(issue, dt_interface.defect_tracker_issue) # Don't replicate issues which fail replicate_p. (But if # the issue is already set up for replication, don't ask # again.) if not issue.rid() and not self.config.replicate_p(issue): continue jobname = self.issue_jobname(issue) if jobs.has_key(jobname): job = jobs[jobname] self.replicate(issue, job, 'both') del jobs[jobname] else: job = self.job(jobname) self.replicate(issue, job, 'dt') # Now go through the remaining changed jobs. for job in jobs.values(): assert isinstance(job, types.DictType) issue_id = job.get('P4DTI-issue-id', 'None') if issue_id != 'None': issue = self.dt.issue(issue_id) if not issue: # "Asked for issue '%s' but got an error instead." raise self.error, catalog.msg(888, issue_id) self.replicate(issue, job, 'p4') else: # Job is new in Perforce, so create new issue in the # defect tracker. self.replicate_new_issue_p4_to_dt(job) # Replicate newly-created job over to defect tracker def replicate_new_issue_p4_to_dt(self, job): if not self.feature['new_issues']: return jobname = job['Job'] try: issue = self.create_issue(job) except: self.mail_report( # "Job '%s' could not be replicated to the defect # tracker." catalog.msg(908, jobname), # "The replicator failed to replicate Perforce job '%s' # to the defect tracker, because of the following # problem:" [catalog.msg(909, jobname)], [], job) return issuename = issue.readable_name() # "Migrated job '%s' to issue '%s'." self.log(892, (jobname, issuename)) if self.config.replicate_p(issue): try: # The result of replicating back may be different from # the original job. job['P4DTI-rid'] = self.rid job['P4DTI-issue-id'] = issue.id() # "Post-migration replication of issue '%s' to job # '%s'." self.log(894, (issue.readable_name(), job['Job'])) changes = self.translate_issue_dt_to_p4(issue, job, 1) if changes: # "-- Defect tracker made changes as a result of # the update: %s." self.log(826, changes) self.update_job(job, changes) self.replicate_filespecs_p4_to_dt(issue, job) self.replicate_fixes_p4_to_dt(issue, job) except: # Undo our half-completed work: delete the issue and # revert the job. issue.delete() self.update_job(job, { 'P4DTI-rid': 'None', 'P4DTI-issue-id': 'None' }) self.mail_report( # "Job '%s' could not be replicated to issue '%s'." catalog.msg(848, (jobname, issuename)), # "The replicator failed to replicate Perforce job # '%s' to defect tracker issue '%s', because of the # following problem:" [catalog.msg(851, (jobname, issuename))], [], job) # replicate(issue, job, changed). Replicate an issue to or from the # corresponding job. The changed argument is 'dt' if the defect # tracking issue has changed but not the Perforce job; 'p4' if vice # versa; 'both' if both have changed. # # Basically this method is a series of conditions that end in one of # the following cases: # # 1. Replicate the issue to the job or vice versa (the normal mode # of operation). # # 2. Overwrite the job with the issue or vice versa (if they have # both changed and the conflict policy says to overwrite). This is # just like replication, except that the old version of the # overwritten entity gets mailed to its owner as a record in case # data was lost. # # 3. Do nothing (if both have changed and the conflict policy says # to do nothing). # # 4. Revert the job from the issue (if we tried to replicate the # job to the issue but it failed, probably due to lack of privileges # or invalid data). def replicate(self, issue, job, changed): assert isinstance(issue, dt_interface.defect_tracker_issue) assert isinstance(job, types.DictType) assert changed in ['dt','p4','both'] issuename = issue.readable_name() jobname = job['Job'] # Figure out what to do with this issue and job. Do nothing? # Overwrite issue with job? Overwrite job with issue? # Only the defect tracker issue has changed. if changed == 'dt': # "Replicating issue '%s' to job '%s'." self.log(804, (issuename, jobname)) self.replicate_issue_dt_to_p4(issue, job) if not issue.rid(): # If we started out with jobname == 'new', then by # this time it must have been set to the new jobname # by the update_job() method. if job['Job'] == 'new': # "Replicated issue '%s' to Perforce, but didn't # get a jobname for it (the 'Job' field is still # 'new')." raise self.error, catalog.msg(904, (issue.id())) issue.setup_for_replication(job['Job']) # "Set up issue '%s' to replicate to job '%s'." self.log(803, (issue.id(), job['Job'])) # Only the Perforce job has changed. elif changed == 'p4': # "Replicating job '%s' to issue '%s'." self.log(805, (jobname, issuename)) try: self.replicate_issue_p4_to_dt(issue, job) except p4dti_exceptions.RecordLockedError: raise except: self.revert_issue_dt_to_p4(issue, job) # Both have changed. Apply the conflict resolution policy. else: assert changed == 'both' # Check for no changes and ignore changes = self.translate_issue_dt_to_p4(issue, job) if not changes: return # "Issue '%s' and job '%s' have both changed. Consulting # conflict resolution policy." self.log(806, (issuename, jobname)) self.log(809, changes) decision = self.conflict_policy(issue, job) if decision == 'dt': # "Defect tracker issue '%s' and Perforce job '%s' # have both changed since the last time the replicator # polled the databases. The replicator's conflict # resolution policy decided to overwrite the job with # the issue." reason = [ catalog.msg(841, (issuename, jobname)) ] self.overwrite_issue_dt_to_p4(issue, job, reason, 0) elif decision == 'p4': # "Defect tracker issue '%s' and Perforce job '%s' # have both changed since the last time the replicator # polled the databases. The replicator's conflict # resolution policy decided to overwrite the issue # with the job." reason = [ catalog.msg(842, (issuename, jobname)) ] self.overwrite_issue_p4_to_dt(issue, job, reason, 0) else: # "Conflict resolution policy decided: no action." self.log(807) # revert_issue_dt_to_p4(self, issue, job). This is called when an # error has occurred in replicating from Perforce to the defect # tracker. The most likely reason for this is a privilege failure # (the user is not allowed to edit that issue in that way) or a # failure to put valid values in the job fields. In this case, set # the job back to a copy of the issue. def revert_issue_dt_to_p4(self, issue, job): assert isinstance(issue, dt_interface.defect_tracker_issue) assert isinstance(job, types.DictType) issuename = issue.readable_name() jobname = job['Job'] # Save exception information because we might need to send a # message with two stack tracebacks! exc_info = sys.exc_info() try: try: # Get the issue again, since it might have been changed # in memory in the course of the failed replication. # Note new variable name so as not to overwrite the old # issue. (Can we avoid all this nonsense by keeping # better track of old and new issues?) GDR 2000-10-31. issue_2 = self.dt.issue(issue.id()) if not issue_2: # "Issue '%s' not found." raise self.error, catalog.msg(840, issue.id()) self.overwrite_issue_dt_to_p4( issue_2, job, [ # "The replicator failed to replicate Perforce job # '%s' to defect tracker issue '%s', because of the # following problem:" catalog.msg(851, (jobname, issuename)), ]) except: # Replicating back to Perforce failed. Report both # errors to the administrator. self.mail_report( # "Job '%s' could not be replicated to issue '%s'." catalog.msg(848, (jobname, issuename)), [ # "The replicator failed to replicate Perforce job # '%s' to defect tracker issue '%s' because of this # problem:" catalog.msg(854, (jobname, issuename)), self.exception_message(exc_info), # "Here's a full Python traceback:" catalog.msg(852), self.stacktrace(exc_info), # "The replicator attempted to restore the job to a # copy of the issue, but this failed too, because of # the following problem:" catalog.msg(855), ], [ # "The replicator has now given up." catalog.msg(856), ]) finally: # Break circular reference. See [van Rossum 2000-03-22, # 3.1] and rule code/python/compatible. del exc_info # overwrite_issue_p4_to_dt(self, issue, job, reason). As # replicate_issue_p4_to_dt, but e-mails an old copy of the issue to # the owner of the job and the administrator. The reason argument # is a list of message objects giving the reason for the # overwriting. If the error argument is 1, the mail message # includes an error message and stack traceback. def overwrite_issue_p4_to_dt(self, issue, job, reason, error=1): assert isinstance(issue, dt_interface.defect_tracker_issue) assert isinstance(job, types.DictType) assert isinstance(reason, types.ListType) issuename = issue.readable_name() jobname = job['Job'] # "Overwrite issue '%s' with job '%s'." self.log(810, (issuename, jobname)) # Build e-mail before overwriting so we get the old issue. # "Issue '%s' overwritten by job '%s'." subject = catalog.msg(857, (issuename, jobname)) extra = [ # "The replicator has therefore overwritten defect tracker # issue '%s' with Perforce job '%s'." catalog.msg(858, (issuename, jobname)), # "The defect tracker issue looked like this before being # overwritten:" catalog.msg(859), str(issue), ] self.replicate_issue_p4_to_dt(issue, job) self.mail_report(subject, reason, extra, job, error) # overwrite_issue_dt_to_p4(self, issue, job, reason). As # replicate_issue_dt_to_p4, but e-mails an old copy of the issue to # the owner of the job and the administrator. The reason argument # is a list of strings or message objects given a reason for the # overwriting. If the error argument is 1, the mail message # includes an error message and stack traceback. def overwrite_issue_dt_to_p4(self, issue, job, reason, error=1): assert isinstance(issue, dt_interface.defect_tracker_issue) assert isinstance(job, types.DictType) assert isinstance(reason, types.ListType) issuename = issue.readable_name() jobname = job['Job'] # "Overwrite job '%s' with issue '%s'." self.log(811, (jobname, issuename)) # Build e-mail before overwriting so we get the old job. # "Job '%s' overwritten by issue '%s'." subject = catalog.msg(860, (jobname, issuename)) extra = [ # "The replicator has therefore overwritten Perforce job # '%s' with defect tracker issue '%s'. See section 2.2 of # the P4DTI User Guide for more information." catalog.msg(861, (jobname, issuename)), # "The job looked like this before being overwritten:" catalog.msg(862), self.job_format(job), ] self.replicate_issue_dt_to_p4(issue, job) self.mail_report(subject, reason, extra, job, error) # replicate_issue_dt_to_p4(issue, old_job). Replicate the given # issue from the defect tracker to Perforce. def replicate_issue_dt_to_p4(self, issue, job): assert isinstance(issue, dt_interface.defect_tracker_issue) assert isinstance(job, types.DictType) # Transform the issue into a job. This has to be done first # because the job might be new, and we won't be able to # replicate fixes or filespecs until the job's been created # (p4 fix won't accept non-existent jobnames). I suppose I # could create a dummy job to act as a placeholder here, but # that's not easy at all -- you have to know quite a lot about # the jobspec to be able to create a job. changes = self.translate_issue_dt_to_p4(issue, job, 1) if changes: # "-- Changed fields: %s." self.log(812, changes) self.update_job(job, changes) else: # "-- No issue fields were replicated." self.log(813) self.replicate_filespecs_dt_to_p4(issue, job) self.replicate_fixes_dt_to_p4(issue, job) # replicate_filespecs_dt_to_p4(issue, job). Replicate filespecs # from the defect tracker to Perforce. def replicate_filespecs_dt_to_p4(self, issue, job): if not self.feature['filespecs']: return dt_filespecs = issue.filespecs() p4_filespecs = self.job_filespecs(job) if self.filespecs_differences(dt_filespecs, p4_filespecs): names = map(lambda f: f.name(), dt_filespecs) self.update_job(job, { 'P4DTI-filespecs': string.join(names,'\n') }) # "-- Filespecs changed to '%s'." self.log(814, string.join(names)) # replicate_fixes_dt_to_p4(issue, job). Replicate fixes from the # defect tracker to Perforce. def replicate_fixes_dt_to_p4(self, issue, job): if not self.feature['fixes']: return return #RHGC - for fixes, p4 is the master - don't replicate other way p4_fixes = self.job_fixes(job) dt_fixes = issue.fixes() job_status = None diffs = self.fixes_differences(dt_fixes, p4_fixes) jobname = job['Job'] for p4_fix, dt_fix in diffs: if p4_fix and not dt_fix: self.p4.run('fix -d -c %s %s' % (p4_fix['Change'], jobname)) self.record_job_update(job) # "-- Deleted fix for change %s." self.log(815, p4_fix['Change']) elif not p4_fix and dt_fix: try: self.p4.run('fix -s %s -c %d %s' % (dt_fix.status(), dt_fix.change(), jobname)) except p4i.error, message: # We get an error here if the changelist was somehow # deleted. In this case there's not much we can do # except log the error. See job000128. self.log(message) else: self.record_job_update(job) job_status = dt_fix.status() # "-- Added fix for change %d with status %s." self.log(816, (dt_fix.change(), dt_fix.status())) elif p4_fix['Status'] != dt_fix.status(): self.p4.run('fix -s %s -c %d %s' % (dt_fix.status(), dt_fix.change(), jobname)) self.record_job_update(job) job_status = dt_fix.status() # "-- Fix for change %d updated to status %s." self.log(817, (dt_fix.change(), dt_fix.status())) else: # This should't happen, since fixes_differences returns # only a list of pairs which differ. assert 0 # It might be the case that the job status has been changed in # the course of creating a fix record. Restore the correct # status if necessary. if (job_status and job_status != job.get(self.config.job_status_field, None)): self.update_job(job, { 'Status': job[self.config.job_status_field] }) # replicate_issue_p4_to_dt(issue, job). Replicate the given job # from Perforce to the defect tracker. def replicate_issue_p4_to_dt(self, issue, job): assert isinstance(issue, dt_interface.defect_tracker_issue) assert isinstance(job, types.DictType) fix_changes = {} self.replicate_fixes_p4_to_dt(issue, job, fix_changes=fix_changes) self.replicate_filespecs_p4_to_dt(issue, job) # Transform the job into an issue and update the issue. changes = self.translate_issue_p4_to_dt(issue, job) for k in fix_changes.keys(): changes[k] = fix_changes[k] if changes: # "-- Changed fields: %s." self.log(824, repr(changes)) p4_user = self.job_modifier(job) dt_user = self.config.user_translator.translate_p4_to_dt( p4_user, self.dt, self.dt_p4) issue.update(dt_user, changes) else: # "-- No job fields were replicated." self.log(825) # The issue may have changed as a consequence of updating it. # For example, in TeamTrack the issue's owner changes when an # issue goes through a transition. So we fetch the issue again, # check for changes and replicate them back to the job if we # find them. See job000053. new_issue = self.dt.issue(issue.id()) if not new_issue: # "Issue '%s' not found." raise self.error, catalog.msg(840, issue.id()) new_changes = self.translate_issue_dt_to_p4(new_issue, job, 1) if new_changes: # "-- Defect tracker made changes as a result of the update: # %s." self.log(826, new_changes) self.update_job(job, new_changes) # replicate_filespecs_p4_to_dt(issue, job). Replicate filespecs for # the given job from Perforce to the defect tracker. def replicate_filespecs_p4_to_dt(self, issue, job): if not self.feature['filespecs']: return p4_filespecs = self.job_filespecs(job) dt_filespecs = issue.filespecs() filespec_diffs = self.filespecs_differences(dt_filespecs, p4_filespecs) for p4_filespec, dt_filespec in filespec_diffs: if dt_filespec and not p4_filespec: dt_filespec.delete() # "-- Deleted filespec %s." self.log(822, dt_filespec.name()) elif not dt_filespec: issue.add_filespec(p4_filespec) # "-- Added filespec %s." self.log(823, p4_filespec) else: # This should't happen, since filespecs_differences # returns only a list of pairs which differ. assert 0 # replicate_fixes_p4_to_dt(issue, job). Replicate fixes for the # given job from Perforce to the defect tracker. Ensures that the # changelists for the fixes are also replicated. # # If you change this function, you may have to change the regression # test for job000385; see test_p4dti.py. def replicate_fixes_p4_to_dt(self, issue, job, fix_changes = {}, failed_before = 0): if not self.feature['fixes']: return # RHGC - Also call the global translator - handles fields without direct 1-to-1 mapping get_fix_changes = 0 if hasattr(self.config, 'fix_update_p4_to_dt'): get_fix_changes = 1 self.config.fix_update_p4_to_dt(self, issue, job, fix_changes) p4_fixes = self.job_fixes(job) dt_fixes = issue.fixes() fix_diffs = self.fixes_differences(dt_fixes, p4_fixes) for p4_fix, dt_fix in fix_diffs: if dt_fix and not p4_fix: dt_fix.delete() # "-- Deleted fix for change %d." self.log(818, dt_fix.change()) if get_fix_changes: self.config.fix_update_p4_to_dt(self, issue, job, fix_changes) else: # p4 fix has changed # "-- Considering Perforce fix %s." self.log(819, p4_fix) (change, client, date, status, user) = self.translate_fix_p4_to_dt(p4_fix) # make sure changelist is replicated try: changelist = self.p4.run('change -o %s' % change)[0] except p4i.error: # The changelist might have been renumbered since we # called job_fixes; see job000385. If it has, then # try again. But don't get stuck in an infinite # loop. if failed_before: raise else: self.replicate_fixes_p4_to_dt(issue, job, failed_before = 1) return self.replicate_changelist_p4_to_dt(changelist) if not dt_fix: # new fix; add to DT issue.add_fix(change, client, date, status, user) # "-- Added fix for change %s with status %s." self.log(820, (p4_fix['Change'], p4_fix['Status'])) if get_fix_changes: self.config.fix_update_p4_to_dt(self, issue, job, fix_changes) elif dt_fix.status() != p4_fix['Status']: # status changed dt_fix.update(change, client, date, status, user) # "-- Fix for change %s updated to status %s." self.log(821, (p4_fix['Change'], p4_fix['Status'])) if get_fix_changes: self.config.fix_update_p4_to_dt(self, issue, job, fix_changes) else: # This should't happen, since fixes_differences # returns only a list of pairs which differ. assert 0 # translate_fix_p4_to_dt(p4_fix). Translate a Perforce fix record # to a defect tracker fix object. def translate_fix_p4_to_dt(self, p4_fix): assert isinstance(p4_fix, types.DictType) change = int(p4_fix['Change']) client = p4_fix['Client'] date = self.config.date_translator.translate_p4_to_dt( p4_fix['Date'], self.dt, self.dt_p4) status = p4_fix['Status'] user = self.config.user_translator.translate_p4_to_dt( p4_fix['User'], self.dt, self.dt_p4) return change, client, date, status, user # translate_issue_dt_to_p4(issue, job). Return changes as a # dictionary but don't apply them yet. The optional third argument # missing_is_empty determines whether a missing field in a Perforce # job is considered identical to an empty string. def translate_issue_dt_to_p4(self, issue, job, missing_is_empty=0): assert isinstance(issue, dt_interface.defect_tracker_issue) assert isinstance(job, types.DictType) p4_default_value = None if missing_is_empty: p4_default_value = '' changes = {} # Do the P4DTI fields need to be changed? If so, record in # changes. for key, value in [('P4DTI-rid', self.rid), ('P4DTI-issue-id', issue.id())]: if job.get(key, None) != value: changes[key] = value # What about the replicated fields? for dt_field, p4_field, trans in self.config.field_map: try: p4_value = trans.translate_dt_to_p4( issue[dt_field], self.dt, self.dt_p4, issue, job) except: # "Translating issue field '%s' (value '%s') to job # field '%s'..." self.log(922, (dt_field, issue[dt_field], p4_field)) raise # Allow for None which means don't translate if p4_value <> None and job.get(p4_field, p4_default_value) != p4_value: changes[p4_field] = p4_value # RHGC - Also call the global translator - handles fields without direct 1-to-1 mapping if hasattr(self.config, 'translate_dt_to_p4'): self.config.translate_dt_to_p4(self, issue, job, changes) return changes # translate_issue_p4_to_dt(issue, job). Return changes as a # dictionary but don't apply them yet. def translate_issue_p4_to_dt(self, issue, job): assert isinstance(issue, dt_interface.defect_tracker_issue) assert isinstance(job, types.DictType) changes = {} for dt_field, p4_field, trans in self.config.field_map: # Missing fields indicate optional fields without a value -- # this happens when the empty string has been supplied for # the value. So supply the empty string ourselves. See # job000181. p4_value = job.get(p4_field, '') try: dt_value = trans.translate_p4_to_dt( p4_value, self.dt, self.dt_p4, issue, job) except: # "Translating job field '%s' (value '%s') to issue # field '%s'..." self.log(923, (p4_field, p4_value, dt_field)) raise if dt_value != issue[dt_field]: changes[dt_field] = dt_value # RHGC - Also call the global translator - handles fields without direct 1-to-1 mapping if hasattr(self.config, 'translate_p4_to_dt'): self.config.translate_p4_to_dt(self, issue, job, changes) return changes # create_issue(job). Makes a new issue corresponding to the # job. Returns the new issue. def create_issue(self, job): assert isinstance(job, types.DictType) dict = {} for dt_field, p4_field, trans in self.config.field_map: # Missing fields indicate optional fields without a value -- # this happens when the empty string has been supplied for # the value. So supply the empty string ourselves. See # job000181. When migrating, this will also happen for # fields which we are about to add to the jobspec. p4_value = job.get(p4_field, '') dt_value = trans.translate_p4_to_dt( p4_value, self.dt, self.dt_p4, None, job) dict[dt_field] = dt_value # "Raw issue: %s" self.log(919, dict) self.config.prepare_issue_advanced( self.config, self.dt, self.dt_p4, dict, job) # "Prepared issue: %s" self.log(920, dict) return self.dt.new_issue(dict, job['Job']) # A. REFERENCES # # [GDR 2000-09-13] "Replicator design"; Gareth Rees; Ravenbrook Limited; # 2000-09-13; # . # # [GDR 2000-10-16] "Perforce Defect Tracking Integration Integrator's # Guide"; Gareth Rees; Ravenbrook Limited; 2000-10-16; # . # # [RB 2000-08-10] "Perforce Defect Tracking Integration Architecture"; # Richard Brooksby; Ravenbrook Limited; 2000-08-10; # . # # [Requirements] "Perforce Defect Tracking Integration Project # Requirements"; Gareth Rees; Ravenbrook Limited; 2000-05-24; # . # # [Seiwald 2000-09-11] "Re: Is 'p4 counter logger 0' idempotent?" # (e-mail message); Christopher Seiwald; Perforce Software; 2000-09-11; # . # # [Seiwald 2000-11-28] "Re: Can we rely on 'always' fields not appearing # in 'job -o newjob'?" (e-mail message); Christopher Seiwald; Perforce # Software; 2000-11-28; # . # # [van Rossum 2000-03-22] "Python Library Reference (version 1.5.2)"; # Guide van Rossum; 2000-03-22; # . # # # B. DOCUMENT HISTORY # # 2000-12-05 NB addess -> address # # 2000-12-05 GDR Imported p4 module so replicator can catch p4.error. # Added replicator method mail_concerning_job() for e-mailing people # about a job. There were several places where the owner of a job was # been fetched and e-mailed, some of which were buggy. This method # replaces all those instances, hopefully correctly. # # 2000-12-06 GDR Fixed the replicator's user_email_address method so # that it really returns None when there is no such user. # # 2000-12-06 GDR Updated supported Perforce changelevel to 18974 (this # is the changelevel we document against). # # 2000-12-06 GDR Fixing job000133 (replicator gets wrong user when a job # is fixed): When the last person who changed the job is the replicator # user, update the issue on behalf of the job owner instead. # # 2000-12-06 GDR If the owner of a job and the person who last changed # it are the same (a common occurrence), include them only once in any # e-mail sent by the replicator about that job. # # 2000-12-06 GDR E-mail messages from the replicator concerning # overwritten jobs are much improved. # # 2000-12-06 GDR The overwriting methods now send e-mail with the new # issue/job in them, not the old issue/job. # # 2000-12-07 GDR When there's no error message (typically in the case of # assertion failure), say so. Format the job properly in all messages # (including the one sent by the conflict method). Use "Perforce job" # and "defect tracker issue" for clarity. (Even better would be to have # a defect_tracker.name so it could say "TeamTrack issue".) # # 2000-12-07 GDR Created new class dt_perforce; a placeholder for an # eventual full implementation of a defect_tracker subclass that # interfaces to Perforce. # # 2001-01-19 NB Better stack traces. # # 2001-01-19 GDR Handle empty fields. Fix comments. # # 2001-01-23 NB SMTP server test (unmatched users). # # 2001-02-04 GDR Updated definition of defect_tracker.all_issues() # method. # # 2001-02-12 GDR Fixed bug in check_consisteney. # # 2001-02-13 GDR Don't send e-mail if administrator_address or # smtp_server is None. # # 2001-02-14 GDR user_email_address returns None if the user doesn't # exist, even when there are no spare licenses (see job000204). # # 2001-02-16 NB Added replicate-p configuration parameter. # # 2001-02-21 GDR The replicator backs off exponentially if it fails to # poll successfully, so as not to mailbomb the administrator. # # 2001-02-22 GDR replicate_changelist_p4_to_dt applies a text translator # to the change description, since a change description can have several # lines of text. # # 2001-02-23 GDR Added a corresponding_id method to the # defect_tracker_issue class. # # 2001-03-02 RB Transferred copyright to Perforce under their license. # # 2001-03-11 GDR Use messages for errors, logging and e-mail. # # 2001-03-13 GDR Removed the recording of conflicts. Conflict # resolution is always immediate. Moved translator class to translator # module. Moved defect tracker interface classes to dt_interface.py. # # 2001-03-14 GDR Use messages when consistency checking. # # 2001-03-15 GDR Get configuration from config module. # # 2001-03-16 GDR Added refresh_perforce_jobs() method. # # 2001-03-21 GDR The setup_for_replication() method takes a jobname # argument. # # 2001-03-23 GDR New method job_modifier returns a best guess at who # last modified the job, to fix job000270. # # 2001-03-24 GDR Check supported Perforce server changelevel in p4.py, # not replicator.__init__ (so that we find out if p4 -G jobspec -i will # work before actually trying it in init.py). # # 2001-03-25 RB Moved message 889 to catalog due to merge from version # 1.0 sources. # # 2001-05-17 GDR Defect tracker methods 'add_issue' and # 'changed_entities' may return cursors as well as lists. # # 2001-05-19 GDR Added progress report for consistency checking. # # 2001-05-22 GDR Compare job names case-insensitively when fetching a # job to work around job000313. # # 2001-06-14 GDR The reason argument to overwrite_issue_dt_to_p4 must # consist only of messages, since they are logged as well as mailed. # Each call to the defect_tracker.issue() method now has error checking. # # 2001-06-15 NB Moved functionality out of init because it's not all # required by all the scripts. Creation of client and calling # defect_tracker.init moved to __init__ method. Checking of jobspec # moved to new method check_jobspec and called from check, refresh and # run. # # 2001-06-25 NB post-migration replication now works! # # 2001-06-25 NB Now support the 'use_perforce_jobnames' configuration # parameter by specifying 'new' for the jobname in replicate_many() # and then recording the jobname when we find out what it is in # update_job(). # # 2001-06-26 NB Now support the creation of new jobs in Perforce. # Also moved the replication of changelists, and changed the interface # to changed_entities (so that changelists are replicated iff the # matching fixes are replicated). # # 2001-06-27 NB Moved code from new_issue out to the defect tracker # (changed new_issue interface). # # 2001-06-29 NB Produce full traceback if migration fails. # # 2001-06-30 GDR The replicator doesn't stop if it can't replicate a fix # because the changelist has been deleted (see job000128). # # 2001-07-04 NB Changed issue creation system so we use the regular # field map. # # 2001-08-06 GDR Specify -1 for DST argument to mktime(). # # 2001-10-02 GDR Include users with duplicate e-mail addresses in # startup message. See job000308. # # 2001-10-03 GDR Handle renumbered changelist race condition during # replication of fixes from Perforce to the defect tracker; see # job000385. # # 2001-10-07 GDR Reformatted as a document. # # 2001-10-23 GDR Renamed poll() as poll_databases(); added poll() entry # point. Report error if jobname is still new after update_job. Don't # call replicate_issue_p4_to_dt after migrating; just replicate fixes # and filespecs. Wrap migration code with checks that the feature is # supported. Protect new_issues_end with a try ... finally. # # 2001-10-29 GDR Send e-mail if create_issue fails. Always log e-mail # even if it doesn't get sent. # # 2001-11-01 NB Add calls to poll_start and poll_end, for job000306. # # 2001-11-05 GDR Rename migrate_issue as prepare_issue_advanced; new # configuration parameter replicate_job_p. # # 2001-11-07 NDL Extracted contents of run() into smaller functions # to make them acessible to NT service code. # # 2001-11-09 NDL Added debug messages at start and end of # poll_databases(). # # 2001-11-19 NDL Changed text of message 891 (to make it more general). # # 2001-11-20 GDR Rename pre_migrate_issue to translate_jobspec_advanced. # Update Perforce jobspec in entry points where needed. # # 2001-11-21 GDR Allow migration to be run multiple times. Use hasattr # consistently. # # 2001-11-22 GDR Simplify migration by not replicating back (this means # that migration doesn't touch Perforce jobs). # # 2001-11-26 GDR More debugging messages when migrating. # # 2001-11-27 GDR Support starting migration at a particular job. # # 2001-11-28 GDR Don't assume that jobs have fields other than 'Job': # use job.get(field, default) consistently. Don't delete all jobs when # refreshing; instead replicate from the defect tracker, taking care # never to run "p4 job -o JOB" in case a job fails to match the jobspec. # # 2001-11-29 GDR Added messages explaining what was happening when an # error happened. Consider missing field equal to empty one when # replicating back from the defect tracker or when checking consistency. # # 2001-12-04 GDR Handle sets of supported features. # # 2001-12-08 GDR Don't include an error message and stack traceback in # an ordinary conflict report. Delete traceback variables as advised in # [van Rossum 2000-03-22, 3.1]. # # 2002-01-28 GDR Don't replicate changes in Perforce that we made in the # previous poll (see record_job_update). In Perforce 2002.1, use the # P4DTI-user field as an accurate guide as to who last edited the job # (see job_modifier). # # 2002-01-31 GDR Don't replicate changelists unless the defect tracker # supports the fixes feature. # # 2002-02-01 GDR Put replicate_* functions in a more logical order. # Call config.replicate_p directly rather than via the defect tracker # issue. # # 2002-03-28 NB Correct lambda syntax. # # 2003-05-21 NB Removed a specialized piece of control flow which # protects against some TeamTrack-specific errors. # # 2003-05-22 NB Code to add the replicator user to Bugzilla when # migrating Perforce users. # # 2003-05-30 NB When replicating all issues to Perforce, take care not # to replicate ones which are replicated by someone else. # # 2003-05-30 NB Work around broken client spec or depot list. # # 2003-12-05 NB Changed interface to jobspec-checking function. # # 2003-12-12 NB Change jobspec-related functions to expose them to new # scripts. # # # C. COPYRIGHT AND LICENSE # # This file is copyright (c) 2001 Perforce Software, Inc. All rights # reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in # the documentation and/or other materials provided with the # distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS # OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR # TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE # USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH # DAMAGE. # # # $Id: //info.ravenbrook.com/project/p4dti/version/2.1/code/replicator/replicator.py#2 $