# replicator.py -- P4DTI replicator.
# Gareth Rees, Ravenbrook Limited, 2000-08-09.
# $Id: //info.ravenbrook.com/project/p4dti/branch/2000-11-14/bugzilla/code/replicator/replicator.py#5 $
#
# See "Perforce Defect Tracking Integration Architecture"
# for the architecture of the integration;
# "Replicator design" for the design of the
# replicator; and "Replicator classes in Python"
# for the class organization of the
# replicator.
#
# 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 logger
import re
import smtplib
import socket
import string
import sys
import time
import traceback
import types
# The defect_tracker_issue class is an abstract class representing an issue in
# the defect tracker. You can't use this class; you must subclass it and use
# the subclass.
class defect_tracker_issue:
# action(). Return the action field (replicate/wait/keep/discard).
# add_filespec(filespec). Associate a filespec with the issue.
# add_fix(change, client, date, user, status). Associate a fix with the
# issue.
# id(). Return an identifier for the given defect tracking issue, as a
# string. This identifier can be used to fetch the issue from the defect
# tracker, by passing it to defect_tracker.issue.
# filespecs(). Return a list of filespecs for the issue. The elements of
# the list belong to a subclass of the defect_tracker_filespec class.
# fixes(). Return a list of fixes for the issue. The elements of the list
# belong to a subclass of the defect_tracker_fix class.
# readable_name(). Return a human-readable name for the issue, as a
# string. This should be suitable for use as a Perforce jobname.
# replicate_p(). A policy used to set up replication for issues where
# replication is not yet specified. Return true if the issue should be
# replicated by this replicator, false if it should not. The default
# policy is to replicate all issues.
def replicate_p(self):
return 1
# rid(). Return the identifier of the replicator which is in charge of
# replicating this issue, or the empty string if the issue is not being
# replicated.
# setup_for_replication(server_id). Set up the issue for replication. The
# server_id argument is the Perforce server id that the replicator
# replicates to.
# update(user, changes). Update the issue record in the defect tracking
# system by applying changes, which is a dictionary mapping field name to
# the new value for that field name. User is the user who made the change
# in Perforce.
# update_action(action). Update the issue in the defect tracking system so
# that it's action field is set to the action argument.
# The defect_tracker_filespec class is an abstract class representing an
# associated filespec record in the defect tracker. You can't use this class;
# you must subclass it and use the subclass.
class defect_tracker_filespec:
pass
# delete(). Delete the filespec record.
# name(). Return the filespec's name.
# The defect_tracker_fix class is an abstract class representing a fix record
# in the defect tracker. You can't use this class; you must subclass it and
# use the subclass.
class defect_tracker_fix:
pass
# change(). Return the change number for the fix record.
# delete(). Delete the fix record.
# status(). Return the status for the fix record.
# update(change, client, date, user, status). Update the fix record so
# that it corresponds to the Perforce fix.
# The defect_tracker class is an abstract class representing the interface
# between the replicator and the defect tracker. You can't use this class; you
# must subclass it and use the subclass.
class defect_tracker:
config = { 'logger' : logger.file_logger() }
rid = None
sid = None
def __init__(self, rid, sid, config = {}):
self.rid = rid
self.sid = sid
# Merge the supplied config with the default config (the former takes
# precedence).
for k in config.keys():
self.config[k] = config[k]
# all_issues(). Return a list of all defect tracking issues that are being
# replicated by this replicator, or which are not being replicated by any
# replicator. Each element of the list belongs to the defect_tracker_issue
# class (or a subclass).
# changed_entities(). Return a triple consisting of (a) a list of the
# issues that have changed in the defect tracker and which are either
# replicated by this replicator, or are new issues that are not yet
# replicated; (b) a list of the changelists that have changed; and (c) a
# marker. Each element of the first list belongs to the
# defect_tracker_issue class (or a subclass). The marker will be passed to
# the method mark_changes_done when that is called after all the issues
# have been replicated. For defect trackers other than Perforce, the
# second list should be empty.
# mark_changes_done(marker). Called when all the issues returned by
# changed_entities have been replicated. The argument is the second
# element of the pair returned by the call to changed_entities. (The idea
# behind this is that the defect tracker interface may have some way of
# recording that it has considered all these issues -- perhaps by recording
# the last key on a changes table. It is important not to record this
# until it is true, so that if the replicator crashes between getting the
# changed issues and replicating them then we'll consider the same set of
# changed issues the next time round, and hopefully this will give us a
# chance to either replicate them correctly or else discover that they are
# conflicting.)
# init(). Set up the defect tracking database for the integration. Set up
# issues for replication by this replicator according to the policy in the
# replicate_p method of the issue.
# issue(issue_id). Return the defect tracking issue whose identifier has
# the string form given by issue_id, or None if there is no such issue.
# log(format, arguments, priority). Write a message to the replicator's
# log.
def log(self, format, arguments = (), priority = None):
format = "%s\t" + format
if isinstance(arguments, types.TupleType):
arguments = (self.rid,) + arguments
else:
arguments = (self.rid, arguments)
self.config['logger'].log("P4DTI-0", format, arguments, priority)
# replicate_changelist(change, client, date, description, status, user).
# Replicate the changelist to the defect tracking database. Return 1 iff
# the changelist was changed, 0 if it was already in the database and was
# unchanged.
# A translator subclass translates between corresponding fields in two defect
# trackers. For example, we could have a state translator that translates
# between a state field in TeamTrack and the status field in Perforce.
# The translator class itself is the null translator. It doesn't change
# values, but does check types.
class translator:
# The defect trackers that it translates between.
dts = (None, None)
def __init__(self, dts):
assert isinstance(dts, types.TupleType)
assert len(dts) == 2
assert isinstance(dts[0], defect_tracker)
assert isinstance(dts[1], defect_tracker)
self.dts = dts
# Translate a field from defect tracker 0 to defect tracker 1. The issues
# argument is a pair of issues to which the translation applies, or None if
# the translation isn't being applied to issues.
#
# Note that some translators may only be applied to fields in issues. For
# example, the TeamTrack to Perforce state translator can only work if it
# knows the project to which the TeamTrack issue belongs (the same logical
# state may be represented by several actua; states in different projects).
# So that translator insists on the issues argument being supplied.
def translate_0_to_1(self, value, issues = None):
if issues:
assert isinstance(issues, types.TupleType)
assert len(issues) == 2
assert isinstance(issues[0], defect_tracker_issue)
#assert isinstance(issues[1], defect_tracker_issue)
return value
# Translate a field from defect tracker 1 to defect tracker 0. The issues
# argument is a pair of issues to which the translation applies, or None if
# the translation isn't being applied to issues.
def translate_1_to_0(self, value, issues = None):
if issues:
assert isinstance(issues, types.TupleType)
assert len(issues) == 2
assert isinstance(issues[0], defect_tracker_issue)
#assert isinstance(issues[1], defect_tracker_issue)
return value
# The replicator class is a generic replicator. Pass the constructor a defect
# tracking interface and (optionally) a configuration hash. The generic
# replicator assumes the Perforce server is on the same host as the replicator.
class replicator:
config = {
'administrator-address': None,
'counter' : None,
'job-status-field' : 'Status',
'logger' : logger.file_logger(),
'poll-period' : 10,
'replicator-address' : 'p4dti',
'smtp-server' : None,
'user-translator': None,
'date-translator': None,
'replicated-fields': [],
}
dt = None
rid = None
p4 = None
# Error object for fatal errors raised by the replicator.
error = 'P4DTI Replicator error'
# Error object for conflicts. Use raise self.conflict_error, (id, format,
# args) when raising this.
conflict_error = 'P4DTI Replicator conflict'
# The action_table maps the (dt_action, p4_action) to the action that the
# replicator should take: 'normal' means apply the normal decision rules;
# 'conflict' means that the entities are already marked as conflicting; a
# conflict should be reported if entities have changed; 'p4' means that the
# issue should be overwritten with the job; 'dt' means that the job should
# be overwritten with the issue.
action_table = {
( 'replicate', 'replicate' ): 'normal',
( 'wait', 'wait' ): 'conflict',
( 'wait', 'keep' ): 'p4',
( 'wait', 'discard' ): 'dt',
( 'keep', 'wait' ): 'dt',
( 'keep', 'discard' ): 'dt',
( 'discard', 'wait' ): 'p4',
( 'discard', 'keep' ): 'p4',
}
def __init__(self, rid, dt, p4, config = {}):
self.dt = dt
self.p4 = p4
self.rid = rid
# Merge the supplied config with the default config (the former takes
# precedence).
for k in config.keys():
self.config[k] = config[k]
# Make a counter name for this replicator.
if not self.config['counter']:
self.config['counter'] = 'P4DTI-%s' % self.rid
# changed_entities(). Return a 3-tuple consisting of (a) changed jobs
# (b) changed changelists and (c) the last log entry that was considered.
# The changed jobs are those that are due for replication by this
# replicator (that is, the P4DTI-rid field of the job matches the
# replicator id). The last log entry will be passed to
# mark_changes_done.
def changed_entities(self):
# Get all entries from the log.
log_entries = self.p4.run('logger -c 0')
jobs = {}
changelists = []
last_log_entry = None # The last entry number in the log.
for e in log_entries:
last_log_entry = int(e['sequence'])
if e['key'] == 'job':
jobname = e['attr']
if not jobs.has_key(jobname):
job = self.job(jobname)
if (job.has_key('P4DTI-rid')
and job['P4DTI-rid'] == self.rid
# We ought to make sure not to return jobs that were
# last updated by the replicator, by looking at the
# P4DTI-user field in each job. But this doesn't work
# yet: see job000014.
# and job.has_key('P4DTI-user')
# and job['P4DTI-user']!=self.p4.config['p4-user']
):
jobs[jobname] = job
elif e['key'] == 'change':
change_number = e['attr']
try:
changelist = self.p4.run('change -o %s' % change_number)[0]
changelists.append(changelist)
except p4.error:
# The changelist might not exist, because it might have
# been a pending changelist that's been renumbered. So
# don't replicate it. Should it be deleted? GDR
# 2000-11-02.
pass
return jobs, changelists, last_log_entry
# mark_changes_done(log_entry). Update the Perforce database to
# record the fact that the replicator has replicated all changes up to
# log_entry.
def mark_changes_done(self, log_entry):
# Update counter to last entry number in the log that we've replicated.
# If this is the last entry in the log, it has the side-effect of
# deleting the log (see "p4 help undoc").
if log_entry:
self.p4.run('logger -t %s -c %d' % (self.config['counter'], log_entry))
# check_consistency(). Run a consistency check on the two databases,
# reporting any inconsistencies.
def check_consistency(self):
print "Checking consistency for replicator '%s'" % self.rid
inconsistencies = 0 # Number of inconsistencies found.
# Get hashes of issues (by id) and jobs (by jobname).
issues = {}
for issue in self.dt.all_issues():
issues[issue.id()] = issue
jobs = {}
for j in self.p4.run('jobs -e P4DTI-rid=%s' % self.rid):
jobs[j['Job']] = j
for id, issue in issues.items():
# Progress indication.
sys.stdout.write(".")
# Report if issue has no corresponding job.
if issue.rid() != self.rid:
if issue.replicate_p():
print "Issue '%s' should be replicated but is not." % id
inconsistencies = inconsistencies + 1
continue
jobname = issue.readable_name()
if not jobs.has_key(jobname):
print("Issue '%s' should be replicated to job '%s' but that "
"job either does not exists or is not replicated."
% (id, jobname))
inconsistencies = inconsistencies + 1
continue
# Get corresponding job.
job = jobs[jobname]
del jobs[jobname]
# Report if mapping is in error.
if job['P4DTI-issue-id'] != id:
print("Issue '%s' is replicated to job '%s' but that job "
"is replicated to issue '%s'."
% (id, jobname, job['P4DTI-issue-id']))
inconsistencies = inconsistencies + 1
# Report if actions aren't replicate/replicate.
issue_action = issue.action()
job_action = job['P4DTI-action']
if not self.action_table.has_key((issue_action, job_action)):
print("Issue '%s' has action '%s' and job '%s' has action "
"'%s': this combination is illegal."
% (id, issue_action, jobname, job_action))
inconsistencies = inconsistencies + 1
else:
action = self.action_table[(issue_action, job_action)]
if action != 'normal':
print("Issue '%s' has action '%s' and job '%s' has action "
"'%s'." % (id, issue_action, jobname, job_action))
inconsistencies = inconsistencies + 1
# Report if job and issue don't match.
changes = self.translate_issue_dt_to_p4(issue, job)
if changes:
print("Job '%s' would need the following set of changes in "
"order to match issue '%s': %s."
% (jobname, id, str(changes)))
inconsistencies = inconsistencies + 1
# Report if the sets of filespecs differ.
p4_filespecs = self.job_filespecs(job)
dt_filespecs = issue.filespecs()
for p4_filespec, dt_filespec in self.filespecs_differences(dt_filespecs, p4_filespecs):
if p4_filespec and not dt_filespec:
print("Job '%s' has associated filespec '%s' but there is "
"no corresponding filespec for issue '%s'."
% (jobname, p4_filespec, id))
inconsistencies = inconsistencies + 1
elif not p4_filespec and dt_filespec:
print("Issue '%s' has associated filespec '%s' but there "
"is no corresponding filespec for job '%s'."
% (id, dt_filespec.name(), jobname))
inconsistencies = inconsistencies + 1
else:
# Corresponding filespecs can't differ (since their only
# attribute is their name).
assert 0
# Report if the sets of fixes differ.
p4_fixes = self.job_fixes(job)
dt_fixes = issue.fixes()
for p4_fix, dt_fix in self.fixes_differences(dt_fixes, p4_fixes):
if p4_fix and not dt_fix:
print("Change %s fixes job '%s' but there is "
"no corresponding fix for issue '%s'."
% (p4_fix['Change'], jobname, id))
inconsistencies = inconsistencies + 1
elif not p4_fix and dt_fix:
print("Change %d fixes issue '%s' but there is "
"no corresponding fix for job '%s'."
% (dt_fix.change(), id, jobname))
inconsistencies = inconsistencies + 1
else:
print("Change %s fixes job '%s' with status '%s', but "
"change %d fixes issue '%s' with status '%s'."
% (p4_fix['Change'], jobname, p4_fix['Status'],
dt_fix.change(), id, dt_fix.status()))
inconsistencies = inconsistencies + 1
# There should be no remaining jobs, so any left are in error.
for job in jobs.values():
if issues[job['P4DTI-issue-id']]:
print("Job '%s' is marked as being replicated to issue '%s' "
"but that issue is being replicated to job '%s'."
% (job['Job'], job['P4DTI-issue-id'],
issues[job['P4DTI-issue-id']].readable_name()))
inconsistencies = inconsistencies + 1
else:
print("Job '%s' is marked as being replicated to issue '%s' "
"but that issue either doesn't exist or is not being "
"replicated by this replicator."
% (job['Job'], job['P4DTI-issue-id']))
inconsistencies = inconsistencies + 1
# Report on success/failure.
print
print "Consistency check completed."
print len(issues), "issues checked."
if inconsistencies == 0:
print "Looks all right to me."
elif inconsistencies == 1:
print "1 inconsistency found."
else:
print inconsistencies, "inconsistencies found."
# fixes_differences(dt_fixes, p4_fixes). Each argument is a list of fixes
# for the same job/issue. Return list of pairs (p4_fix, dt_fix) of
# corresponding fixes which differ. Elements of pairs are None where there
# is no corresponding fix.
def fixes_differences(self, dt_fixes, p4_fixes):
# Make hash from change number to p4 fix.
p4_fix_by_change = {}
for p4_fix in p4_fixes:
p4_fix_by_change[int(p4_fix['Change'])] = p4_fix
# Make pairs (dt fix, corresponding p4 fix or None).
pairs = []
for dt_fix in dt_fixes:
if not p4_fix_by_change.has_key(dt_fix.change()):
pairs.append((None, dt_fix))
else:
p4_fix = p4_fix_by_change[dt_fix.change()]
del p4_fix_by_change[dt_fix.change()]
if dt_fix.status() != p4_fix['Status']:
pairs.append((p4_fix, dt_fix))
# Remaining p4 fixes are unpaired.
for p4_fix in p4_fix_by_change.values():
pairs.append((p4_fix, None))
return pairs
# filespecs_differences(dt_filespecs, p4_filespecs). Each argument is a
# list of filespecs for the same job/issue. Return list of pairs
# (p4_filespec, dt_filespec) of filespecs which differ. Elements of pairs
# are None where there is no corresponding filespec (this is always the
# case since there is no associated information with a filespec; the
# function is like this for consistency with fixes_differences, and so that
# it is easy to extend if there is ever a way to associate information with
# a filespec, for example the nature of the association -- see requirement
# 55).
def filespecs_differences(self, dt_filespecs, p4_filespecs):
# Make hash from name to p4 filespec.
p4_filespec_by_name = {}
for p4_filespec in p4_filespecs:
p4_filespec_by_name[p4_filespec] = p4_filespec
# Make pairs (dt filespec, None).
pairs = []
for dt_filespec in dt_filespecs:
if not p4_filespec_by_name.has_key(dt_filespec.name()):
pairs.append((None, dt_filespec))
else:
del p4_filespec_by_name[dt_filespec.name()]
# Make pairs (None, p4 filespec).
for p4_filespec in p4_filespec_by_name.values():
pairs.append((p4_filespec, None))
return pairs
# init(). Set up Perforce and the defect tracking system so that
# replication can proceed.
def init(self):
# Check that the Perforce server version is supported by the
# integration.
server_version_re = re.compile('Server version: '
'[^/]+/[^/]+/[^/]+/([0-9]+)')
changelevel = 0
supported_changelevel = 18548
for x in self.p4.run('info'):
if x.has_key('code') and x['code'] == 'info' and x.has_key('data'):
match = server_version_re.match(x['data'])
if match:
changelevel = int(match.group(1))
if changelevel < supported_changelevel:
raise self.error, \
("Perforce server changelevel %d is not "
"supported by P4DTI. Server must be at "
"changelevel %d or above"
% (changelevel, supported_changelevel))
else:
break
if not changelevel:
raise self.error, "p4 info didn't report a recognisable version"
# Initialize the defect tracking system.
self.dt.init()
# Make a client for the replicator.
self.p4.run('client -i', self.p4.run('client -o'))
# TODO: Initialize Perforce by adding fields to jobspec if not present.
# For the moment I can't add fields to the jobspec, so I'll just check
# that they are there. I can't even check the presence of the
# P4DTI-user field this way, since it isn't given a value for a
# non-existent job like the one we're asking for here.
job = self.job('P4DTI-no-such-job')
for field in ['P4DTI-filespecs', 'P4DTI-issue-id', 'P4DTI-rid',
'P4DTI-action']:
if not job.has_key(field):
raise self.error, ("Field '%s' not found in Perforce jobspec"
% field)
# Has the logger been started? (We must be careful not to set the
# logger counter to 0 more than once; this will confuse Perforce
# according to Chris Seiwald's e-mail .
logger_started = 0
logger_re = re.compile('logger = ([0-9]+)$')
counters = self.p4.run('counters')
for c in counters:
if (c.has_key('code') and c['code'] == 'info' and c.has_key('data')
and logger_re.match(c['data'])):
logger_started = 1
# If not, start it.
if not logger_started:
self.p4.run('counter logger 0')
# job(jobname). Return the Perforce job with the given name if it exists,
# or an empty job specification (otherwise).
def job(self, jobname):
jobs = self.p4.run('job -o %s' % jobname)
if len(jobs) != 1 or not jobs[0].has_key('Job'):
raise self.error, ("expected a job but found %s" % str(jobs))
elif jobs[0]['Job'] != jobname:
raise self.error, ("asked for job '%s' but got job '%s'"
% (jobname, jobs[0]['Job']))
else:
return jobs[0]
# job_filespecs(job). Return a list of filespecs for the given job. Each
# element of the list is a filespec, as a string.
def job_filespecs(self, job):
filespecs = string.split(job['P4DTI-filespecs'], '\n')
# Since Perforce text fields are terminated with a newline, the last
# item of the list should be empty. Remove it.
if filespecs:
assert filespecs[-1] == ''
filespecs = filespecs[:-1]
return filespecs
# job_fixes(job). Return a list of fixes for the given job. Each element
# of the list is a dictionary with keys Change, Client, User, Job, and
# Status.
def job_fixes(self, job):
return self.p4.run('fixes -j %s' % job['Job'])
# log(format, arguments, priority). Write the message to the replicator's
# log.
def log(self, format, arguments = (), priority = None):
format = "%s\t" + format
if isinstance(arguments, types.TupleType):
arguments = (self.rid,) + arguments
else:
arguments = (self.rid, arguments)
self.config['logger'].log("P4DTI-0", format, arguments, priority)
# mail(to, subject, body, cc=''). Send e-mail to the given recipient
# integration with the given subject and body.
def mail(self, to, subject, body, cc=''):
if self.config['administrator-address'] and self.config['smtp-server']:
smtp = smtplib.SMTP(self.config['smtp-server'])
from_address = self.config['replicator-address']
message = ("From: %s\nTo: %s\nCC: %s\nSubject: %s\n\n%s"
% (from_address, to, cc, subject, body))
smtp.sendmail(from_address, to, message)
smtp.quit()
# mail_administrator(subject, body). Send e-mail to the administrator of
# the integration with the given subject and body.
def mail_administrator(self, subject, body):
self.mail(self.config['administrator-address'], subject, body)
# conflict(issue, job, message). Report that the given issue conflicts
# with the given job. The message argument is a string containing
# additional detail about the conflict. Mark the issue and job as
# conflicting if they are not already.
def conflict(self, issue, job, message):
try:
# We can't just update the issue in hand because that might have
# changed in the course of replicating some fields. So fetch it
# again. This is quite unsatisfactory: we should keep better track
# of the old and new versions of the issue. GDR 2000-10-27.
old_issue = self.dt.issue(issue.id())
old_issue.update_action('wait')
# The same problem applies to the job. GDR 2000-10-27.
old_job = self.job(job['Job'])
self.update_job_action(old_job, 'wait')
finally:
self.log("Issue '%s' conflicts with corresponding job '%s'. %s",
(issue.id(), job['Job'], message))
subject = ("Issue '%s' conflicts with corresponding job '%s'."
% (issue.id(), job['Job']))
body = ("%s\n\n%s\n\nIssue:\n%s\n\nJob:\n%s"
% (subject, message, issue, job))
self.mail_administrator(subject, body)
# conflict_policy(issue, job). This method is called when both the issue
# and the corresponding job have changed since the last time they were
# consistent. Return 'p4' if the Perforce job is correct and should be
# replicated to the defect tracker. Return 'dt' if the defect tracking
# issue is correct and should be replicated to Perforce. Return 'none' if
# the replicator should take no further action. Any other result indicates
# that the replicator should treat the situation as a conflict and proceed
# accordingly. The default policy is to treat the situation as a conflict.
def conflict_policy(self, issue, job):
return 'both'
# poll(). Poll the DTS for changed issues. Poll Perforce for changed jobs
# and changelists. Replicate all of these entities.
def poll(self):
# Get the changed issues (ignore changed changelists if any since we
# only replicate changelists from Perforce to the defect tracker).
changed_issues, _, dt_marker = self.dt.changed_entities()
if len(changed_issues) == 1:
self.log('1 issue has changed')
elif len(changed_issues) > 1:
self.log('%d issues have changed', len(changed_issues))
changed_jobs, changelists, p4_marker = self.changed_entities()
if len(changed_jobs) == 1:
self.log('1 job has changed')
elif len(changed_jobs) > 1:
self.log('%d jobs have changed', len(changed_jobs))
# Replicate the issues and the jobs.
self.replicate_many(changed_issues, changed_jobs)
# Replicate the affected changelists.
for c in changelists:
self.replicate_changelist_p4_to_dt(c)
# Tell the defect tracker and Perforce that we've finished replicating
# these changes.
self.dt.mark_changes_done(dt_marker)
self.mark_changes_done(p4_marker)
def replicate_changelist_p4_to_dt(self, changelist):
change = int(changelist['Change'])
client = changelist['Client']
date = self.config['date-translator'].translate_1_to_0(changelist['Date'])
description = changelist['Description']
status = changelist['Status']
user = self.config['user-translator'].translate_1_to_0(changelist['User'])
if self.dt.replicate_changelist(change, client, date, description, status, user):
self.log("Replicated changelist %d", change)
# replicate_many(issues, jobs). Replicate the issues and jobs. The issues
# argument is a list of issues (which must belong to a subclass of
# defect_tracker_issue; the jobs list is a hash from jobname to job).
#
# The reason why the arguments have different conventions (list vs hash) is
# that the algorithm for getting the changed jobs from the p4 logger outpt
# involves constructing a hash from jobname to job, and it seems silly to
# turn this hash back into a list only to immediately turn it back into a
# hash again.
def replicate_many(self, issues, jobs):
# Make a list of triples of (defect tracking issue, Perforce job,
# status). Status is 'dt' if the defect tracking issue has changed but
# not the Perforce job; 'p4' if vice versa; 'both' if both have
# changed. TODO: mitigate effects of race conditions?
triples = []
# Go through issues making triples. Set up issues for replication if
# they are not already replicated. Delete corresponding jobs from the
# jobs dictionary.
for issue in issues:
# Issue not set up for replication yet?
if not issue.rid():
# Should issue be replicated by this replicator?
if issue.replicate_p():
issue.setup_for_replication()
self.log("Set up issue '%s' to replicate to job '%s'",
(issue.id(), issue.readable_name()))
else:
# Don't replicate this issue at all.
continue
jobname = issue.readable_name()
if jobs.has_key(jobname):
job = jobs[jobname]
triples.append((issue, job, 'both'))
del jobs[jobname]
else:
job = self.job(jobname)
triples.append((issue, job, 'dt'))
# Now go through the remaining changed jobs.
for job in jobs.values():
issue = self.dt.issue(job['P4DTI-issue-id'])
if not issue:
raise self.error, "No issue '%s'" % job['P4DTI-issue-id']
triples.append((issue, job, 'p4'))
# Now process each triple.
for issue, job, status in triples:
try:
self.replicate(issue, job, status)
except self.conflict_error, message:
self.conflict(issue, job, message)
except: # Should catch TSAPI errors only?
type, value, tb = sys.exc_info()
tb_list = traceback.format_exception(type, value, tb)
message = ("An error occurred while trying to replicate.\n\n"
+ string.join(tb_list, ''))
# If only the Perforce job changed (so we were replicating to
# the defect tracker), then it is most likely that the error is
# due to the change being rejected by the defect tracker. So
# attempt to set the job back to the way it was and e-mail the
# person responsible. Note however that we can't be sure who
# was responsible since changes to jobs made via fixes don't
# update the user field. GDR 2000-10-31.
if job.has_key('P4DTI-user'):
user = job['P4DTI-user']
else:
user = None
if status == 'p4' and user and user != self.p4.config['p4-user']:
self.log("Job '%s' could not be replicated to issue '%s'. %s",
(job['Job'], issue.id(), message))
try:
# Get the issue again, since it might have been changed
# in memory in the course of the failed replication.
# Note new variable name so as not to overwrite the old
# issue. (Can we avoid all this nonsense by keeping
# better track of old and new issues?) GDR 2000-10-31.
issue2 = self.dt.issue(job['P4DTI-issue-id'])
if not issue2:
raise self.error, "No issue '%s'" % job['P4DTI-issue-id']
self.replicate(issue2, job, 'dt')
p4_user = self.p4.run('user -o %s' % user)[0]
email = p4_user['Email']
self.log("Mailing '%s'.", (email,))
subject = "Job '%s' could not be replicated to issue '%s'." % (job['Job'], issue.id())
body = ("%s\n\n%s\n\nIssue:\n%s\n\nJob:\n%s"
% (subject, message, issue2, job))
self.mail(email, subject, body,
self.config['administrator-address'])
except:
# Replicating back failed, so it's really a conflict.
# Really I should make tracebacks for both errors here.
# GDR 2000-10-31.
type, value, tb = sys.exc_info()
tb_list = traceback.format_exception(type, value, tb)
message2 = ("An error occurred while trying to replicate.\n\n"
+ string.join(tb_list, ''))
self.conflict(issue, job, message + '\n' + message2)
else:
self.conflict(issue, job, message)
# replicate(issue, job, changed). Replicate an issue to or from the
# corresponding job. The changed argument is 'dt' if the defect tracking
# issue has changed but not the Perforce job; 'p4' if vice versa; 'both' if
# both have changed. Return true iff the replication was successful.
def replicate(self, issue, job, changed):
id = issue.id()
jobname = job['Job']
# Figure out what to do with this issue and job. Report a conflict?
# Do nothing? Overwrite issue with job? Overwrite job with issue?
# The action arguments may tell us what to do immediately.
issue_action = issue.action()
job_action = job['P4DTI-action']
if not self.action_table.has_key((issue_action, job_action)):
raise self.conflict_error, \
("Issue '%s' has action '%s' and job '%s' has action '%s': "
"this combination is illegal."
% (id, issue_action, jobname, job_action))
action = self.action_table[(issue_action, job_action)]
# Action 'conflict' means that there was already a conflict between the
# job and the issue, and now one or both have changed. Report this,
# since the administrator may need to know what's been happening to
# these entities in order to resolve the conflict.
if action == 'conflict':
if changed == 'dt':
raise self.conflict_error, "Existing conflict; issue changed."
elif changed == 'p4':
raise self.conflict_error, "Existing conflict; job changed."
else:
assert changed == 'both'
raise self.conflict_error, "Existing conflict; both changed."
# Action 'normal' means that the ordinary decision procedure should
# apply. This involves consulting the automatic conflict resolution
# policy when both have changed. Otherwise, overwrite the unchanged
# entity with the changed entity.
elif action == 'normal':
# Both issues changed? Apply the conflict resolution policy.
if changed == 'both':
self.log("Issue '%s' and job '%s' have both changed. "
"Consulting conflict resolution policy.",
(id, jobname))
action = self.conflict_policy(issue, job)
if action == 'none':
self.log("Conflict resolution policy decided: no action.")
return None
elif action == 'dt':
self.log("Conflict resolution policy decided: "
"Overwrite job with issue.")
elif action == 'p4':
self.log("Conflict resolution policy decided: "
"Overwrite issue with job.")
else:
self.log("Conflict resolution policy decided: "
"Report conflict.")
raise self.conflict_error, "Both changed."
else:
action = changed
elif action == 'p4':
self.log("Overwrite issue '%s' with job '%s' (actions are %s/%s)",
(id, jobname, issue_action, job_action))
subject = "Issue '%s' overwritten by job '%s'" % (id, jobname)
body = ("Action field of issue '%s' is '%s' and the action field "
"of job '%s' is '%s'. This means that the issue will be "
"overwritten by the job.\n\nThe old issue was:\n%s"
% (id, issue_action, jobname, job_action, issue))
self.mail_administrator(subject, body)
elif action == 'dt':
self.log("Overwrite job '%s' with issue '%s' (actions are %s/%s)",
(jobname, id, job_action, issue_action))
subject = "Job '%s' overwritten by issue '%s'" % (jobname, id)
body = ("Action field of issue '%s' is '%s' and the action field "
"of job '%s' is '%s'. This means that the job will be "
"overwritten by the issue.\n\nThe old job was:\n%s"
% (id, issue_action, jobname, job_action, job))
self.mail_administrator(subject, body)
else:
assert 0
# By now we should have decided which way to replicate.
assert action == 'dt' or action == 'p4'
if action == 'dt':
self.log("Replicating issue '%s' to job '%s'",
(issue.id(), job['Job']))
return self.replicate_issue_to_job(issue, job)
else: # action == 'p4'
self.log("Replicating job '%s' to issue '%s'",
(job['Job'], issue.id()))
return self.replicate_job_to_issue(job, issue)
# replicate_issue_to_job(issue, old_job). Replicate the given issue from
# the defect tracker to Perforce. Return true iff the issue was replicated
# successfully. Otherwise throw an exception.
def replicate_issue_to_job(self, issue, job):
# Transform the issue into a job. This has to be done first because
# the job might be new, and we won't be able to replicate fixes or
# filespecs until the job's been created (p4 fix won't accept
# non-existent jobnames). I suppose I could create a dummy job to act
# as a placeholder here, but that's not easy at all -- you have to know
# quite a lot about the jobspec to be able to create a job.
changes = self.translate_issue_dt_to_p4(issue, job)
if changes:
self.log("-- Changed fields: %s", `changes`)
self.update_job(job, changes)
else:
self.log("-- No issue fields were replicated.")
# Replicate filespecs.
dt_filespecs = issue.filespecs()
p4_filespecs = self.job_filespecs(job)
if self.filespecs_differences(dt_filespecs, p4_filespecs):
names = map(lambda(f): f.name(), dt_filespecs)
self.update_job(job, { 'P4DTI-filespecs': string.join(names,'\n') })
self.log("-- Filespecs changed to '%s'", string.join(names))
# Replicate fixes.
p4_fixes = self.job_fixes(job)
dt_fixes = issue.fixes()
job_status = job[self.config['job-status-field']]
for p4_fix, dt_fix in self.fixes_differences(dt_fixes, p4_fixes):
if p4_fix and not dt_fix:
self.p4.run('fix -d -c %s %s'
% (p4_fix['Change'], p4_fix['Job']))
self.log("-- Deleted fix for change %s", p4_fix['Change'])
elif not p4_fix and dt_fix:
self.p4.run('fix -s %s -c %d %s'
% (dt_fix.status(), dt_fix.change(),
issue.readable_name()))
job_status = dt_fix.status()
self.log("-- Added fix for change %d with status %s",
(dt_fix.change(), dt_fix.status()))
elif p4_fix['Status'] != dt_fix.status():
self.p4.run('fix -s %s -c %d %s'
% (dt_fix.status(), dt_fix.change(),
issue.readable_name()))
job_status = dt_fix.status()
self.log("-- Fix for change %d updated to status %s",
(dt_fix.change(), dt_fix.status()))
else:
# This should't happen, since fixes_differences returns only a
# list of pairs which differ.
assert 0
# It might be the case that the job status has been changed by
# replicating a fix from the defect tracker. But this changed status
# won't be right. So restore the correct status if necessary.
if job_status != job[self.config['job-status-field']]:
self.update_job(job, { 'Status': job[self.config['job-status-field']] })
# Job and issue are up to date.
issue.update_action('replicate')
self.update_job_action(job, 'replicate')
return 1
# replicate_job_to_issue(job, issue). Replicate the given job from
# Perforce to the defect tracker. Return true iff the job was replicated
# successfully.
def replicate_job_to_issue(self, job, issue):
# Replicate fixes.
p4_fixes = self.job_fixes(job)
dt_fixes = issue.fixes()
fix_diffs = self.fixes_differences(dt_fixes, p4_fixes)
for p4_fix, dt_fix in fix_diffs:
self.log("p4_fix = %s", (p4_fix,))
if dt_fix and not p4_fix:
dt_fix.delete()
self.log("-- Deleted fix for change %d", dt_fix.change())
elif not dt_fix:
change, client, date, status, user = self.translate_fix(p4_fix)
issue.add_fix(change, client, date, status, user)
self.log("-- Added fix for change %s with status %s",
(p4_fix['Change'], p4_fix['Status']))
elif dt_fix.status() != p4_fix['Status']:
change, client, date, status, user = self.translate_fix(p4_fix)
dt_fix.update(change, client, date, status, user)
self.log("-- Fix for change %s updated to status %s",
(p4_fix['Change'], p4_fix['Status']))
else:
# This should't happen, since fixes_differences returns only a
# list of pairs which differ.
assert 0
# Replicate filespecs.
p4_filespecs = self.job_filespecs(job)
dt_filespecs = issue.filespecs()
filespec_diffs = self.filespecs_differences(dt_filespecs, p4_filespecs)
for p4_filespec, dt_filespec in filespec_diffs:
if dt_filespec and not p4_filespec:
dt_filespec.delete()
self.log("-- Deleted filespec %s", dt_filespec.name())
elif not dt_filespec:
issue.add_filespec(p4_filespec)
self.log("-- Added filespec %s", p4_filespec)
else:
# This should't happen, since filespecs_differences returns
# only a list of pairs which differ.
assert 0
# Transform the job into an issue and update the issue.
changes = self.translate_issue_p4_to_dt(issue, job)
if changes:
self.log("-- Changed fields: %s", `changes`)
user = self.config['user-translator'].translate_1_to_0(job['P4DTI-user'])
issue.update(user, changes)
else:
self.log("-- No job fields were replicated.")
# Job and issue are up to date.
issue.update_action('replicate')
self.update_job_action(job, 'replicate')
return 1
def replicate_changelists(self):
# Replicate all the changelists.
self.log("Checking changelists to see if they need replicating...")
changelists = self.p4.run('changes')
self.log("-- %d changelists to check", len(changelists))
for c in changelists:
c2 = self.p4.run('change -o %s' % c['change'])[0]
self.replicate_changelist_p4_to_dt(c2)
# run() repeatedly polls the DTS.
def run(self):
while 1:
self.log("Polling...")
self.poll()
time.sleep(self.config['poll-period'])
# translate_fix_p4_to_dt(p4_fix).
def translate_fix_p4_to_dt(self, p4_fix):
change = int(p4_fix['Change'])
client = p4_fix['Client']
date = self.config['date-translator'](p4_fix['Date'])
status = p4_fix['Status']
user = self.config['user-translator'](p4_fix['User'])
return (change, client, date, user, status)
# translate_issue_dt_to_p4(issue, job). Return changes as a dictionary but
# don't apply them yet.
def translate_issue_dt_to_p4(self, issue, job):
changes = { 'P4DTI-rid': self.rid,
'P4DTI-issue-id': issue.id(),
'P4DTI-action': 'replicate',
}
for dt_field, p4_field, trans in self.config['replicated-fields']:
p4_value = trans.translate_0_to_1(issue[dt_field], (issue, job))
if not job.has_key(p4_field) or p4_value != job[p4_field]:
changes[p4_field] = p4_value
return changes
# translate_issue_p4_to_dt(issue, job). Return changes as a dictionary but
# don't apply them yet.
def translate_issue_p4_to_dt(self, issue, job):
changes = {}
for dt_field, p4_field, trans in self.config['replicated-fields']:
if job.has_key(p4_field):
p4_value = job[p4_field]
else:
p4_value = None
dt_value = trans.translate_1_to_0(p4_value, (issue, job))
if dt_value != issue[dt_field]:
changes[dt_field] = dt_value
return changes
# update_job(job, changes).
def update_job(self, job, changes = {}):
for key, value in changes.items():
job[key] = value
self.p4.run('job -i', [job])
# update_job_action(job, action).
def update_job_action(self, job, action):
# Only update the job action if it's being replicated by this
# replicator. See job000020.
if job['P4DTI-rid'] == self.rid and job['P4DTI-action'] != action:
self.update_job(job, { 'P4DTI-action': action })