# Perforce Defect Tracking Integration Project # # # DT_BUGZILLA.PY -- DEFECT TRACKER INTERFACE (BUGZILLA) # # Nick Barnes, Ravenbrook Limited, 2000-11-21 # # # 1. INTRODUCTION # # This Python module implements an interface between the P4DTI # replicator and the Bugzilla defect tracker [Requirements, 18], by # defining the classes listed in [GDR 2000-10-16, 7]. In particular, it # defines the following classes: # # [3] bugzilla_bug(dt_interface.defect_tracker_issue) [GDR 2000-10-16, # 7.2] # # [4] bugzilla_fix(dt_interface.defect_tracker_fix) [GDR 2000-10-16, # 7.3] # # [5] bugzilla_filespec(dt_interface.defect_tracker_filespec) [GDR # 2000-10-16, 7.4]. # # [6] dt_bugzilla(dt_interface.defect_tracker) [GDR 2000-10-16, 7.1]. # # [7] Translators [GDR 2000-10-16, 7.5] for dates [GDR 2000-10-16, # 7.5.1], elapsed times, foreign keys, single select fields, states [GDR # 2000-10-16, 7.5.2], multi-line text fields [GDR 2000-10-16, 7.5.3] and # users [GDR 2000-10-16, 7.5.4]. # # This module accesses the Bugzilla database using the Python interface # to Bugzilla [NB 2000-11-14c] and accesses and stores data according to # the Bugzilla database schema [NB 2000-11-14a] and the Bugzilla schema # extensions [NB 2000-11-14b]. # # The intended readership of this document is project developers. # # This document is not confidential. import catalog import dt_interface import message import re import string import translator import types import time # 2. DATA AND UTILITIES # 2.1. Error object # # All exceptions raised by this module use 'error' as the exception # object. error = 'Bugzilla module error' # 3. BUGZILLA BUG INTERFACE # # This class implements the replicator's interface to the bugs in # Bugzilla [GDR 2000-10-16, 7.2]. class bugzilla_bug(dt_interface.defect_tracker_issue): dt = None # The defect tracker this bug belongs to. bug = None # The dictionary representing the bugzilla bug. p4dti_bug = None # Dictionary representing the p4dti_bugs record. def __init__(self, bug, dt): # the set of keys which we explictly use in this class. for key in ['bug_id', 'reporter', 'qa_contact', 'everconfirmed', 'assigned_to', 'groupset', 'bug_status', 'resolution']: assert bug.has_key(key) assert isinstance(dt, dt_bugzilla) self.dt = dt self.bug = bug self.p4dti_bug = self.dt.bugzilla.bug_p4dti_bug(bug) def __getitem__(self, key): assert isinstance(key, types.StringType) if self.bug.has_key(key): return self.bug[key] else: return self.p4dti_bug[key] def __repr__(self): return repr({'bug':self.bug, 'p4dti':self.p4dti_bug}) def has_key(self, key): return self.bug.has_key(key) or self.p4dti_bug.has_key(key) def add_filespec(self, filespec): filespec_record = {} filespec_record['filespec'] = filespec filespec_record['bug_id'] = self.bug['bug_id'] filespec = bugzilla_filespec(self, filespec_record) filespec.add() def add_fix(self, change, client, date, status, user): fix_record = {} fix_record['bug_id'] = self.bug['bug_id'] fix_record['changelist'] = change fix_record['client'] = client fix_record['p4date'] = date fix_record['status'] = status fix_record['user'] = user fix = bugzilla_fix(self, fix_record) fix.add() def corresponding_id(self): if (self.p4dti_bug != None and self.p4dti_bug.has_key('jobname')): return self.p4dti_bug['jobname'] else: return self.dt.config.jobname_function(self.bug) def id(self): return str(self.bug['bug_id']) def filespecs(self): filespecs = self.dt.bugzilla.filespecs_from_bug_id( self.bug['bug_id']) return map(lambda f, self=self: bugzilla_filespec(self, f), filespecs) def fixes(self): fixes = self.dt.bugzilla.fixes_from_bug_id(self.bug['bug_id']) return map(lambda f, self=self: bugzilla_fix(self, f), fixes) def readable_name(self): return str(self.bug['bug_id']) def rid(self): if self.p4dti_bug == None: # not yet replicated return "" else: return self.p4dti_bug['rid'] def make_p4dti_bug(self, jobname, created=0): assert self.p4dti_bug == None self.p4dti_bug = {} self.p4dti_bug['bug_id'] = self.bug['bug_id'] self.p4dti_bug['jobname'] = jobname self.dt.bugzilla.add_p4dti_bug(self.p4dti_bug, created) def setup_for_replication(self, jobname): self.make_p4dti_bug(jobname, created=0) # Check Bugzilla permissions. # In Bugzilla 2.10 to 2.16.4, permissions are checked in # CheckCanChangeField() in process_bug.cgi. This is the test: # # 0. disabled users can't make any change; # (this is not in CheckCanChangeField(): # disabled users can't even log in) # 1. anyone can make a null change; # 2. anyone can make a change which just adds or removes # whitespace at the beginning or end of a value; # 3. anyone can add a description record; # 4. anyone in the "editbugs" group can make any change; # 5. the bug's reporter can always change the bug status; # 6. anyone in the "canconfirm" group can change the status to any # opened status. # 7. anyone can change the status to any opened status if the bug has # 'everconfirmed' set. # 8. The reporter, or assigned_to, or qa_contact of a bug can make # any change to the bug other than a change to an opened status. # 9. Nobody else can make a change. # # An opened status is NEW, REOPENED, or ASSIGNED. # # Note that there is not a check made of whether the user is in # the bug group of the bug. There is an implicit check of this in # buglist.pl and bug_form.pl; if the user is not in the bug group, # the bug is not displayed. def opened_status(self, status): assert isinstance(status, types.StringType) return status in ['NEW', 'REOPENED', 'ASSIGNED'] def can_change_field(self, user, canconfirm, key, old, new): assert isinstance(key, types.StringType) assert type(old) == type(new) # 0. disabled users handled by check_permissions(). # 1. null changes are eliminated by the replicator. assert (old != new) # 2. whitespace changes. if (isinstance(old, types.StringType) and isinstance(new, types.StringType) and string.strip(old) == string.strip(new)): return 1 # 3. anyone can add a description record. if key == 'longdesc': return 1 # 4. editbugs handled by check_permissions(). # 5, 6, 7: if key == 'bug_status': # 5: reporter can change status if user == self.bug['reporter']: return 1 if self.opened_status(new): # 6. canconfirm if canconfirm: return 1 # 7. everconfirmed if self.bug['everconfirmed'] == 1: return 1 if (key != 'bug_status') or not self.opened_status(new): # 8. reporter/assigned_to/qa_contact if (user == self.bug['reporter'] or user == self.bug['assigned_to'] or user == self.bug['qa_contact']): return 1 # 9. nobody else return 0 def check_permissions(self, user, changes): # user 0 can't do anything. if user == 0: # "User 0 cannot edit bug %d." raise error, catalog.msg(538, (self.bug['bug_id'])) # 0. disabled users can't do anything. if self.dt.bugzilla.user_is_disabled(user): # "User %d is disabled, so cannot edit bug %d." raise error, catalog.msg(537, (user, self.bug['bug_id'])) user_groupset = self.dt.user_groupset(user) bug_groupset = self.bug['groupset'] assert (bug_groupset == 0 or self.dt.singleton_groupset(bug_groupset)) # 4. user in editbugs can make any change. if self.dt.groupset_has_named_group(user_groupset, 'editbugs'): return # Are we in the bug's groupset? # Bugzilla doesn't check this, but there is an implicit # check because Bugzilla won't show this bug to this user. if ((bug_groupset != 0) and (user_groupset & bug_groupset) != bug_groupset): # "User %d isn't in the right bug group to edit bug %d." raise error, catalog.msg(500, (user, self.bug['bug_id'])) canconfirm = self.dt.groupset_has_named_group(user_groupset, 'canconfirm') for key, newvalue in changes.items(): if not self.can_change_field(user, canconfirm, key, self.bug[key], newvalue): # "User %d doesn't have permission to change field '%s' # of bug %d to %s." raise error, catalog.msg(501, (user, key, self.bug['bug_id'], newvalue)) # Enforce Bugzilla's transition invariants: # # 1. bugs in 'RESOLVED', 'VERIFIED', and 'CLOSED' states must have # a valid 'resolution' field, whereas bugs in other states must # have an empty 'resolution' field. # 2. only certain transitions are allowable. allowable_transitions = { 'UNCONFIRMED': ['NEW', # confirm 'ASSIGNED', # assign 'RESOLVED'], # resolve 'NEW': ['ASSIGNED', # accept 'RESOLVED'], # resolve 'ASSIGNED': ['NEW', # reassign 'RESOLVED'], # resolve 'RESOLVED': ['VERIFIED', # verify 'CLOSED', # close 'REOPENED'], # reopen 'VERIFIED': ['CLOSED', # close 'REOPENED'], # reopen 'CLOSED': ['REOPENED'], # reopen 'REOPENED': ['NEW', # reassign 'ASSIGNED', # accept 'RESOLVED'] # resolve } def transition_allowed(self, old_status, new_status): return new_status in self.allowable_transitions[old_status] def status_needs_resolution(self, status): return status in ['RESOLVED', 'VERIFIED', 'CLOSED'] def enforce_invariants(self, changes): if (changes.has_key('resolution') and changes['resolution'] == 'DUPLICATE'): # "The P4DTI does not support marking bugs as DUPLICATE from # Perforce." raise error, catalog.msg(502) if changes.has_key('bug_status'): # We are making a transition. if not self.transition_allowed(self.bug['bug_status'], changes['bug_status']): # "Bugzilla does not allow a transition from status '%s' # to '%s'." raise error, catalog.msg(503, (self.bug['bug_status'], changes['bug_status'])) # Changing from 'UNCONFIRMED' sets everconfirmed. if (self.bug['bug_status'] == 'UNCONFIRMED' and self.bug['everconfirmed'] != 1): changes['everconfirmed'] = 1 if (self.status_needs_resolution(changes['bug_status']) and not self.status_needs_resolution( self.bug['bug_status'])): # We are transitioning to a status which requires a # resolution from one which does not. if (changes.has_key('resolution') and changes['resolution'] == ''): # We are also clearing the resolution. This may # happen due to a timing problem; if one p4 user # correctly transitions a bug to REOPENED and # clears the resolution field, and then another p4 # user transitions the bug to RESOLVED without # setting the resolution, without an intervening # replication, we may end up here. changes['resolution'] = 'FIXED' if (self.bug['resolution'] == '' and not changes.has_key('resolution')): # We are not setting the resolution field. We # can't force Perforce users to set the resolution # field, and even if procedures require it we can # still get here due to a race problem. If it # does happen, we set the resolution to FIXED. changes['resolution'] = 'FIXED' if not self.status_needs_resolution(changes['bug_status']): # We are transitioning to a status which requires # an empty resolution. If we don't have an empty # resolution, put one in. if changes.has_key('resolution'): if changes['resolution'] != '': changes['resolution'] = '' else: if self.bug['resolution'] != '': changes['resolution'] = '' # Some Bugzilla fields can not be updated from Perforce, or can # only be updated by appending. def restrict_fields(self, changes): for key in changes.keys(): if key in self.dt.config.read_only_fields: # "Cannot change Bugzilla field '%s'." raise error, catalog.msg(504, key) if key in self.dt.config.append_only_fields: new = changes[key] old = self.bug[key] if (len(new) < len(old) or new[:len(old)] != old): # "Can only append to Bugzilla field '%s'." raise error, catalog.msg(505, key) if (key in ['reporter', 'assigned_to'] and changes[key] == 0): # "Can't change Bugzilla field '%s' to 0." raise error, catalog.msg(540, key) def update(self, user, changes): changes_bug = {} changes_p4dti_bug = {} assert isinstance(user, types.IntType) for key, value in changes.items(): assert isinstance(key, types.StringType) if self.bug.has_key(key): changes_bug[key] = value elif self.p4dti_bug.has_key(key): changes_p4dti_bug[key] = value else: # "Updating non-existent Bugzilla field '%s'." raise error, catalog.msg(506, key) self.restrict_fields(changes_bug) self.enforce_invariants(changes_bug) self.check_permissions(user, changes_bug) self.dt.bugzilla.update_bug(changes_bug, self.bug, user) # Add processmail script to pending queue. self.dt.bugzilla.processmail(self.bug['bug_id'], user) # Now the bug is updated in the database, update our copy. for key, value in changes_bug.items(): self.bug[key] = value self.dt.bugzilla.update_p4dti_bug(changes_p4dti_bug, self.bug['bug_id']) # Now the p4dti_bug is updated in the database, update our copy. for key, value in changes_p4dti_bug.items(): self.p4dti_bug[key] = value # Delete this bug. def delete(self): bug_id = self.bug['bug_id'] self.dt.bugzilla.delete_fixes_for_bug(bug_id) self.dt.bugzilla.delete_filespecs_for_bug(bug_id) if self.p4dti_bug: self.dt.bugzilla.delete_p4dti_bug(bug_id) self.dt.bugzilla.delete_bug(bug_id) # 4. BUGZILLA FIX INTERFACE # # This class implements the replicator's interface to a fix record in # Bugzilla [GDR 2000-10-16, 7.3]. class bugzilla_fix(dt_interface.defect_tracker_fix): bug = None # The Bugzilla bug to which the fix refers. fix = None # The dictionary representing the bugzilla fix record. def __init__(self, bug, dict): assert isinstance(bug, bugzilla_bug) assert isinstance(dict, types.DictType) for key in ['changelist', 'client', 'p4date', 'status', 'bug_id', 'user']: assert dict.has_key(key) self.bug = bug self.fix = dict def __getitem__(self, key): assert isinstance(key, types.StringType) return self.fix[key] def __repr__(self): return repr(self.fix) def __setitem__(self, key, value): assert isinstance(key, types.StringType) self.fix[key] = value def add(self): self.bug.dt.bugzilla.add_fix(self.fix) def change(self): return self.fix['changelist'] def delete(self): self.bug.dt.bugzilla.delete_fix(self.fix) def status(self): return self.fix['status'] def update(self, change, client, date, status, user): changes = {} if self.fix['changelist'] != change: changes['changelist'] = change if self.fix['client'] != client: changes['client'] = client if self.fix['p4date'] != date: changes['p4date'] = date if self.fix['status'] != status: changes['status'] = status if self.fix['user'] != user: changes['user'] = user if len(changes) != 0: self.bug.dt.bugzilla.update_fix(changes, self.fix['bug_id'], self.fix['changelist']) # 5. BUGZILLA FILESPEC INTERFACE # # This class implements the replicator's interface to a filespec record # in Bugzilla [GDR 2000-10-16, 7.4]. class bugzilla_filespec(dt_interface.defect_tracker_filespec): bug = None # The Bugzilla bug to which the filespec refers. filespec = None # The dictionary representing the filespec record. def __init__(self, bug, dict): self.bug = bug self.filespec = dict def __getitem__(self, key): return self.filespec[key] def __repr__(self): return repr(self.filespec) def __setitem__(self, key, value): self.filespec[key] = value def add(self): self.bug.dt.bugzilla.add_filespec(self.filespec) def delete(self): self.bug.dt.bugzilla.delete_filespec(self.filespec) def name(self): return self.filespec['filespec'] # 6. BUGZILLA INTERFACE # # The dt_bugzilla class implements a generic interface between the # replicator and the Bugzilla defect tracker [GDR 2000-10-16, 7.1]. # Some configuration can be done by passing a configuration hash to the # constructor; for more advanced configuration you should subclass this # and replace some of the methods. class dt_bugzilla(dt_interface.defect_tracker): rid = None sid = None bugzilla = None def __init__(self, config): self.config = config self.rid = config.rid self.sid = config.sid self.bugzilla = config.bugzilla def log(self, msg, args = ()): if not isinstance(msg, message.message): msg = catalog.msg(msg, args) self.config.logger.log(msg) def all_issues(self): bugs = self.bugzilla.all_bugs_since(self.config.start_date) return map(lambda bug,dt=self: bugzilla_bug(bug,dt), bugs) def poll_start(self): self.bugzilla.lock_tables() self.bugzilla.clear_processmails() def poll_end(self): self.bugzilla.unlock_tables() self.bugzilla.invoke_processmails() def changed_entities(self): replication = self.bugzilla.new_replication() last = self.bugzilla.latest_complete_replication() bugs = self.bugzilla.changed_bugs_since(last) return (map(lambda bug,dt=self: bugzilla_bug(bug,dt), bugs), {}, # changed changelists replication) def mark_changes_done(self, replication): self.bugzilla.end_replication() def init(self): # ensure that bugzilla.replication is valid even outside a # replication cycle, so that all_issues() works. See # job000221. NB 2001-02-22. self.bugzilla.first_replication(self.config.start_date) # Supported features; see [GDR 2000-10-16, 3.5]. feature = { 'filespecs': 1, 'fixes': 1, 'migrate_issues': 1, 'new_issues': 1, 'new_users': 1, } def supports(self, feature): return self.feature.get(feature, 0) def issue(self, bug_id): bug = self.bugzilla.bug_from_bug_id(int(bug_id)) return bugzilla_bug(bug, self) def add_user(self, p4user, email, fullname): userid = self.bugzilla.userid_from_email(email) if userid: # "Perforce user '%s <%s>' already exists in Bugzilla as # user %d." self.log(533, (p4user, email, userid)) return userid else: dict = {} dict['login_name'] = email dict['password'] = self.config.migrated_user_password dict['realname'] = fullname groupset = 0L self.init_group_tables() for group in self.config.migrated_user_groups: if self.group_by_name.has_key(group): groupset = (groupset + self.group_by_name[group]['bit']) else: # "'%s' not a Bugzilla group." raise error, catalog.msg(535, group) dict['groupset'] = groupset userid = self.bugzilla.add_user(dict) # "Perforce user '%s <%s>' added to Bugzilla as user %d." self.log(534, (p4user, email, userid)) return userid def add_replicator_user(self): email = self.config.replicator_address userid = self.bugzilla.userid_from_email(email) if userid: # "Perforce replicator user <%s> already exists in # Bugzilla as user %d." self.log(553, (email, userid)) return userid else: dict = {} dict['login_name'] = email dict['password'] = self.config.migrated_user_password dict['realname'] = "Perforce defect tracking integration" dict['groupset'] = 0L dict['disabledtext'] = "This user can access Bugzilla only as the P4DTI replicator process" userid = self.bugzilla.add_user(dict) # "Perforce replicator user <%s> added to Bugzilla as user %d." self.log(554, (email, userid)) return userid def new_issues_start(self): self.bugzilla.lock_tables() self.bugzilla.clear_processmails() def new_issues_end(self): self.bugzilla.unlock_tables() self.bugzilla.invoke_processmails() # new_issue_defaults(dict). Supply default values for the product, # component and version fields where possible (see [AG, 6.2]). def new_issue_defaults(self, dict): assert isinstance(dict, types.DictType) dict['product'] = dict.get('product', '') dict['component'] = dict.get('component', '') dict['version'] = dict.get('version', '') # Infer product if there's only one product in Bugzilla. products = self.bugzilla.products() if len(products) == 1 and dict['product'] == '': dict['product'] = products.keys()[0] if not dict.has_key('product'): # No product: can't infer anything else. return product_name = dict['product'] # Infer component if there's only one for this product. components = self.bugzilla.components_of_product(product_name) if len(components) == 1 and dict['component'] == '': dict['component'] = components.keys()[0] # Infer version if there's only one for this product. versions = self.bugzilla.versions_of_product(product_name) if len(versions) == 1 and dict['version'] == '': dict['version'] = versions[0] def new_issue(self, dict, jobname): # Only know how to deal with these fields at bug creation. for key, value in dict.items(): if (value != '' and value != 0 and not key in ['resolution', 'groupset', 'assigned_to', 'bug_file_loc', 'bug_severity', 'bug_status', 'op_sys', 'priority', 'rep_platform', 'target_milestone', 'qa_contact', 'longdesc', 'short_desc', 'product', 'component', 'version', 'reporter', 'creation_ts', 'delta_ts', ]): # "Can't create Bugzilla bug with field '%s'." raise error, catalog.msg(531, key) # must have non-empty summary if not dict.has_key('short_desc'): # "Can't create Bugzilla bug without short_desc field." raise error, catalog.msg(517) if dict['short_desc'] == '': # "Can't create Bugzilla bug with empty short_desc field." raise error, catalog.msg(518) # must have reporter if not dict.has_key('reporter'): # "Can't create Bugzilla bug without reporter field." raise error, catalog.msg(532) user = dict['reporter'] # this user has been produced by the lax user translator; it # could be 0 or the P4DTI user. The P4DTI user is allowed # here (think about migration, in which case the reporter may # be ancient history in Perforce). if user == 0: # "Can't create Bugzilla bug with reporter 0." raise error, catalog.msg(539) # Supply defaults for product, component and version if # possible. self.new_issue_defaults(dict) # Product must exist. products = self.bugzilla.products() if dict.get('product', '') == '': # "Can't create Bugzilla bug without product field." raise error, catalog.msg(519) product_name = dict['product'] if not products.has_key(product_name): # "Can't create Bugzilla bug for non-existent product '%s'." raise error, catalog.msg(520, product_name) # Get product record. product = products[product_name] # Component must exist. components = self.bugzilla.components_of_product(product_name) if len(components) == 0: # "Can't create Bugzilla bug for product '%s' with no # components." raise error, catalog.msg(521, product_name) if dict.get('component', '') == '': # "Can't create Bugzilla bug without component field." raise error, catalog.msg(522) if not components.has_key(dict['component']): # "Can't create Bugzilla bug: product '%s' has no component # '%s'." raise error, catalog.msg(523, (product_name, dict['component'])) component = components[dict['component']] # Version must exist. versions = self.bugzilla.versions_of_product(product_name) if len(versions) == 0: # "Can't create Bugzilla bug for product '%s' with no # versions." raise error, catalog.msg(524, product_name) if dict.get('version', '') == '': # "Can't create Bugzilla bug without version field." raise error, catalog.msg(525) version = dict['version'] if not version in versions: # "Can't create Bugzilla bug: product '%s' has no version # '%s'." raise error, catalog.msg(526, (product_name, version)) email = self.bugzilla.email_from_userid(user) # permissions for the reporter to create the bug. user_groupset = self.user_groupset(user) product_group = self.bugzilla.product_group(product_name) if product_group != None: if (user_groupset & product_group['bit']) == 0: # "User '%s' isn't in Bugzilla product group for product # '%s'; migrating bug anyway." self.log(527, (email, product_name)) # if groupset, check valid otherwise set to default (0) if dict.has_key('groupset'): groupset = dict['groupset'] if (groupset & self.valid_groupset_bits()) != groupset: # "Can't create Bugzilla bug with invalid groupset # '%s'." raise error, catalog.msg(528, groupset) else: if product_group != None: dict['groupset'] = product_group['bit'] else: dict['groupset'] = 0 if product['votestoconfirm'] != 0: default_bug_status = 'UNCONFIRMED' else: default_bug_status = 'NEW' if dict.has_key('bug_status'): bug_status = dict['bug_status'] # if the bug_status is not the default for this product, # check the user has the permissions. if (bug_status != default_bug_status and not self.groupset_has_named_group(user_groupset, "editbugs") and not self.groupset_has_named_group(user_groupset, "canconfirm")): # "User '%s' doesn't have permissions to create Bugzilla # bug for product '%s' with status '%s'; migrating bug # anyway." self.log(529, (email, product_name, bug_status)) else: bug_status = default_bug_status dict['bug_status'] = bug_status if bug_status != 'UNCONFIRMED': dict['everconfirmed'] = 1 else: dict['everconfirmed'] = 0 if not dict.has_key('resolution'): dict['resolution'] = '' if (dict['resolution'] == '' and bug_status in ['RESOLVED', 'VERIFIED', 'CLOSED']): # "Can't create Bugzilla bug with bug_status '%s' and no # resolution." raise error, catalog.msg(530, bug_status) # other defaults if dict.get('assigned_to', 0) == 0: dict['assigned_to'] = component['initialowner'] if not dict.has_key('bug_file_loc'): dict['bug_file_loc'] = '' if dict.get('bug_severity', '') == '': dict['bug_severity'] = 'normal' if dict.get('op_sys', '') == '': dict['op_sys'] = 'other' if dict.get('priority', '') == '': dict['priority'] = 'P2' if dict.get('rep_platform', '') == '': dict['rep_platform'] = 'Other' if dict.get('target_milestone', '') == '': dict['target_milestone'] = product['defaultmilestone'] if dict.get('qa_contact', 0) == 0: dict['qa_contact'] = component['initialqacontact'] if not dict.has_key('longdesc'): dict['longdesc'] = 'No initial comment.' bug_id = self.bugzilla.add_bug(dict, email) bug = self.issue(bug_id) # in future might want another jobname here. bug.make_p4dti_bug(jobname, created=1) return bug def replicate_changelist(self, change, client, date, description, status, user): dt_changelists = self.bugzilla.changelists(change) if len(dt_changelists) == 0: # no existing changelist; make a new one dt_changelist={} self.transform_changelist(dt_changelist, change, client, date, description, status, user) self.bugzilla.add_changelist(dt_changelist) return 1 else: # determine the changes changes = self.transform_changelist(dt_changelists[0], change, client, date, description, status, user) if changes: self.bugzilla.update_changelist(changes, change) return 1 else: return 0 def transform_changelist(self, dt_changelist, change, client, date, description, status, user): changes = {} changes['changelist'] = change changes['client'] = client changes['p4date'] = date changes['description'] = description # "(x and 1) or 0" gives the int value of the boolean x. changes['flags'] = ((status == 'submitted') and 1) or 0 changes['user'] = user for key, value in changes.items(): if (not dt_changelist.has_key(key) or dt_changelist[key] != value): dt_changelist[key] = value else: del changes[key] return changes # groups. group_by_name = {} group_by_bit = {} def init_group_tables(self): groups = self.bugzilla.groups() for group in groups: self.group_by_name[group['name']] = group self.group_by_bit[group['bit']] = group def groupset_has_named_group(self, groupset, group): if not self.group_by_name.has_key(group): self.init_group_tables() if not self.group_by_name.has_key(group): # "Bugzilla does not have a group called '%s'." raise error, catalog.msg(507, group) groupbit = self.group_by_name[group]['bit'] return (groupset & groupbit) == groupbit def valid_groupset_bits(self): valid = 0L if not self.group_by_bit: self.init_group_tables() for bit in self.group_by_bit.keys(): valid = valid + bit return valid def user_has_named_group(self, user, group): groupset = self.bugzilla.groupset_from_userid(user) return self.groupset_has_named_group(groupset, group) def user_groupset(self, user): return self.bugzilla.groupset_from_userid(user) def singleton_groupset(self, groupset): return groupset != 0 and (groupset & (groupset - 1)) == 0 # 7. TRANSLATORS # # These classes translate values of particular types between Bugzilla # and Perforce [GDR 2000-10-16, 7.5]. # 7.1. State translator # # This class translates bug statuses [GDR 2000-10-16, 7.5.2]. class status_translator(translator.translator): # A map from Bugzilla status name to Perforce status name. status_bz_to_p4 = {} # A map from Perforce status name to Bugzilla status name (the # reverse of the above map). status_p4_to_bz = {} def __init__(self, statuses): # Compute the maps. for bz_status, p4_status in statuses: assert isinstance(bz_status, types.StringType) assert isinstance(p4_status, types.StringType) self.status_bz_to_p4[bz_status] = p4_status self.status_p4_to_bz[p4_status] = bz_status def translate_dt_to_p4(self, bz_status, bz, p4, issue=None, job=None): assert isinstance(bz_status, types.StringType) if self.status_bz_to_p4.has_key(bz_status): return self.status_bz_to_p4[bz_status] else: # "No Perforce status corresponding to Bugzilla status # '%s'." raise error, catalog.msg(509, bz_status) def translate_p4_to_dt(self, p4_status, bz, p4, issue=None, job=None): assert isinstance(p4_status, types.StringType) if self.status_p4_to_bz.has_key(p4_status): return self.status_p4_to_bz[p4_status] else: # "No Bugzilla status corresponding to Perforce status # '%s'." raise error, catalog.msg(510, p4_status) # 7.2. Enumerated field translator # # This class translates values in enumerated fields. Because enumerated # fields in Bugzilla are mapped to select fields in Perforce, we have to # translate the value using the keyword translator [GDR 2000-10-16, # 7.5.2] so that it is valid in Perforce. class enum_translator(translator.translator): keyword_translator = None def __init__(self, keyword_translator): self.keyword_translator = keyword_translator def translate_dt_to_p4(self, bz_enum, bz = None, p4 = None, issue = None, job = None): assert isinstance(bz_enum, types.StringType) if bz_enum == '': return 'NONE' else: return self.keyword_translator.translate_dt_to_p4(bz_enum) def translate_p4_to_dt(self, p4_enum, bz = None, p4 = None, issue = None, job = None): if p4_enum == 'NONE': return '' else: return self.keyword_translator.translate_p4_to_dt(p4_enum) # 7.3. Date translator # # The date_translator class translates date fields between defect # trackers Bugzilla (0) and Perforce (1) [GDR 2000-10-16, 7.5.1]. # # Some Perforce dates are reported in the form 2000/01/01 00:00:00 # (e.g., dates in changeslists) and others are reported as seconds since # 1970-01-01 00:00:00 (e.g., dates in fixes). I don't know why this is, # but I have to deal with it by checking for both formats. # # MySQL datetime values are in the form 'YYYY-MM-DD hh:mm:ss'. # # Note that we deliberately prevent MySQLdb from using DateTime types # for datetime values (see job000193, configure_bugzilla.py). Maybe one # day that will change. class date_translator(translator.translator): p4_date_regexps = [ re.compile("^([0-9][0-9][0-9][0-9])/([0-9][0-9])/([0-9][0-9]) " "([0-9][0-9]):([0-9][0-9]):([0-9][0-9])$"), re.compile("^[0-9]+$") ] bz_date_regexp = re.compile( "^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9]) " "([0-9][0-9]):([0-9][0-9]):([0-9][0-9])$") def translate_dt_to_p4(self, bz_date, bz, p4, issue=None, job=None): assert isinstance(bz_date, types.StringType) assert isinstance(bz, dt_bugzilla) assert isinstance(p4, dt_interface.defect_tracker) assert issue == None or isinstance(issue, bugzilla_bug) match = self.bz_date_regexp.match(bz_date) if match: return ('%s/%s/%s %s:%s:%s' % (match.group(1), match.group(2), match.group(3), match.group(4), match.group(5), match.group(6))) else: return '' def translate_p4_to_dt(self, p4_date, bz, p4, issue=None, job=None): assert isinstance(p4_date, types.StringType) assert isinstance(bz, dt_bugzilla) assert isinstance(p4, dt_interface.defect_tracker) assert issue == None or isinstance(issue, bugzilla_bug) match = self.p4_date_regexps[0].match(p4_date) if match: return ('%s-%s-%s %s:%s:%s' % (match.group(1), match.group(2), match.group(3), match.group(4), match.group(5), match.group(6))) elif self.p4_date_regexps[1].match(p4_date): return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(int(p4_date))) else: return '' # becomes 0000-00-00 00:00:00 on insertion # 7.4. Timestamp translator # # The timestamp_translator class translates timestamp fields between # defect trackers Bugzilla (0) and Perforce (1). # # Some Perforce dates are reported in the form 2000/01/01 00:00:00 # (e.g., dates in changeslists) and others are reported as seconds since # 1970-01-01 00:00:00 (e.g., dates in fixes). I don't know why this is, # but I have to deal with it by checking for both formats. # # MySQL timestamps are YYYYMMDDhhmmss. # # If there's nothing in Perforce, translate to '', which will get set # to now() in MySQL. class timestamp_translator(translator.translator): p4_date_regexps = [ re.compile("^([0-9][0-9][0-9][0-9])/([0-9][0-9])/([0-9][0-9]) " "([0-9][0-9]):([0-9][0-9]):([0-9][0-9])$"), re.compile("^[0-9]+$") ] bz_timestamp_regexp = re.compile( "^([0-9][0-9][0-9][0-9])([0-9][0-9])([0-9][0-9])" "([0-9][0-9])([0-9][0-9])([0-9][0-9])$") def translate_dt_to_p4(self, bz_date, bz, p4, issue=None, job=None): assert isinstance(bz_date, types.StringType) assert isinstance(bz, dt_bugzilla) assert isinstance(p4, dt_interface.defect_tracker) assert issue == None or isinstance(issue, bugzilla_bug) match = self.bz_timestamp_regexp.match(bz_date) if match: return ('%s/%s/%s %s:%s:%s' % (match.group(1), match.group(2), match.group(3), match.group(4), match.group(5), match.group(6))) else: return '' def translate_p4_to_dt(self, p4_date, bz, p4, issue=None, job=None): assert isinstance(p4_date, types.StringType) assert isinstance(bz, dt_bugzilla) assert isinstance(p4, dt_interface.defect_tracker) assert issue == None or isinstance(issue, bugzilla_bug) match = self.p4_date_regexps[0].match(p4_date) if match: return ('%s%s%s%s%s%s' % (match.group(1), match.group(2), match.group(3), match.group(4), match.group(5), match.group(6))) elif self.p4_date_regexps[1].match(p4_date): return time.strftime("%Y%m%d%H%M%S", time.gmtime(int(p4_date))) else: return '' # 7.6. Text translator # # The text_translator class translates multi-line text fields between # defect trackers Bugzilla (0) and Perforce (1) [GDR 2000-10-16, 7.5.3]. class text_translator(translator.translator): # Transform Bugzilla text field contents to Perforce text field # contents by adding a newline. def translate_dt_to_p4(self, bz_string, bz, p4, issue=None, job=None): assert isinstance(bz_string, types.StringType) # Add final newline, unless the string is empty. if bz_string: bz_string = bz_string + '\n' return bz_string # Transform Perforce text field contents to Bugzilla text field # contents by removing a line ending. def translate_p4_to_dt(self, p4_string, bz, p4, issue=None, job=None): assert isinstance(p4_string, types.StringType) # Remove final newline (if any). if p4_string and p4_string[-1] == '\n': p4_string = p4_string[:-1] return p4_string # 7.7. Integer translator # # The int_translator class translates integer fields between defect # trackers Bugzilla (0) and Perforce (1). class int_translator(translator.translator): # Transform Bugzilla integer field contents to Perforce word field # contents by converting line endings. def translate_dt_to_p4(self, bz_int, bz, p4, issue=None, job=None): assert (isinstance(bz_int, types.IntType) or isinstance(bz_int, types.LongType)) s = str(bz_int) # Note that there's a difference between Python 1.5.2 and Python # 1.6 here, in whether str of a long ends in an L. if s[-1:] == 'L': s = s[:-1] return s # Transform Perforce word field contents to Bugzilla integer field # contents. def translate_p4_to_dt(self, p4_string, bz, p4, issue=None, job=None): assert isinstance(p4_string, types.StringType) try: if p4_string == '': return 0L else: return long(p4_string) except: # "Perforce field value '%s' could not be translated to a # number for replication to Bugzilla." raise error, catalog.msg(511, p4_string) # 7.7. User translator # # The user_translator class translates user fields between defect # trackers Bugzilla (0) and Perforce (1) [GDR 2000-10-16, 7.5.3]. # # A Perforce user field contains a Perforce user name (for example, # "nb"). The Perforce user record contains an e-mail address (for # example, "nb@ravenbrook.com"). # # A Bugzilla user field contains an integer (MySQL type 'mediumint'), # (for example, 3). The Bugzilla user record (MySQL table 'profiles') # contains an e-mail address (MySQL column 'login_name') (for example, # "nb@ravenbrook.com"). For some Bugzilla user fields # (e.g. qa_contact), the integer may be 0, meaning "None". # # To translate a user field, we find an identical e-mail address. # # If there is no such Perforce user, we just use the e-mail address, # because we can (in fact) put any string into a Perforce user field. # # If there is no such Bugzilla user, we check whether the Perforce user # field is in fact the e-mail address of a Bugzilla user (for example, # one that we put there because there wasn't a matching Perforce user). # If so, we use that Bugzilla user. # # Sometimes, a Perforce user field which cannot be translated into # Bugzilla is an error. For instance, if a Perforce user sets the # qa_contact field of a job to a nonsense value, we should catch that # and report it as an error. # # Sometimes, however, we should allow such values. For instance, when # translating the user field of a fix record or changelist: we should # not require _all_ past and present Perforce users to have Bugzilla # user records. In that case, we should translate to a default value. # For this purpose, the replicator has a Bugzilla user of its own. # # To distinguish between these two cases, we have a switch # 'allow_unknown'. If allow_unknown is 1, we use the default # translation. If allow_unknown is 0, we report an error. class user_translator(translator.user_translator): # A map from Bugzilla user ids to Perforce user names user_bz_to_p4 = {} # A map from Perforce user names to Bugzilla user ids user_p4_to_bz = {} # A map from Bugzilla user ids to (downcased) email addresses bz_id_to_email = None # A map from (downcased) email addresses to Bugzilla user ids bz_email_to_id = None # A map from (downcased) email addresses to Perforce user names p4_email_to_user = None # A map from Perforce user names to (downcased) email addresses p4_user_to_email = None # A map from Perforce user names to Perforce full names. p4_user_to_fullname = None # A map from Perforce user name to email address for users with # duplicate email addresses in Perforce. p4_duplicates = None # A map from Bugzilla user id to email address for users with # duplicate (downcased) email addresses in Bugzilla. bz_duplicates = None # A map from Perforce user name to email address for Perforce # users that can't be matched with users in Bugzilla. p4_unmatched = None # A map from Bugzilla user real name to email address for Bugzilla # users that can't be matched with users in Perforce. bz_unmatched = None # The Bugzilla P4DTI user email address (config.replicator_address) bugzilla_user = None # The Bugzilla P4DTI user id bugzilla_id = None # The Perforce P4DTI user name (config.p4_user) p4_user = None # A switch controlling whether this translator will translate # Perforce users without corresponding Bugzilla users into # the Bugzilla P4DTI user id. allow_unknown = 0 def __init__(self, bugzilla_user, p4_user, allow_unknown = 0): self.bugzilla_user = string.lower(bugzilla_user) self.p4_user = p4_user self.allow_unknown = allow_unknown # Deduce and record the mapping between Bugzilla userid and # Perforce username. def init_users(self, bz, p4): # Clear the maps. self.user_bz_to_p4 = {} self.user_p4_to_bz = {} self.bz_email_to_id = {} self.bz_id_to_email = {} self.p4_email_to_user = {} self.p4_user_to_email = {} self.p4_duplicates = {} self.bz_duplicates = {} self.p4_unmatched = {} self.bz_unmatched = {} self.p4_user_to_fullname = {} # Populate the Perforce-side maps. p4_users = p4.p4.run("users") for u in p4_users: email = string.lower(u['Email']) user = u['User'] self.p4_user_to_fullname[user] = u['FullName'] self.p4_user_to_email[user] = email if self.p4_email_to_user.has_key(email): matching_user = self.p4_email_to_user[email] # "Perforce users '%s' and '%s' both have email address # '%s' (when converted to lower case)." bz.log(541, (user, matching_user, email)) self.p4_duplicates[matching_user] = email self.p4_duplicates[user] = email else: self.p4_email_to_user[email] = user # Check the Perforce P4DTI user exists: if not self.p4_user_to_email.has_key(self.p4_user): # "Perforce P4DTI user '%s' is not a known Perforce user." raise error, catalog.msg(542, self.p4_user) p4_email = self.p4_user_to_email[self.p4_user] # Check that the Perforce P4DTI user has a unique email address: if self.p4_duplicates.has_key(self.p4_user): duplicate_users = [] for (user, email) in self.p4_duplicates.items(): if email == p4_email and user != self.p4_user: duplicate_users.append(user) # "Perforce P4DTI user '%s' has the same e-mail address # '%s' as these other Perforce users: %s." raise error, catalog.msg(543, (self.p4_user, p4_email, duplicate_users)) # Make a list of all the user ids matching the Bugzilla P4DTI user. bugzilla_ids = [] # Populate the Bugzilla-side maps. bz_users = bz.bugzilla.user_id_and_email_list() for (id, email) in bz_users: email = string.lower(email) self.bz_id_to_email[id] = email # Collect ids matching the Bugzilla P4DTI user if email == self.bugzilla_user: bugzilla_ids.append(id) if self.bz_email_to_id.has_key(email): matching_id = self.bz_email_to_id[email] bz_real_name = bz.bugzilla.real_name_from_userid(id) matching_real_name = bz.bugzilla.real_name_from_userid(matching_id) # "Bugzilla users '%s' and '%s' both have email address # '%s' (when converted to lower case)." bz.log(544, (bz_real_name, matching_real_name, email)) self.bz_duplicates[bz_real_name] = email self.bz_duplicates[matching_real_name] = email else: self.bz_email_to_id[email] = id # Check that the Bugzilla P4DTI user exists: if len(bugzilla_ids) == 0: # "Bugzilla P4DTI user '%s' is not a known Bugzilla user." raise error, catalog.msg(513, self.bugzilla_user) # Check that the Bugzilla P4DTI user is unique: if len(bugzilla_ids) > 1: # "Bugzilla P4DTI user e-mail address '%s' belongs to # several Bugzilla users: %s." raise error, catalog.msg(545, (self.bugzilla_user, bugzilla_ids)) # There can be only one. self.bugzilla_id = bugzilla_ids[0] # The Perforce-specific and Bugzilla-specific maps are now # complete. Note that the p4_user_to_email map and the # bz_id_to_email map may have duplicate values (in which case # the keys in the inverse maps are the first-seen # corresponding keys). # Populate the translation maps. # We could do this at the same time as one of the previous phases, # but IMO it's cleaner to separate it out like this. for (id, email) in self.bz_id_to_email.items(): if self.p4_email_to_user.has_key(email): p4_user = self.p4_email_to_user[email] # Already matched? if self.user_p4_to_bz.has_key(p4_user): matching_id = self.user_p4_to_bz[p4_user] bz_real_name = bz.bugzilla.real_name_from_userid(id) self.bz_unmatched[bz_real_name] = email # "Bugzilla user '%s' (e-mail address '%s') not # matched to any Perforce user, because Perforce # user '%s' already matched to Bugzilla user %d." bz.log(546, (bz_real_name, email, p4_user, matching_id)) else: self.user_bz_to_p4[id] = p4_user self.user_p4_to_bz[p4_user] = id # "Bugzilla user %d matched to Perforce user '%s' by # e-mail address '%s'." bz.log(547, (id, p4_user, email)) else: bz_real_name = bz.bugzilla.real_name_from_userid(id) self.bz_unmatched[bz_real_name] = email # "Bugzilla user '%s' (e-mail address '%s') not matched # to any Perforce user." bz.log(548, (bz_real_name, email)) # Identify unmatched Perforce users. for (user, email) in self.p4_user_to_email.items(): if not self.user_p4_to_bz.has_key(user): self.p4_unmatched[user] = email # "Perforce user '%s' (e-mail address '%s') not matched # to any Bugzilla user." bz.log(549, (user, email)) # Ensure that Bugzilla P4DTI user and Perforce P4DTI user # correspond. if self.user_bz_to_p4.has_key(self.bugzilla_id): # Bugzilla P4DTI user has P4 counterpart: p4_bugzilla_user = self.user_bz_to_p4[self.bugzilla_id] # But is it the p4_user? if (p4_bugzilla_user != self.p4_user): # "Bugzilla P4DTI user '%s' has e-mail address # matching Perforce user '%s', not Perforce P4DTI # user '%s'." raise error, catalog.msg(512, (self.bugzilla_user, p4_bugzilla_user, self.p4_user)) else: if self.user_p4_to_bz.has_key(self.p4_user): bz_user = self.user_p4_to_bz[self.p4_user] bz_email = self.bz_id_to_email[bz_user] # "Bugzilla P4DTI user '%s' does not have a matching # Perforce user. It should match the Perforce user # '%s' but that matches the Bugzilla user %d (e-mail # address '%s')." raise error, catalog.msg(550, (self.bugzilla_user, self.p4_user, bz_user, bz_email)) else: # "Bugzilla P4DTI user '%s' does not have a matching # Perforce user. It should match the Perforce user # '%s' (which has e-mail address '%s')." raise error, catalog.msg(551, (self.bugzilla_user, self.p4_user, p4_email)) # always translate 0 to 'None' and back again self.user_p4_to_bz['None'] = 0 self.user_bz_to_p4[0] = 'None' def unmatched_users(self, bz, p4): self.init_users(bz, p4) # "A user field containing one of these users will be translated # to the user's e-mail address in the corresponding Perforce job # field." bz_user_msg = catalog.msg(515) # "It will not be possible to use Perforce to assign bugs to # these users. Changes to jobs made by these users will be # ascribed in Bugzilla to the replicator user <%s>." p4_user_msg = catalog.msg(516, self.bugzilla_user) # "These Perforce users have duplicate e-mail addresses. They # may have been matched with the wrong Bugzilla user." duplicate_p4_user_msg = catalog.msg(536) # "These Bugzilla users have duplicate e-mail addresses (when # converted to lower case). They may have been matched with # the wrong Perforce user." duplicate_bz_user_msg = catalog.msg(552) return (self.bz_unmatched, self.p4_unmatched, bz_user_msg, p4_user_msg, self.bz_duplicates, self.p4_duplicates, duplicate_bz_user_msg, duplicate_p4_user_msg) keyword = translator.keyword_translator() def translate_p4_to_dt(self, p4_user, bz, p4, issue=None, job=None): if not self.user_p4_to_bz.has_key(p4_user): self.init_users(bz, p4) if self.user_p4_to_bz.has_key(p4_user): return self.user_p4_to_bz[p4_user] else: bz_email = self.keyword.translate_p4_to_dt(p4_user) if self.bz_email_to_id.has_key(bz_email): return self.bz_email_to_id[bz_email] elif self.allow_unknown: return self.bugzilla_id else: # "There is no Bugzilla user corresponding to Perforce # user '%s'." raise error, catalog.msg(514, p4_user) def translate_dt_to_p4(self, bz_user, bz, p4, issue=None, job=None): if not self.user_bz_to_p4.has_key(bz_user): self.init_users(bz, p4) if self.user_bz_to_p4.has_key(bz_user): return self.user_bz_to_p4[bz_user] else: bz_email = self.bz_id_to_email[bz_user] return self.keyword.translate_dt_to_p4(bz_email) # A. REFERENCES # # [GDR 2000-10-16] "Perforce Defect Tracking Integration Integrator's # Guide"; Gareth Rees; Ravenbrook Limited; 2000-10-16; # . # # [NB 2000-11-14a] "Bugzilla database schema"; Nick Barnes; Ravenbrook # Limited; 2000-11-14; # . # # [NB 2000-11-14b] "Bugzilla database schema extensions for integration # with Perforce"; Nick Barnes; Ravenbrook Limited; 2000-11-14; # . # # [NB 2000-11-14c] "Python interface to Bugzilla: design"; Nick Barnes; # Ravenbrook Limited; 2000-11-14; # . # # # B. DOCUMENT HISTORY # # 2000-12-05 NB Fixes for job job000089 and job000118. We update # bugs_activity and have a new table p4dti_bugs_activity which # duplicates bugs_activity rows added by this replicator. A complicated # select then identifies bugs which have been changed other than by the # replicator. Locking added. Fixes, filespecs, and changelists now # work. # # 2000-12-07 RB Changed call to create bugzilla object to pass explicit # parameters (see corresponding change in bugzilla.py there). # # 2000-12-13 NB Enforce allowable transitions. Fix signature of # bugzilla_fix.update. Pass logger through to SQL interface. # # 2000-12-15 NB Added verbosity control. # # 2001-01-11 NB Added translators for timestamps, enums, and ints. # Refined the user translator so that we catch more errors. Added a big # comment explaining the user translator. Changed the initialization # code, as now we get a DB connection rather than the parameters for # opening one. # # 2001-01-12 NB Fixed text translator (newlines are just \n). Moved # configuration of read-only and append-only fields to # configure_bugzilla.py. Stop added to bugs_activity for some fields. # # 2001-01-23 NB Fix something that changed in python 1.6 (str(long)). # user translator now has unmatched_users method. Removed duplicate # call to bugzilla.create_p4dti_tables(). # # 2001-01-26 NB Processmail support. # # 2001-02-08 NB Better checking. # # 2001-02-19 NB Moved keyword translation to p4.py, as it is specific to # Perforce but generic to defect trackers. # # 2002-02-23 NB Made error messages more consistent. # # 2001-03-02 RB Transferred copyright to Perforce under their license. # # 2001-03-12 GDR Use messages for errors and e-mails. # # 2001-03-13 GDR Removed action field from table p4dti_bugs and all # methods that use it (since conflict resolution is now always # immediate). Get translator class from translator, not replicator. # Get defect tracker classes from dt_interface, not replicator. # # 2001-03-15 GDR Get the configuration from the config module. # # 2001-03-21 GDR The setup_for_replication() method takes a jobname # argument. # # 2001-05-09 NB add_issue() allows us to add bugs to Bugzilla. # # 2001-06-21 NB Treat email addresses case-insensitively. job000337. # # 2001-06-25 NB Added has_key to a bug and fixed date translation of # empty dates. # # 2001-06-26 NB changed interface to changed_entities. # # 2001-06-26 NB Add bugzilla_bug.delete(), for deleting a bug. Needed # when creating a bug from a new Perforce job, if replicating fails # half-way. Also added argument to 'setup_for_replication()', to record # in the defect tracker that this issue was created by migration from # Perforce. Also changed changed_entities interface. # # 2001-06-27 NB all_issues and changed_entities can't call the same # underlying function. See job000340. # # 2001-06-27 NB remove second argument from setup_for_replication by # making new_issue set the issue up for replication (code moved from # replicator.py) and extracting the common code. # # 2001-07-03 NB Restored middle result of changed_entities. # # 2001-07-13 NB date translator should return '' if match fails, so we # can tell that it did. MySQL will then convert that to the zero date # anyway. # # 2001-07-16 GDR Call first_replication() to ensure there's a record in # the replications table. # # 2001-09-19 NB Bugzilla 2.14 (job000390): move some functionality to # bugzilla.py, which knows the Bugzilla version. # # 2001-10-02 GDR Report Perforce users with duplicate e-mail addresses. # # 2001-10-28 GDR Formatted as a document. # # 2001-11-01 NB Add disabled user check (job000124). # # 2001-11-01 NB Add poll_start and poll_end, for job000306. # # 2001-11-05 GDR New method new_issue_defaults. # # 2001-12-04 GDR New method supports. # # 2002-01-29 GDR User translator applies the keyword translator for # unknown users, in case they have spaces or other forbidden characters. # # 2002-02-01 GDR Setup a new bug for replication without checking # replicate_p, as specified in the IG. # # 2002-04-03 NB Translate bogus timestamps from Perforce into '', # which will become now() in the Bugzilla MySQL interface. # # 2002-04-03 NB Added better default behaviour in creating issue. # Handled user of 0 in qacontact field (and prohibited elsewhere). # job000494. # # 2002-04-03 NB Handle migration when numeric fields are being # replicated (the translator needs to handle '' and the new_issue # script needs to disregard zero fields). job000496. # # 2002-06-14 NB A big rewrite of the user translator, prompted by # job000533. # # 2003-05-22 NB Add_replicator_user method to add the replicator user. # Also update the can_change_field() logic and comments. # # # 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/dt_bugzilla.py#3 $