# dt_teamtrack.py -- defect tracking interface (TeamTrack). # Gareth Rees, Ravenbrook Limited, 2000-09-07. # # See "Replicator design" for the design of the # replicator; "Replicator classes in Python" # for the class organization of the replicator; "Replicator interface to # TeamTrack" for the design of # this module; "TeamTrack database schema extensions for integration with # Perforce" for the database schema # which this module depends on; and "Python interface to TeamTrack: design" # for the design of the teamtrack # module. # # Copyright 2000 Ravenbrook Limited. This document is provided "as is", # without any express or implied warranty. In no event will the authors # be held liable for any damages arising from the use of this document. # You may make and distribute copies and derivative works of this # document provided that (1) you do not charge a fee for this document or # for its distribution, and (2) you retain as they appear all copyright # and licence notices and document history entries, and (3) you append # descriptions of your modifications to the document history. import replicator import re import socket import string import teamtrack import time import types error = 'P4DTI TeamTrack interface error' # Field types in the VCACTIONS table. (See "TeamTrack database schema # extensions for integration with Perforce", 2.1.) vcactions_type_filespec = 1 vcactions_type_fix = 2 vcactions_type_changelist = 3 vcactions_type_config = 4 # sql_escape: string -> string. Return the input string, escaped for inclusion # in a SQL query by doubling single quotes. This works for Microsoft Access, # but I'm not sure that it's correct ANSI SQL. See job000031. def sql_escape(s): return string.replace(s, "'", "''") class teamtrack_case(replicator.defect_tracker_issue): dt = None # The defect tracker this case belongs to. case = None # The teamtrack_record object representing the case. def __init__(self, case, dt): self.case = case self.dt = dt def __getitem__(self, key): assert isinstance(key, types.StringType) return self.case[key] def __repr__(self): return repr(self.case) def __setitem__(self, key, value): assert isinstance(key, types.StringType) self.case[key] = value def action(self): return self['P4DTI_ACTION'] def add_filespec(self, filespec): filespec_record = self.dt.server.new_record(teamtrack.table['VCACTIONS']) filespec_record['TYPE'] = vcactions_type_filespec filespec_record['CHAR2'] = self.dt.sid filespec_record['CHAR1'] = self.rid() filespec_record['TABLEID'] = teamtrack.table['CASES'] filespec_record['RECID'] = self.case['ID'] filespec_record['TIME1'] = 0 filespec_record['TIME2'] = 0 filespec_record['FILENAME'] = repr({'filespec': filespec}) filespec_record.add() def add_fix(self, change, client, date, status, user): fix_record = self.dt.server.new_record(teamtrack.table['VCACTIONS']) fix_record['TYPE'] = vcactions_type_fix fix_record['CHAR2'] = self.dt.sid fix_record['CHAR1'] = self.rid() fix_record['TABLEID'] = teamtrack.table['CASES'] fix_record['RECID'] = self.case['ID'] fix_record['TIME2'] = 0 fix = self.dt.config['fix-class'](fix_record, self) fix.transform_from_p4(change, client, date, status, user) fix.add() def filespecs(self): query = "TS_TYPE=%d AND TS_RECID=%s" % (vcactions_type_filespec, self.id()) filespecs = [] for filespec in self.dt.server.query(teamtrack.table['VCACTIONS'], query): filespecs.append(self.dt.config['filespec-class'](filespec, self)) return filespecs # find_transition(old_state, new_state). Given an issue and the old and # new states, find a transition that corresponds to this state change, or # return None if there is no such transition. def find_transition(self, old_state, new_state): assert isinstance(old_state, types.IntType) assert isinstance(new_state, types.IntType) project = self['PROJECTID'] ss = (old_state, new_state) if not (self.dt.project_to_states_to_transition.has_key(project) and self.dt.project_to_states_to_transition[project].has_key(ss)): # The transitions may have changed since we last looked in the # database, so refresh our cache. self.dt.read_transitions() if (self.dt.project_to_states_to_transition.has_key(project) and self.dt.project_to_states_to_transition[project].has_key(ss)): return self.dt.project_to_states_to_transition[project][ss] else: # No appropriate transition found. return None def fixes(self): query = "TS_TYPE=%d AND TS_RECID=%s" % (vcactions_type_fix, self.id()) fixes = [] for fix in self.dt.server.query(teamtrack.table['VCACTIONS'], query): fixes.append(self.dt.config['fix-class'](fix, self)) return fixes def id(self): return str(self['ID']) def readable_name(self): readable_name = self['P4DTI_JOBNAME'] if readable_name: return readable_name elif self.dt.type_id_to_prefix.has_key(self['ISSUETYPE']): # For Mahi we're using only the ISSUETYPE and ISSUEID, even # though this is theoretically unsafe. GDR 2000-10-31. # There ought to be a flag in the configuration that selects # between the safe and nice behaviours. GDR 2000-10-31. return ('%s%s' % (self.dt.type_id_to_prefix[self['ISSUETYPE']], self['ISSUEID'])) else: return '%s' % (self['ISSUEID'],) def rid(self): return self['P4DTI_RID'] def setup_for_replication(self): self['P4DTI_RID'] = self.dt.rid self['P4DTI_SID'] = self.dt.sid self['P4DTI_JOBNAME'] = self.readable_name() self['P4DTI_ACTION'] = 'replicate' self.case.update() def update(self, user, changes = {}): assert isinstance(user, types.IntType) assert isinstance(changes, types.DictType) # Work out a transition based on the old case state and the new case # state, if the state changed. Otherwise, update the case by using # transition 0 (this is a secret feature of the transition function - # see John McGinley's e-mail). transition = 0 if changes.has_key('STATE') and changes['STATE'] != self['STATE']: transition = self.find_transition(self['STATE'], changes['STATE']) if not transition: # Don't change the state except through a transition. GDR # 2000-10-27. raise error, ("No transition from state '%s' to state '%s'." % (self.dt.state_id_to_name[self['STATE']], self.dt.state_id_to_name[changes['STATE']])) for key, value in changes.items(): self[key] = value user = self.dt.user_id_to_name[user] if transition: self.dt.log("-- Transition: %d; User: %s", (transition, user)) self.case.transition(user, transition) def update_action(self, action): assert action in ['keep','discard','replicate','wait'] if self['P4DTI_ACTION'] != action: self['P4DTI_ACTION'] = action self.case.update() class teamtrack_fix(replicator.defect_tracker_fix): case = None # The TeamTrack case to which the fix refers. fix = None # The teamtrack_record object representing the fix record. data = { 'status': '', 'client': '' } # The data that goes in the # TS_FILENAME field. def __init__(self, fix, case): assert isinstance(case, teamtrack_case) self.fix = fix self.case = case if fix['FILENAME']: self.data = eval(fix['FILENAME']) def __getitem__(self, key): 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 if key == 'FILENAME': self.data = eval(value) def add(self): self.fix.add() def change(self): return self['INFO1'] def delete(self): self.case.dt.server.delete_record(teamtrack.table['VCACTIONS'], self.fix['ID']) def status(self): return self.data['status'] def transform_from_p4(self, change, client, date, status, user): assert isinstance(change, types.IntType) assert isinstance(client, types.StringType) assert isinstance(date, types.IntType) assert isinstance(status, types.StringType) assert isinstance(user, types.IntType) self['INFO1'] = change self['TIME1'] = date self['AUTHOR1'] = user self['FILENAME'] = repr({ 'status': status, 'client': client }) def update(self, change, client, date, status, user): self.transform_from_p4(change, client, date, status, user) self.fix.update() class teamtrack_filespec(replicator.defect_tracker_filespec): case = None # The TeamTrack case to which the filespec refers. filespec = None # The teamtrack_record object representing the filespec. data = { 'filespec': '' } # The data that goes in the TS_FILENAME field. def __init__(self, filespec, case): self.filespec = filespec self.case = case if self['FILENAME']: self.data = eval(self['FILENAME']) def __getitem__(self, key): assert isinstance(key, types.StringType) return self.filespec[key] def __setitem__(self, key, value): assert isinstance(key, types.StringType) self.filespec[key] = value if key == 'FILENAME': data = eval(value) def delete(self): self.case.dt.server.delete_record(teamtrack.table['VCACTIONS'], self.filespec['ID']) def name(self): return self.data['filespec'] # The TeamTrack class implements a generic interface between the replicator and # TeamShare's "TeamTrack" defect tracker. Some configuration can be done by # passing a configuration hash to the constructor; for more advanced # configruation you should subclass this and replace some of the methods. The # configuration assumes that the teamTrack server is on the same host as the # replicator. class dt_teamtrack(replicator.defect_tracker): config = { 'server' : socket.gethostname(), 'user' : None, 'userid' : None, 'password' : '', 'case-class' : teamtrack_case, 'fix-class' : teamtrack_fix, 'filespec-class' : teamtrack_filespec, 'p4-server-description' : 'Perforce server', 'state-pairs' : [], 'changelist-url' : None, } rid = None server = None sid = None # A map from auxiliary table ID to the ID of the entities in that table to # the name for that entity. For example, if entity 7 in the PROJECTS table # (table 8) has the name "Image Builder", then table_to_id_to_name[8][7] == # "Image Builder". table_to_id_to_name = { } # A map from auxiliary table ID to the ID of the entities in that table to # the name for that entity. For example, if entity 7 in the PROJECTS table # (table 8) has the name "Image Builder", then # table_to_name_to_id[8]["Image Builder"] == 7. table_to_name_to_id = { } # A map from field name to selection name to the id for that selection. # For example, if selection called "Foo" in field "PROJECTS" has id 7, then # field_to_selection_to_id['PROJECTS']['Foo'] == 7. field_to_selection_to_id = { } # A map from TeamTrack project to a map from lowercased state name to state # id. So for example, if project 7 has a state called "Open" with id 25, # then project_to_name_to_state[7]['open'] == 25. The reason why the state # names are lowercase here is because they are lowercase in Perforce for # usability, to avoid having two states which differ only in case. project_to_name_to_state = { } # A map from project and a pair of states to a transition that is available # to cases in that project and which transitions between the states. For # example, if transition 27 is available in project 5 and transitions # between state 6 and state 7, then # project_to_states_to_transition[5][(6,7)] == 27. project_to_states_to_transition = { } # A map from selection id to name. selection_id_to_name = { } # A map from TeamTrack state id to state name. state_id_to_name = { } # A map from the type of an issue to the prefix for that type (e.g., "BUG", # "ENH"). type_id_to_prefix = { } # A map from TeamTrack user id (the ID field in the USERS table) to their # user name. user_id_to_name = { } # A map from TeamTrack user name to their user id. user_name_to_id = { } # A map from TeamTrack user id to their e-mail address. user_id_to_email = { } # A map from TeamTrack user's e-mail address to their user id. user_email_to_id = { } def __init__(self, rid, sid, config = {}): replicator.defect_tracker.__init__(self, rid, sid, config) if not self.config['user']: self.config['user'] = 'P4DTI-%s' % self.rid self.server = teamtrack.connect(self.config['user'], self.config['password'], self.config['server']) self.read_types() self.read_users() # Get the userid corresponding to the replicator's userid. This will # be used in queries to ignore records changed most recently by the # replicator. user = self.server.query(teamtrack.table['USERS'], "TS_LOGINID = '%s'" % sql_escape(self.config['user'])) if len(user) != 1: raise error, ("No login id in TeamTrack's USERS table corresponds " "to replicator's login id '%s'." % self.config['user']) self.config['userid'] = user[0]['ID'] def all_issues(self): query = ("(TS_P4DTI_RID='%s' OR TS_P4DTI_RID='' OR " "TS_P4DTI_RID IS NULL)" % self.rid) cases = [] for c in self.server.query(teamtrack.table['CASES'], query): cases.append(self.config['case-class'](c, self)) return cases def changed_entities(self): # Get the last change record that was dealt with. query = ("TS_TYPE=%d AND TS_CHAR1='%s' AND TS_CHAR2='LAST_CHANGE'" % (vcactions_type_config, self.rid)) last_change = self.server.query(teamtrack.table['VCACTIONS'], query) if not last_change: raise error, "No LAST_CHANGE record for this replicator" # Get the list of changes to cases that haven't been dealt with yet. # Ignore changes made by the replicator: I believe that TeamTrack # record changes made using the TeamShare API as due to userid 0, but I # check for self.config['userid'] just to be sure. last_change_id = last_change[0]['INFO1'] query = ("TS_TABLEID = %d AND TS_ID > %d AND TS_USERID <> %d " "AND TS_USERID <> 0" % (teamtrack.table['CASES'], last_change_id, self.config['userid'])) changes = self.server.query(teamtrack.table['CHANGES'], query) # Work out the set of changed cases (since a changed case may appear # several times in the CHANGES table but we don't want to replicate it # more than once) and the last change id. case_ids = {} for c in changes: case_ids[c['CASEID']] = 1 if c['ID'] > last_change_id: last_change_id = c['ID'] # Get the changed cases. changed_cases = [] if case_ids: # The IS NULL condition is there because the TeamShare API doesn't # reliably set a NULL field to the empty string when you assign the # empty string to the field and update the record. See e-mail to # Larry Fish, 2000-09-19. query = ("(TS_P4DTI_RID='%s' OR TS_P4DTI_RID='' OR " "TS_P4DTI_RID IS NULL) AND TS_ID IN (%s)" % (self.rid, repr(case_ids.keys())[1:-1])) for c in self.server.query(teamtrack.table['CASES'], query): changed_cases.append(self.config['case-class'](c, self)) if last_change[0]['INFO1'] != last_change_id: last_change[0]['INFO1'] = last_change_id # Note that there are no changed changelists. return changed_cases, [], last_change[0] else: # There were no new changes, so there must be no changed cases. # BUT this isn't correct. There might be changes to fixes and # filespecs that don't show up as changes to cases. So I've commented # out the next line. GDR 2000-10-24. # assert changed_cases == [] # Note that there are no changed changelists. return changed_cases, [], None def mark_changes_done(self, last_change): if last_change: last_change.update() def init(self): # Check that the TeamTrack database version is supported. supported_dbver = 23 system_info = self.server.read_record(teamtrack.table['SYSTEMINFO'], 1) if system_info['DBVER'] < supported_dbver: raise error, ('TeamTrack database version %d not supported ' 'by P4DTI. Minimum supported version is %d.' % (system_info['DBVER'], supported_dbver)) # Fields to add to the TS_CASES table. new_fields = [ { 'name': 'P4DTI_RID', 'type': teamtrack.field_type['TEXT'], 'length': 32, 'attributes': 1, # Fixed-width text. 'description': "P4DTI replicator identifier", 'value': '' }, { 'name': 'P4DTI_SID', 'type': teamtrack.field_type['TEXT'], 'length': 32, 'attributes': 1, # Fixed-width text. 'description': "P4DTI Perforce server identifier", 'value': '' }, { 'name': 'P4DTI_JOBNAME', 'type': teamtrack.field_type['TEXT'], 'length': 0, # Arbitrarily long. 'attributes': 0, # "Memo" = variable-width. 'description': "P4DTI Perforce jobname", 'value': '' }, { 'name': 'P4DTI_ACTION', 'type': teamtrack.field_type['TEXT'], 'length': 32, 'attributes': 1, # Fixed-width text. 'description': "P4DTI action", 'value': 'replicate' }, ] # Make a TS_CASES record so we can see if the new fields are already # present. case = self.server.new_record(teamtrack.table['CASES']) # Add each new field if not present. added_fields = [] for new_field in new_fields: if not case.has_key(new_field['name']): self.log("Installing field '%s' in the TS_CASES table.", new_field['name']) f = self.server.new_record(teamtrack.table['FIELDS']) f['TABLEID'] = teamtrack.table['CASES'] f['NAME'] = new_field['description'] f['DBNAME'] = new_field['name'] f['FLDTYPE'] = new_field['type'] f['LEN'] = new_field['length'] f['ATTRIBUTES'] = new_field['attributes'] f['STATUS'] = 0 # Active, not deleted. f['PROPERTY'] = 1 # Not editable. f['DESCRIPTION'] = new_field['description'] f.add_field() added_fields.append(new_field) if added_fields: # Previous installation was not up to date. Put default values in # the new fields. cases = self.server.query(teamtrack.table['CASES'], '') for case in cases: for added_field in added_fields: case[added_field['name']] = added_field['value'] case.update() if len(added_fields) != len(new_fields): self.log("Partially installed the new fields in the TS_CASES " "table. Previous installation was not up to date.") else: self.log("Installed all new fields in the TS_CASES table.") # config_params gives the values that should appear in the Replicator's # configuration parameters table. Each entry in the config_params list # is a 4-tuple ( parameter name, field name, field value, force # update?) See the TeamTrack schema extensions document for the # meaning of these configuration parameters. # Look up the LASTID field in the TABLES table for the CHANGES table; # this is the highest value for the ID used in the CHANGES table. # We'll use this for the initial value of the LAST_CHANGE parameter # (unless there's a LAST_CHANGE parameter there already). See # job000047 and the TeamTrack schema documentation. last_change = self.server.query(teamtrack.table['TABLES'], 'TS_ID = %d' % teamtrack.table['CHANGES'])[0]['LASTID'] # Build a string of status values separated by / for the STATUS_VALUES # keyword. status_values = string.join(map(lambda p: p[1], self.config['state-pairs']),'/') config_params = [ ( 'LAST_CHANGE', 'INFO1', last_change, 0 ), ( 'SERVER', 'FILENAME', repr({ 'sid': self.sid, 'description': self.config['p4-server-description']}), 1 ), ( 'STATUS_VALUES', 'FILENAME', repr({ 'sid': self.sid, 'description': status_values }), 1 ), ( 'CHANGELIST_URL', 'FILENAME', repr({ 'sid': self.sid, 'description': self.config['changelist-url']}), 1 ), ] # Get all the configuration parameters for this replicator; make a hash # by parameter name. query = ("TS_TYPE=%d AND TS_CHAR1='%s'" % (vcactions_type_config, self.rid)) params = {} for p in self.server.query(teamtrack.table['VCACTIONS'], query): params[p['CHAR2']] = p # Now add or update. for name, field, value, force_p in config_params: if not params.has_key(name): r = self.server.new_record(teamtrack.table['VCACTIONS']) r['TYPE'] = vcactions_type_config r['CHAR1'] = self.rid r['CHAR2'] = name r[field] = value r.add() self.log("Put '%s' parameter in replicator configuration " "with value '%s'", (name, repr(value))) elif force_p and params[name][field] != value: params[name][field] = value params[name].update() self.log("Updated '%s' parameter in replicator configuration " "to have value '%s'", (name, repr(value))) def issue(self, case_id): assert isinstance(case_id, types.StringType) try: case = self.server.read_record(teamtrack.table['CASES'], int(case_id)) return self.config['case-class'](case, self) except ValueError: # case_id was not a number and int() failed. return None except teamtrack.tsapi_error: # No such issue. return None # read_auxiliary_table(table). Record mappings between id and name # for a given auxiliary table (the tables PROJECTS, COMPANIES, PRODUCTS, # SERVICEAGREEMENTS are suitable for this method). def read_auxiliary_table(self, table): assert isinstance(table, types.IntType) self.log("Reading table %d.", (table,)) records = self.server.query(table, '') self.table_to_id_to_name[table] = { } self.table_to_name_to_id[table] = { } for r in records: self.table_to_id_to_name[table][r['ID']] = r['NAME'] if self.table_to_name_to_id[table].has_key(r['NAME']): self.log("Warning: table '%s' has two entries called '%s'.", (table_name, r['NAME'])) self.table_to_name_to_id[table][r['NAME']] = r['ID'] # read_selections(). Record mappings between selection name and id, so # that we can transform single-select fields. def read_selections(self): self.log("Reading FIELDS and SELECTIONS tables.") fields = self.server.query(teamtrack.table['FIELDS'],'') selections = self.server.query(teamtrack.table['SELECTIONS'],'') # sn_map is a map from selection id to selection name. sn_map = {} # From TeamTrack to p4 we need to map selection id to selection name. for s in selections: sn_map[s['ID']] = s['NAME'] # fn_map is a map from field id to field name, for fields in the # CASES table. fn_map = {} for f in fields: if f['TABLEID'] == teamtrack.table['CASES']: # Normalise the case of the database field name so that we can # rely on it being uppercase throughout the code. See defect # 14 of "Alpha test report for Quokka, 2000-11-01. GDR # 2000-11-01. fn_map[f['ID']] = string.upper(f['DBNAME']) # From p4 to TeamTrack we have selection name and field name and we # need selection id. fns_map = {} for s in selections: if fn_map.has_key(s['FLDID']): field_name = fn_map[s['FLDID']] if not fns_map.has_key(field_name): fns_map[field_name] = {} fns_map[field_name][s['NAME']] = s['ID'] # Store these maps for later use. self.field_to_selection_to_id = fns_map self.selection_id_to_name = sn_map # Determine a mapping from project id and transition name to the transition # id. Determine a mapping from project id and state name to state id. def read_states(self): self.log("Reading PROJECTS and STATES tables.") # Get all the projects and states from the TeamTrack database. projects = self.server.query(teamtrack.table['PROJECTS'], '') states = self.server.query(teamtrack.table['STATES'], '') # sn_map is a map from state id to state name. sn_map = { } for s in states: # Normalize the case of the state name. See "Case of state names" # design decisions [RB 2000-11-28]. sn_map[s['ID']] = string.lower(s['NAME']) # pns_map is a map from project id and lower-cased state name to the # state id corresponding to that state in that project. pns_map = { } for p in projects: pid = p['ID'] if not pns_map.has_key(pid): pns_map[pid] = {} for s in self.server.read_state_list(pid, 1): # Normalize the case of the state name. See "Case of state # names" design decisions [RB 2000-11-28]. pns_map[pid][string.lower(s['NAME'])] = s['ID'] # Remember these maps for use later. self.project_to_name_to_state = pns_map self.state_id_to_name = sn_map # Determine a mapping from project id and transition name to the transition # id. Determine a mapping from project id and state name to state id. def read_transitions(self): self.log("Reading PROJECTS table to discover available transitions.") # Get all the projects and states from the TeamTrack database. projects = self.server.query(teamtrack.table['PROJECTS'], '') # psst_map is a map from project id and a pair of state ids to the # transition id in that project that takes a case from one state to # the other. psst_map = { } for p in projects: pid = p['ID'] if not psst_map.has_key(pid): psst_map[pid] = {} for t in self.server.read_transition_list(pid): psst_map[pid][(t['OLDSTATEID'], t['NEWSTATEID'])] = t['ID'] # Remember this map for use when choosing transitions. self.project_to_states_to_transition = psst_map # read_types(). Record the mapping between issue type and the prefix # for that type. def read_types(self): self.log("Reading SELECTIONS table to find type prefixes.") types = self.server.query(teamtrack.table['SELECTIONS'], '') for t in types: self.type_id_to_prefix[t['ID']] = t['PREFIX'] # read_users(). Record the mapping between userid and username; and # between userid and e-mail address (we'll use the latter to map Perforce # users to TeamTrack users under the assumption that they have the same # e-mail address in both systems). def read_users(self): self.log("Reading USERS table.") users = self.server.query(teamtrack.table['USERS'], '') for u in users: self.user_name_to_id[u['LOGINID']] = u['ID'] self.user_id_to_name[u['ID']] = u['LOGINID'] self.user_email_to_id[u['EMAIL']] = u['ID'] self.user_id_to_email[u['ID']] = u['EMAIL'] def replicate_changelist(self, change, client, date, description, status, user): query = ("TS_TYPE=%d AND TS_CHAR1='%s' AND TS_INFO1=%d" % (vcactions_type_changelist, self.rid, change)) changelists = self.server.query(teamtrack.table['VCACTIONS'], query) if len(changelists) == 0: changelist = self.server.new_record(teamtrack.table['VCACTIONS']) self.translate_changelist(changelist, change, client, date, description, status, user) changelist.add() return 1 elif self.translate_changelist(changelists[0], change, client, date, description, status, user): changelists[0].update() return 1 else: return 0 # translate_changelist(tt_changelist, change, client, date, description, # status, user). Return the changes that were made to tt_changelist. def translate_changelist(self, tt_changelist, change, client, date, description, status, user): assert isinstance(change, types.IntType) assert isinstance(client, types.StringType) assert isinstance(date, types.IntType) assert isinstance(description, types.StringType) assert isinstance(status, types.StringType) assert isinstance(user, types.IntType) changes = {} changes['TYPE'] = vcactions_type_changelist changes['CHAR1'] = self.rid changes['CHAR2'] = self.sid changes['INFO1'] = change changes['AUTHOR1'] = user changes['INFO2'] = (status == 'submitted') changes['TIME1'] = date changes['FILENAME'] = repr({'description': description, 'client': client }) for key, value in changes.items(): if tt_changelist[key] != value: tt_changelist[key] = value else: del changes[key] return changes # The date_translator class translates date fields between defect trackers # TeamTrack (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. class date_translator(replicator.translator): readable_date_re = 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])$") seconds_date_re = re.compile("^[0-9]+$") def translate_0_to_1(self, tt_date, tt, p4, case = None, job = None): assert isinstance(tt_date, types.IntType) assert isinstance(tt, dt_teamtrack) assert isinstance(p4, replicator.defect_tracker) assert case == None or isinstance(case, teamtrack_case) # Empty date fields in TeamTrack appear to be represented by -2. See # job000146. if tt_date < 0: return '' else: return time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(tt_date)) def translate_1_to_0(self, p4_date, tt, p4, case = None, job = None): assert isinstance(p4_date, types.StringType) assert isinstance(tt, dt_teamtrack) assert isinstance(p4, replicator.defect_tracker) assert case == None or isinstance(case, teamtrack_case) # Empty date fields in TeamTrack appear to be represented by -2. See # job000146. if p4_date == '': return -2 match = self.readable_date_re.match(p4_date) if match: # Note that months are 1-12 in Python, unlike in C. return int(time.mktime((int(match.group(1)), int(match.group(2)), int(match.group(3)), int(match.group(4)), int(match.group(5)), int(match.group(6)), 0, 0, 0))) elif self.seconds_date_re.match(p4_date): return int(p4_date) else: raise error, ("Unexpected date format from Perforce: '%s'" % p4_date) # The auxiliary_translator class translates fields that cross-reference an # auxiliary table (e.g. PROJECTS) between defect trackers TeamTrack (0) and # Perforce (1). class auxiliary_translator(replicator.translator): table_name = None table = None def __init__(self, table_name): if not teamtrack.table.has_key(table_name): raise error, ("No such table: %s" % table_name) self.table_name = table_name self.table = teamtrack.table[table_name] def translate_0_to_1(self, tt_value, tt, p4, case = None, job = None): assert isinstance(tt_value, types.IntType) assert isinstance(tt, dt_teamtrack) assert isinstance(p4, replicator.defect_tracker) assert case == None or isinstance(case, teamtrack_case) if tt_value == 0: return '(None)' if not (tt.table_to_id_to_name.has_key(self.table) and tt.table_to_id_to_name[self.table].has_key(tt_value)): # The entity might have been added since we last looked in the # database, so refresh our cache. tt.read_auxiliary_table(self.table) if (tt.table_to_id_to_name.has_key(self.table) and tt.table_to_id_to_name[self.table].has_key(tt_value)): return tt.table_to_id_to_name[self.table][tt_value] else: raise error, ("No TeamTrack entity in table '%s' with id %d." % (self.table_name, tt_selection)) def translate_1_to_0(self, p4_value, tt, p4, case = None, job = None): assert isinstance(p4_value, types.StringType) assert isinstance(tt, dt_teamtrack) assert isinstance(p4, replicator.defect_tracker) assert case == None or isinstance(case, teamtrack_case) if p4_value == '(None)' or p4_value == '': return 0 if not (tt.table_to_name_to_id.has_key(self.table) and tt.table_to_name_to_id[self.table].has_key(p4_value)): # The entity might have been added since we last looked in the # database, so refresh our cache. tt.read_auxiliary_table(self.table) if (tt.table_to_name_to_id.has_key(self.table) and tt.table_to_name_to_id[self.table].has_key(p4_value)): return tt.table_to_name_to_id[self.table][p4_value] else: raise error, ("No TeamTrack entity in table '%s' with name '%s'." % (self.table_name, p4_value)) # translate_keyword_tt_to_p4: string -> string. Translates a TeamTrack name to # a Perforce keyword. TeamTrack names can have arbitrary characters in them, # but Perforce keywords can't have spaces or slashes and possibly can't have # various other characters in them, so we'll play safe and translate certain # non-alphanumeric characters to %xx (xx being the hexadecimal character code # for the offending character). In order to make the translated keywords more # readable, we translate space to underscore. # # Note that this map is reversible so that we can translate keywords in the # other direction (see translate_keyword_p4_to_tt below). # # These two functions are provided separately because they may be used by # several translators. def translate_keyword_tt_to_p4(tt_keyword): assert isinstance(tt_keyword, types.StringType) def translate_match(match): if match.group(0) == ' ': return '_' else: return '%%%02x' % ord(match.group(0)) return re.sub('[^\w(),.?!-]', translate_match, tt_keyword) # translate_keyword_p4_to_tt: string -> string. Translates a Perforce keyword # to a TeamTrack name. Reverse of the translation above. def translate_keyword_p4_to_tt(p4_keyword): assert isinstance(p4_keyword, types.StringType) def translate_match(match): if match.group(0) == '_': return ' ' else: return chr(string.atoi(match.group(1), 0x10)) return re.sub('%([\da-fA-F]{2})|_', translate_match, p4_keyword) # The single_select_translator class translates single select fields between # defect trackers TeamTrack (0) and Perforce (1). class single_select_translator(replicator.translator): # The field that this translator translates. field = None def __init__(self, field): self.field = field def translate_0_to_1(self, tt_selection, tt, p4, case = None, job = None): assert isinstance(tt_selection, types.IntType) assert isinstance(tt, dt_teamtrack) assert isinstance(p4, replicator.defect_tracker) assert case == None or isinstance(case, teamtrack_case) if not (tt.selection_id_to_name.has_key(tt_selection)): # The selection might have been added since we last looked in the # database, so refresh our cache. tt.read_selections() if (tt.selection_id_to_name.has_key(tt_selection)): # Selections are 'word's in Perforce and arbitrary strings in # TeamTrack, so translate it. return translate_keyword_tt_to_p4(tt.selection_id_to_name[tt_selection]) else: raise error, ("No TeamTrack selection name for selection id '%d'." % tt_selection) def translate_1_to_0(self, p4_selection, tt, p4, case = None, job = None): assert isinstance(p4_selection, types.StringType) assert isinstance(tt, dt_teamtrack) assert isinstance(p4, replicator.defect_tracker) assert case == None or isinstance(case, teamtrack_case) # Selections are 'word's in Perforce and arbitrary strings in # TeamTrack, so translate it. tt_selection = translate_keyword_p4_to_tt(p4_selection) if p4_selection == '(None)' or p4_selection == '': return 0 if not (tt.field_to_selection_to_id.has_key(self.field) and tt.field_to_selection_to_id[self.field].has_key(tt_selection)): # The selection might have been added since we last looked in the # database, so refresh our cache. tt.read_selections() if (tt.field_to_selection_to_id.has_key(self.field) and tt.field_to_selection_to_id[self.field].has_key(tt_selection)): return tt.field_to_selection_to_id[self.field][tt_selection] else: raise error, ("No TeamTrack selection for field '%s' " "corresponding to Perforce selection '%s'." % (self.field, p4_selection)) # The state_translator class translates issue statuses between defcet trackers # TeamTrack (0) and Perforce (1). class state_translator(replicator.translator): # A map from TeamTrack state name to Perforce state name. state_tt_to_p4 = { } # A map from Perforce state name to TeamTrack state name (the reverse of # the above map). state_p4_to_tt = { } # The states argument is a list of pairs (TeamTrack state name, Perforce # state name). def __init__(self, states): # Compute the maps. for tt_state, p4_state in states: assert isinstance(tt_state, types.StringType) assert isinstance(p4_state, types.StringType) self.state_tt_to_p4[tt_state] = p4_state self.state_p4_to_tt[p4_state] = tt_state def translate_0_to_1(self, tt_state, tt, p4, case = None, job = None): assert isinstance(tt_state, types.IntType) assert isinstance(tt, dt_teamtrack) assert isinstance(p4, replicator.defect_tracker) assert case == None or isinstance(case, teamtrack_case) if not tt.state_id_to_name.has_key(tt_state): # The workflows may have changed since we last looked in the # database, so refresh our cache. tt.read_states() if tt.state_id_to_name.has_key(tt_state): tt_name = tt.state_id_to_name[tt_state] if self.state_tt_to_p4.has_key(tt_name): return self.state_tt_to_p4[tt_name] else: raise error, ("No Perforce state in corresponding to " "TeamTrack state '%s'" % tt_name) else: raise error, ("No state name for TeamTrack state %d" % tt_state) def translate_1_to_0(self, p4_state, tt, p4, case, job = None): assert isinstance(p4_state, types.StringType) assert isinstance(tt, dt_teamtrack) assert isinstance(p4, replicator.defect_tracker) assert isinstance(case, teamtrack_case) if not self.state_p4_to_tt.has_key(p4_state): raise error, ("Perforce state '%s' is unknown." % p4_state) tt_state = self.state_p4_to_tt[p4_state] project = case['PROJECTID'] if not (tt.project_to_name_to_state.has_key(project) and tt.project_to_name_to_state[project].has_key(tt_state)): # The state might have been added or the workflows changed since we # last looked in the database, so refresh our cache. tt.read_states() if (tt.project_to_name_to_state.has_key(project) and tt.project_to_name_to_state[project].has_key(tt_state)): return tt.project_to_name_to_state[project][tt_state] else: raise error, ("No TeamTrack state in project '%s' " "corresponding to Perforce state '%s'" % (project, p4_state)) # The text_translator class translates multi-line text fields between deect # trackers TeamTrack (0) and Perforce (1). class text_translator(replicator.translator): # Transform TeamTrack memo field contents to Perforce text field contents # by converting line endings. See job000008 and job000009. def translate_0_to_1(self, tt_string, tt, p4, case = None, job = None): assert isinstance(tt_string, types.StringType) assert isinstance(tt, dt_teamtrack) assert isinstance(p4, replicator.defect_tracker) assert case == None or isinstance(case, teamtrack_case) # Replace \r\n with \n. tt_string = string.replace(tt_string, '\r\n', '\n') # Add final newline, unless the string is empty. if tt_string: tt_string = tt_string + '\n' return tt_string # Transform Perforce text field contents to TeamTrack memo field contents # by converting line endings. See job000008 and job000009. def translate_1_to_0(self, p4_string, tt, p4, case = None, job = None): assert isinstance(p4_string, types.StringType) assert isinstance(tt, dt_teamtrack) assert isinstance(p4, replicator.defect_tracker) assert case == None or isinstance(case, teamtrack_case) # Remove final newline (if any). if p4_string and p4_string[-1] == '\n': p4_string = p4_string[:-1] # Replace \n with \r\n. p4_string = string.replace(p4_string, '\n', '\r\n') return p4_string # The user_translator class translates users between defect trackers TeamTrack # (0) and Perforce (1). # # The user_translator needs to cope three two special cases. # # First, user fields in TeamTrack can be empty (that is, userid is 0). We # replicate these to and from the dummy user "(None)" in Perforce, since that's # how non-existent users show up in TeamTrack. # # Second, we don't insist that all users in Perforce have licences in # TeamTrack. For example, a user who made a changelist long ago and has left # the company. These users are mapped to the TeamTrack userid 0. (But note # that people shouldn't be able to *change* issues in TeamTrack unless they # have a licence -- this was agreed with TeamShare [ref?]. It isn't the # responsibility of the user_translator to worry about this. That's up to # the teamtrack_case.update method). See job000087. # # Third, we don't insist that all users in TeamTrack have licences in Perforce # (we are discussing this with Perforce and the requirement may be dropped # [ref?]). For these users we simply put their TeamTrack user name in Perforce # -- this works because Perforce doesn't check user names. class user_translator(replicator.translator): # Have the maps been build yet? (Each instance of this class only runs # match_users once.) matched_users = 0 # A map from TeamTrack user id to Perforce username (for users where we can # work out a correspondence by e-mail address). user_tt_to_p4 = { } # A map from Perforce username to TeamTrack user id (for users where we can # work out a correspondence by e-mail address). user_p4_to_tt = { } # Obtain a dictionary email -> Perforce id. (This method copied from # dt_bugzilla.user_translator; it probably belongs in the dt_perforce class # so it can be shared between user translators for various defect tracking # systems.) def p4_user_dict(self, p4) : p4_users = p4.p4.run("users") dict = {} for user in p4_users : dict[user['Email']] = user['User'] return dict # Build the user_tt_to_p4 and user_p4_to_tt maps. def match_users(self, tt, p4): assert isinstance(tt, dt_teamtrack) assert isinstance(p4, replicator.dt_perforce) if self.matched_users: return # Read TeamTrack users and e-mail addresses. tt.read_users() # Read Perforce users and e-mail addresses. p4_email_to_user = self.p4_user_dict(p4) # Clear the maps. self.user_tt_to_p4 = {} self.user_p4_to_tt = {} # Pair up users by e-mail address. for user, email in tt.user_id_to_email.items(): if p4_email_to_user.has_key(email): tt.log("Matched TeamTrack user '%s' with Perforce user '%s' by e-mail address '%s'.", (tt.user_id_to_name[user], p4_email_to_user[email], email)) self.user_tt_to_p4[user] = p4_email_to_user[email] self.user_p4_to_tt[p4_email_to_user[email]] = user self.matched_users = 1 def translate_0_to_1(self, tt_user, tt, p4, case = None, job = None): assert isinstance(tt_user, types.IntType) assert isinstance(tt, dt_teamtrack) assert isinstance(p4, replicator.dt_perforce) assert case == None or isinstance(case, teamtrack_case) if tt_user == 0: return '(None)' self.match_users(tt, p4) # Is the user in our map by e-mail address? if self.user_tt_to_p4.has_key(tt_user): return self.user_tt_to_p4[tt_user] # They are not, so use the TeamTrack username as the Perforce username. if not tt.user_id_to_name.has_key(tt_user): # The user might have been added since we last looked in the # TS_USERS table, so refresh our cache. tt.read_users() if tt.user_id_to_name.has_key(tt_user): return tt.user_id_to_name[tt_user] else: return '(None)' def translate_1_to_0(self, p4_user, tt, p4, case = None, job = None): assert isinstance(p4_user, types.StringType) assert isinstance(tt, dt_teamtrack) assert isinstance(p4, replicator.dt_perforce) assert case == None or isinstance(case, teamtrack_case) if p4_user == '(None)' or p4_user == '': return 0 self.match_users(tt, p4) # Is the user in our map by e-mail address? if self.user_p4_to_tt.has_key(p4_user): return self.user_p4_to_tt[p4_user] # They are not, so use the Perforce username as the TeamTrack username. if not tt.user_name_to_id.has_key(p4_user): # The user might have been added since we last looked in the # TS_USERS table, so refresh our cache. tt.read_users() if tt.user_name_to_id.has_key(p4_user): return tt.user_name_to_id[p4_user] else: return 0 # B. Document History # # 2000-12-05 GDR Starts replicating TeamTrack data from installation and not # from the beginning of time. # # 2000-12-06 GDR Report both the user and the transition when a transition is # generated, to make job000133 easier to spot and debug if it happens again. # Improved message sent when a transition can't be found, by using the state # names rather than the state ID numbers. # # 2000-12-07 GDR Updated user_translator so that it matches users by e-mail # address if it can; otherwise it defaults to the original algorithm: assume # that they are identical. # # 2000-12-08 GDR The date_translator copes with empty date fields.