vcseif 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ # Copyright 2001-2013 Rally Software Development Corp. All Rights Reserved.
2
+ require "date"
3
+ require "time"
4
+
5
+ class TimeFile
6
+
7
+ # Store the last time the connector was run - just in case the connector stops and restarts
8
+ # Time (as UTC) is stored as a string in YYYY-MM-DD HH:MM:SS UTC" format
9
+ ISO8601_FORMAT = "%Y-%m-%dT%H:%M:%S.%L%z"
10
+ STD_TS_FORMAT = "%Y-%m-%d %H:%M:%S Z"
11
+ STD_STRPTIME_FMT = "%Y-%m-%d %H:%M:%S %z"
12
+
13
+ %{
14
+ An instance of this class is used to record a timestamp in a file.
15
+ The timestamp is an ASCII representation that is derived from the ISO-8601 format.
16
+ }
17
+
18
+ attr_reader :filename
19
+ attr_reader :log
20
+
21
+ def initialize(filename, logger)
22
+ @filename = filename
23
+ @log = logger
24
+ end
25
+
26
+ def exists?
27
+ return File.exists?(@filename)
28
+ end
29
+
30
+ def read()
31
+ %{
32
+ Returns the time stored in the file as an epoch seconds value
33
+ or a default value of the time 5 minutes ago (again in epoch seconds form).
34
+ }
35
+ # default to 5 minutes ago if file non-existent or empty
36
+ default = Time.now - (5 * 60)
37
+ last_run_timestamp = nil
38
+
39
+ if exists?
40
+ begin
41
+ File.open(@filename, "r") do |file|
42
+ line = file.gets
43
+ if !line.nil? and !line.empty?
44
+ line = line.strip
45
+ if line =~ /\d+.*T\d+:\d+:\d+.\d+/ # in old-style ISO8601 format
46
+ last_run_timestamp = Time.strptime(line, ISO8601_FORMAT)
47
+ else
48
+ begin
49
+ last_run_timestamp = Time.strptime(line, STD_STRPTIME_FMT)
50
+ rescue => ex
51
+ problem = "Invalid format for timefile entry: #{line}"
52
+ action = "reverting to default of #{last_run_timestamp}"
53
+ @log.error("%s, %s" % [problem, action])
54
+ end
55
+ end
56
+ end
57
+ end
58
+ rescue => ex
59
+ problem = "Could not read time entry from #{@filename}"
60
+ action = "rewriting time file with default value"
61
+ @log.error("%s, %s" % [problem, action])
62
+ write(last_run_timestamp)
63
+ end
64
+ end
65
+
66
+ last_run_timestamp = default if last_run_timestamp.nil?
67
+ return last_run_timestamp
68
+ end
69
+
70
+ def write(timehack=nil)
71
+ %{
72
+ Writes the time (expected as a Ruby Time object)
73
+ to the file in ISO 8601 format
74
+ }
75
+ timehack = Time.now if timehack.nil?
76
+ stamp = timehack.utc.strftime(STD_TS_FORMAT)
77
+ File.open(@filename, 'w') { |file| file.puts "%s\n" % stamp }
78
+ end
79
+
80
+ end
@@ -0,0 +1,487 @@
1
+ # Copyright 2001-2013 Rally Software Development Corp. All Rights Reserved.
2
+
3
+ # This is the large stone level operation of the VCSEIF infrastructure,
4
+ # wherein the two connections held by the connector get instantiated,
5
+ # connected to their respective systems and exercised.
6
+
7
+ require 'time'
8
+ require 'open3' # so that ocra will pick this up
9
+
10
+ # require_relative "../vcseif" #dsmith - this should not be needed - available when you require in vcseif
11
+ # which will make the following available:
12
+ # connection.VCSConnection
13
+ # utils/exceptions.UnrecoverableException
14
+ # utils/exceptions.ConfigurationError
15
+ # utils/exceptions.OperationalError
16
+ # utils/rally_logger.RallyLogger
17
+ # utils/auxloader.PluginLoader
18
+ # utils/auxloader.ClassLoader
19
+
20
+ ##############################################################################################
21
+
22
+ class VCSConnector
23
+
24
+ VERSION = VCSEIF::Version
25
+
26
+ PLUGIN_SPEC_PATTERN = Regexp.compile('^(?<plugin_class>\w+)\s*\((?<plugin_config>[^\)]*)\)\s*$')
27
+ PLAIN_PLUGIN_SPEC_PATTERN = Regexp.compile('(?<plugin_class>\w+)\s*$')
28
+
29
+ RALLY_TIMESTAMP = "%Y-%m-%dT%H:%M:%S.%L %Z"
30
+ ISO8601_TIMESTAMP = "%Y-%m-%dT%H:%M:%S"
31
+
32
+ @@transformable_attributes = ['Author']
33
+
34
+ attr_reader :config, :log
35
+ attr_reader :rally_conn, :vcs_conn, :rally_conn_class, :vcs_conn_class
36
+ attr_reader :vcs_name
37
+ attr_reader :rally_conf, :vcs_conf, :svc_conf, :txfm_conf
38
+ attr_reader :transforms
39
+
40
+ def initialize(config, logger)
41
+ @config = config
42
+ @log = logger
43
+
44
+ conn_sections = []
45
+ begin
46
+ non_conn_sections = ['Services', 'Transforms']
47
+ conn_sections = config.topLevels.select {|section| !non_conn_sections.include?(section)}
48
+ if conn_sections.length != 2
49
+ problem = 'Config does not contain two connection sections'
50
+ confex = VCSEIF_Exceptions::ConfigurationError.new(problem)
51
+ raise confex, problem
52
+ end
53
+ rescue Exception => ex
54
+ problem = 'Config file lacks sufficient information for VCSConnector operation'
55
+ confex = VCSEIF_Exceptions::ConfigurationError.new(problem)
56
+ raise confex, problem
57
+ end
58
+
59
+ @rally_conn = nil
60
+ @vcs_conn = nil
61
+
62
+ @vcs_name = conn_sections.select {|section_name| section_name !~ /^Rally/}.first
63
+ @log.info("Rally VCS Connector for %s, version %s" % [@vcs_name, VERSION])
64
+ @log.info("Ruby platform: #{RUBY_PLATFORM}")
65
+ @log.info("Ruby version: #{RUBY_VERSION}")
66
+
67
+ internalizeConfig(config)
68
+
69
+ rally_conn_class_name = config.connectionClassName('Rally')
70
+ vcs_conn_class_name = config.connectionClassName(@vcs_name)
71
+
72
+ @log.debug("Loading #{rally_conn_class_name} class")
73
+ begin
74
+ @rally_conn_class = ClassLoader.loadConnectionClass('lib', rally_conn_class_name, 'rally_vcs_connection')
75
+ rescue Exception => ex
76
+ problem = 'Unable to load RallyVCSConnection class, %s' % ex.message
77
+ boomex = VCSEIF_Exceptions::UnrecoverableException.new(problem)
78
+ raise boomex, problem
79
+ end
80
+
81
+ @log.debug("Loading #{vcs_conn_class_name} class")
82
+ begin
83
+ @vcs_conn_class = ClassLoader.loadConnectionClass('lib', vcs_conn_class_name)
84
+ rescue Exception => ex
85
+ problem = 'Unable to load %sConnection class, %s' % [@vcs_name, ex.message]
86
+ boomex = VCSEIF_Exceptions::UnrecoverableException.new(problem)
87
+ raise boomex, problem
88
+ end
89
+
90
+ @log.debug('Obtaining Rally and VCS connections...')
91
+ establishConnections()
92
+ @log.debug('Rally and VCS connections established')
93
+ validate() # basically just calls validate on both connection instances
94
+
95
+ @log.debug("loading Transform class ...")
96
+ @transforms = loadTransforms(@txfm_conf)
97
+ @log.debug("Transform loaded")
98
+
99
+ @log.info("Initialization complete: Delegate connections operational, " +\
100
+ "aux facilities loaded, ready for scan/reflect ops")
101
+ end
102
+
103
+
104
+ def internalizeConfig(config)
105
+ @rally_conf = config.topLevel('Rally')
106
+ @vcs_conf = config.topLevel(@vcs_name)
107
+ @svc_conf = config.topLevel('Services')
108
+ @txfm_conf = config.topLevel('Transforms') || {}
109
+ # default the Author transform to Passthru if not specified in @txfm_conf
110
+ if not @txfm_conf.keys.include?('Author')
111
+ @txfm_conf['Author'] = 'Passthru'
112
+ end
113
+ ##
114
+ ## @log.debug("config.Services has |#{@svc_conf.inspect}|")
115
+ ##
116
+ end
117
+
118
+
119
+ def establishConnections()
120
+ @rally_conn = @rally_conn_class.new(@rally_conf, @log)
121
+ @vcs_conn = @vcs_conn_class.new( @vcs_conf, @log)
122
+ @vcs_conn.connect() # we do this before rally_conn to be able to get the vcs backend version
123
+ vcs_backend_version = @vcs_conn.getBackendVersion()
124
+
125
+ if @rally_conn != nil and @vcs_conn != nil and @rally_conn.respond_to?('set_integration_header')
126
+ # so we can use it in our X-Rally-Integrations header items here
127
+ rally_headers = {'name' => 'Rally VCSConnector for %s' % @vcs_name,
128
+ 'version' => VERSION,
129
+ 'vendor' => 'Rally Software',
130
+ 'other_version' => vcs_backend_version
131
+ }
132
+ @rally_conn.set_integration_header(rally_headers)
133
+ end
134
+ @rally_conn.setSourceIdentification(@vcs_conn.name, @vcs_conn.uri)
135
+ @rally_conn.connect()
136
+ end
137
+
138
+
139
+ private
140
+ def loadTransforms(txfms)
141
+ """
142
+ Given a txfms parm (nil or Hash with keys for each transformable field)
143
+ that specifies the plugin class and plugin config (Rally User entity attributes
144
+ names that could be used in the transformation process), validate the
145
+ sytactical validity of the transformation specification, and load the
146
+ module containing the transformer class and obtain an instance of that class.
147
+ Populate a Hash keyed by attribute name with the value for each key
148
+ an instance of the transform class.
149
+
150
+ The field names are from the Rally Changeset entity. They must have their
151
+ first letter capitalized and any other words in the entity name must also
152
+ have their first letter capitalized and there can be no embedded spaces.
153
+ """
154
+ attribute_transformer = {}
155
+ return attribute_transformer if txfms.nil?
156
+
157
+ txfms.each_pair do |attributeName, txfm_spec|
158
+ @log.debug("target attribute: %s txfm_spec: %s" % [attributeName, txfm_spec])
159
+ md = PLUGIN_SPEC_PATTERN.match(txfm_spec)
160
+ if not md.nil?
161
+ plugin_class_name, plugin_config = md[:plugin_class], md[:plugin_config]
162
+ else
163
+ md = PLAIN_PLUGIN_SPEC_PATTERN.match(txfm_spec)
164
+ if md.nil?
165
+ problem = "Bad Plugin Spec for %s : %s" % [attributeName, txfm_spec]
166
+ raise VCSEIF_Exceptions::ConfigurationError.new(problem)
167
+ end
168
+ plugin_class_name, plugin_config = md[:plugin_class], ""
169
+ end
170
+
171
+ begin
172
+ plugin_class = PluginLoader.getPlugin(plugin_class_name)
173
+ rescue => ex
174
+ raise
175
+ end
176
+ if plugin_class.nil?
177
+ problem = 'Unable to load plugin for %s' % plugin_class_name
178
+ raise VCSEIF_Exceptions::UnrecoverableException.new(problem)
179
+ end
180
+
181
+ @log.debug("Transform Class: %s" % plugin_class.name)
182
+ kwargs = { 'config' => plugin_config,
183
+ 'vcs_ident' => @vcs_conn.name,
184
+ 'rally' => @rally_conn,
185
+ 'logger' => @rally_conn.log
186
+ }
187
+ begin
188
+ transformer = plugin_class.new(kwargs)
189
+ rescue Exception => ex
190
+ problem = "Caught exception on attempt to get instance of #{plugin_class_name} "
191
+ problem << "plugin, #{ex.message}"
192
+ raise StandardError, problem
193
+ end
194
+ attribute_transformer[attributeName] = (transformer)
195
+ end
196
+
197
+ return attribute_transformer
198
+ end
199
+
200
+
201
+ public
202
+ def validate()
203
+ """
204
+ This calls the validate method on both the Rally and the VCS connections
205
+ """
206
+ valid = true
207
+
208
+ @log.info("Connector validation starting")
209
+
210
+ if not @rally_conn.validate()
211
+ @log.debug("RallyConnection validation failed")
212
+ return false
213
+ end
214
+ @log.debug("RallyConnection validation succeeded")
215
+
216
+ if not @vcs_conn.validate()
217
+ @log.debug("%sConnection validation failed" % @vcs_name)
218
+ return false
219
+ end
220
+ @log.debug("%sConnection validation succeeded" % @vcs_name)
221
+ @log.info("Connector validation completed")
222
+
223
+ return valid
224
+ end
225
+
226
+
227
+ def run(last_commit_timestamp, extension)
228
+ """
229
+ Where the rubber meets the road, or rather, controlling
230
+ the machinery that puts the rubber on the road...
231
+ """
232
+ preBatch(extension)
233
+ status, repo_changesets = reflectChangesetsInRally(last_commit_timestamp)
234
+ postBatch(extension, status, repo_changesets)
235
+ @rally_conn.disconnect()
236
+ @vcs_conn.disconnect()
237
+ return [status, repo_changesets]
238
+ end
239
+
240
+
241
+ def preBatch(extension)
242
+ """
243
+ """
244
+ if extension != nil and extension =~ /PreBatchAction/
245
+ preba = extension['PreBatchAction']
246
+ preba.service()
247
+ end
248
+ end
249
+
250
+
251
+ def postBatch(extension, status, repo_changesets)
252
+ """
253
+ """
254
+ if extension != nil and extension =~ /PostBatchAction/
255
+ postba = extension['PostBatchAction']
256
+ postba.service(status, repo_changesets)
257
+ end
258
+ end
259
+
260
+ #last_commit_timestamp is an instance of Time
261
+ def reflectChangesetsInRally(last_commit_timestamp)
262
+ """
263
+ The last commit timestamm is passed to Connection objects in UTC;
264
+ they are responsible for converting if necessary.
265
+ Time in log messages is always reported in UTC (aka Z or Zulu time).
266
+ """
267
+ status = false
268
+ rally = @rally_conn
269
+ vcs = @vcs_conn
270
+
271
+ rally_repo = @rally_conf['RepositoryName']
272
+ preview_mode = false
273
+ preview_item = @svc_conf['Preview'].to_s.downcase
274
+ preview_mode = true if ['true', 'yes', '1', 'ok', 'on'].include?(preview_item)
275
+ pm_tag = ''
276
+ action = 'adding'
277
+ if preview_mode == true
278
+ pm_tag = "Preview: "
279
+ action = "would add"
280
+ @log.info('***** Preview Mode ***** (no Changesets will be created in Rally)')
281
+ end
282
+
283
+ begin
284
+ rally_ref_time, vcs_ref_time = getRefTimes(last_commit_timestamp)
285
+ time_info = " ref times --> rally_ref_time: |%s| vcs_ref_time: |%s|"
286
+ @log.debug(time_info % [rally_ref_time, vcs_ref_time])
287
+ recent_rally_changesets = rally.getRecentChangesets(rally_ref_time)
288
+ @log.debug("Obtained Rally recent changesets info")
289
+ recent_vcs_changesets = vcs.getRecentChangesets( vcs_ref_time)
290
+ @log.debug("Obtained VCS recent changesets info")
291
+ unrecorded_changesets = identifyUnrecordedChangesets(recent_rally_changesets,
292
+ recent_vcs_changesets,
293
+ vcs_ref_time)
294
+ @log.info("%d unrecorded Changesets" % unrecorded_changesets.length)
295
+
296
+ recorded_changesets = []
297
+ unrecorded_changesets.each do |changeset|
298
+ cts = changeset.commit_timestamp.sub('Z', ' Z').sub('T', ' ')
299
+ desc = '%sChangeset %16.16s | %s | %s not yet reflected in Rally'
300
+ @log.debug(desc % [pm_tag, changeset.ident, cts, changeset.author])
301
+ committer = changeset.author
302
+ transformEligibleAttributes(changeset)
303
+
304
+ adds = collect_files_and_links(changeset.ident, changeset.adds, 'add')
305
+ mods = collect_files_and_links(changeset.ident, changeset.mods, 'mod')
306
+ dels = collect_files_and_links(changeset.ident, changeset.dels, 'del')
307
+
308
+ info = {
309
+ 'Revision' => changeset.ident,
310
+ 'CommitTimestamp' => changeset.commit_timestamp,
311
+ 'Committer' => committer,
312
+ 'Author' => changeset.author,
313
+ 'Message' => changeset.message,
314
+ 'Additions' => adds,
315
+ 'Modifications' => mods,
316
+ 'Deletions' => dels,
317
+ 'Uri' => @vcs_conn.get_rev_uri(changeset.ident)
318
+ }
319
+
320
+ desc = '%sChangeset %16.16s | %s | %s %s to Rally...'
321
+ @log.info(desc % [pm_tag, changeset.ident, cts, changeset.author, action])
322
+ @log.debug(" Rev URI: #{info['Uri']}")
323
+
324
+ if not preview_mode
325
+ begin
326
+ if not rally.changesetExists?(changeset.ident)
327
+ rally_changeset = rally.createChangeset(info)
328
+ recorded_changesets << rally_changeset
329
+ end
330
+ rescue Exception => ex
331
+ raise VCSEIF_Exceptions::OperationalError.new(ex.message)
332
+ end
333
+ end
334
+ end
335
+ status = true
336
+ rescue VCSEIF_Exceptions::OperationalError => ex
337
+ # already resulted in a log message
338
+ rescue VCSEIF_Exceptions::UnrecoverableException => ex
339
+ raise
340
+ rescue Exception => ex
341
+ raise
342
+ end
343
+ status = false if rally.operational_errors > 0
344
+
345
+ repo_changesets = {rally_repo => recorded_changesets}
346
+ return [status, repo_changesets]
347
+ end
348
+
349
+ def collect_files_and_links(revnum, file_list, operation)
350
+ return_list = []
351
+ file_list.each do |rev_file|
352
+ file_uri = @vcs_conn.get_file_rev_uri(revnum, rev_file, operation)
353
+ log.debug(" Change file URI: #{file_uri}")
354
+ return_list << {:file => rev_file, :link => file_uri}
355
+ end
356
+ return_list
357
+ end
358
+
359
+ def getRefTimes(last_commit_timestamp)
360
+ """
361
+ last_commit_timestamp is provided as an epoch seconds value.
362
+ Return a two-tuple of the reference time to be used for obtaining the
363
+ recent changesets in Rally and the reference time to be used for
364
+ obtaining the recent changesets in the target VCS system.
365
+ """
366
+ rally_lookback = get_config_lookback(@rally_conf)
367
+ vcs_lookback = get_config_lookback(@vcs_conf)
368
+ rally_ref_time = Time.at(last_commit_timestamp - rally_lookback).gmtime
369
+ vcs_ref_time = Time.at(last_commit_timestamp - vcs_lookback).gmtime
370
+ return [rally_ref_time, vcs_ref_time]
371
+ end
372
+
373
+ def get_config_lookback(config)
374
+ default_value = 3600 # 1 hour in seconds
375
+ lookback_val = config['Lookback']
376
+ return default_value if lookback_val.nil? || lookback_val.to_i <= 0
377
+
378
+ #default assumption is in minutes for lookback setting
379
+ lookback_val = lookback_val.to_s.downcase.strip
380
+ converted_val = lookback_val.to_i #rubyism: "5days".to_i = 5 "number".to_i = 0
381
+ return_val = (converted_val <= 0) ? default_value : converted_val * 60 #seconds per minute
382
+
383
+ if lookback_val.include?("d") #5 days, 5days, 5d, 5 d
384
+ return_val = (converted_val <= 0) ? default_value : converted_val * 86400 #seconds per day
385
+ elsif lookback_val.include?("h") #24 hours, 24 hours, 24h, 24 h
386
+ return_val = (converted_val <= 0) ? default_value : converted_val * 3600 #seconds per hour
387
+ end
388
+ return_val
389
+ end
390
+
391
+ def transformEligibleAttributes(changeset)
392
+ """
393
+ Check for a transformer for each transformable attribute,
394
+ if no such transformer, leave the changeset.<attribute> value unchanged.
395
+ Otherwise, get the transformer for the transformable attribute,
396
+ call it's service method with:
397
+ getattr(changeset, transformable_attribute.lower())
398
+ and use the result as the value for transformable_attribute,
399
+ modifying the changeset item.
400
+ """
401
+ @@transformable_attributes.each do |transformable_attribute|
402
+ @log.debug("tranformable attribute: |#{transformable_attribute}|")
403
+ if @transforms.include?(transformable_attribute)
404
+ @log.debug("tranforms has a transformer class for #{transformable_attribute}")
405
+ transformer = @transforms[transformable_attribute]
406
+ target_attr = transformable_attribute.downcase()
407
+ @log.debug("transformable target attribute: |#{target_attr}|")
408
+ target_value = changeset.send(target_attr)
409
+ @log.debug("transform target_value: |#{target_value}|")
410
+ transformed = transformer.service(target_value)
411
+ @log.debug("transformed to value: |#{transformed}|")
412
+ if not transformed.nil?
413
+ begin
414
+ ##
415
+ ## puts "#{changeset.class.name} attributes: #{changeset.instance_variables}"
416
+ ##
417
+ changeset.send("#{target_attr}=", transformed)
418
+ rescue Exception => ex
419
+ puts "#{ex.message}"
420
+ for bt_line in ex.backtrace do
421
+ puts bt_line.sub(Dir.pwd, '${CWD}')
422
+ end
423
+ puts "#{ex.inspect}"
424
+ end
425
+ ##
426
+ ## puts "Changeset.#{target_attr} new value: |#{changeset.send(target_attr)}| should be set to |#{transformed}| ?"
427
+ ##
428
+ else
429
+ # TODO: should we log this?
430
+ end
431
+ end
432
+ end
433
+ end
434
+
435
+
436
+ def identifyUnrecordedChangesets(rally_changesets, vcs_changesets, ref_time)
437
+ """
438
+ Assume that for purposes of matching that the changeset identity in either system
439
+ can be determined by the rev_ident (as opposed to the rev_num).
440
+ If there are items in the rally_changesets for which there is a counterpart in
441
+ the vcs_changesets, the information has already been reflected in Rally. --> NOOP
442
+ If there are items in the rally_changesets for which there is no counterpart in
443
+ the vcs_changesets, information has been lost, dat be berry berry bad... --> ERROR
444
+ If there are items in the vcs_changesets for which there is no counterpart in
445
+ the rally_changesets, those items are candidates to be reflected in Rally --> REFLECT
446
+ """
447
+ rally_tank = {}
448
+ # requirement: rc.ident <-- rc.Revision
449
+ rally_changesets.each {|rally_chgset| rally_tank[rally_chgset.ident] = rally_chgset}
450
+
451
+ vcs_tank = {}
452
+ # requirement: vc.ident <-- {node} or {hash} or {number}, etc .
453
+ ##
454
+ ## puts "vcs_changesets ..."
455
+ ## vcs_changesets.each { |cset| { puts cset.details }
456
+ ##
457
+ vcs_changesets.each {|vcs_chgset| vcs_tank[vcs_chgset.ident] = vcs_chgset}
458
+
459
+ reflected_changesets = rally_changesets.select {|rcs| vcs_tank.has_key?(rcs.ident) == true }
460
+ unpaired_changesets = rally_changesets.select {|rcs| vcs_tank.has_key?(rcs.ident) == false}
461
+ unrecorded_changesets = vcs_changesets.select {|vcs| rally_tank.has_key?(vcs.ident) == false}
462
+
463
+ if unpaired_changesets.length > 0
464
+ #lost_changesets = unpaired_changesets.select {|rcs| rcs.CommitTimestamp > ref_time}
465
+ lost_changesets = []
466
+ unpaired_changesets.each do |rcs|
467
+ rally_commit_time = Time.strptime(rcs.CommitTimestamp[0..18], ISO8601_TIMESTAMP)
468
+ lost_changesets << rcs if rally_commit_time > ref_time
469
+ end
470
+ # Hold off on blurting this information in the log. It may just lead to confusion.
471
+ # There _could_ be other systems throwing info into the Rally Change/Changeset pool
472
+ # (although we wouldn't expect that to happen very often).
473
+ #if not lost_changesets.empty?
474
+ # problem = "Changesets exist in Rally for which there is no "
475
+ # problem << "longer a counterpart changeset in the target VCS\n "
476
+ # cs_idents = lost_changesets.collect {|cs| cs.ident}
477
+ # @log.error(problem + cs_idents.join("\n "))
478
+ #end
479
+ end
480
+
481
+ return unrecorded_changesets
482
+ end
483
+
484
+ end # VCSConnector class
485
+
486
+ ####################################################################################
487
+