vcseif 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+