1 |
|
# redMine - project management software
|
|
1 |
# Redmine - project management software
|
2 |
2 |
# Copyright (C) 2006-2007 Jean-Philippe Lang
|
|
3 |
# Copyright (C) 2007-2011 Trac/Redmine Community
|
|
4 |
# References:
|
|
5 |
# - http://www.redmine.org/boards/1/topics/12273 (Trac Importer Patch Coordination)
|
|
6 |
# - http://github.com/landy2005/Redmine-migrate-from-Trac
|
3 |
7 |
#
|
4 |
8 |
# This program is free software; you can redistribute it and/or
|
5 |
9 |
# modify it under the terms of the GNU General Public License
|
... | ... | |
52 |
56 |
'blocker' => priorities[4]
|
53 |
57 |
}
|
54 |
58 |
|
55 |
|
TRACKER_BUG = Tracker.find_by_position(1)
|
56 |
|
TRACKER_FEATURE = Tracker.find_by_position(2)
|
|
59 |
TRACKER_BUG = Tracker.find_by_name('Bug')
|
|
60 |
TRACKER_FEATURE = Tracker.find_by_name('Feature')
|
|
61 |
TRACKER_SUPPORT = Tracker.find_by_name('Support')
|
57 |
62 |
DEFAULT_TRACKER = TRACKER_BUG
|
58 |
63 |
TRACKER_MAPPING = {'defect' => TRACKER_BUG,
|
59 |
64 |
'enhancement' => TRACKER_FEATURE,
|
60 |
|
'task' => TRACKER_FEATURE,
|
|
65 |
'task' => TRACKER_SUPPORT,
|
61 |
66 |
'patch' =>TRACKER_FEATURE
|
62 |
67 |
}
|
63 |
68 |
|
... | ... | |
68 |
73 |
ROLE_MAPPING = {'admin' => manager_role,
|
69 |
74 |
'developer' => developer_role
|
70 |
75 |
}
|
|
76 |
# Add an Hash Table for comments' updatable fields
|
|
77 |
PROP_MAPPING = {'status' => 'status_id',
|
|
78 |
'owner' => 'assigned_to_id',
|
|
79 |
'component' => 'category_id',
|
|
80 |
'milestone' => 'fixed_version_id',
|
|
81 |
'priority' => 'priority_id',
|
|
82 |
'summary' => 'subject',
|
|
83 |
'type' => 'tracker_id'}
|
|
84 |
|
|
85 |
# Hash table to map completion ratio
|
|
86 |
RATIO_MAPPING = {'' => 0,
|
|
87 |
'fixed' => 100,
|
|
88 |
'invalid' => 0,
|
|
89 |
'wontfix' => 0,
|
|
90 |
'duplicate' => 100,
|
|
91 |
'worksforme' => 0}
|
71 |
92 |
|
72 |
93 |
class ::Time
|
73 |
94 |
class << self
|
... | ... | |
153 |
174 |
private
|
154 |
175 |
def trac_fullpath
|
155 |
176 |
attachment_type = read_attribute(:type)
|
156 |
|
trac_file = filename.gsub( /[^a-zA-Z0-9\-_\.!~*']/n ) {|x| sprintf('%%%02x', x[0]) }
|
157 |
|
"#{TracMigrate.trac_attachments_directory}/#{attachment_type}/#{id}/#{trac_file}"
|
|
177 |
trac_file = filename.gsub( /[^a-zA-Z0-9\-_\.!~*]/n ) {|x| sprintf('%%%02X', x[0]) }
|
|
178 |
trac_dir = id.gsub( /[^a-zA-Z0-9\-_\.!~*\\\/]/n ) {|x| sprintf('%%%02X', x[0]) }
|
|
179 |
"#{TracMigrate.trac_attachments_directory}/#{attachment_type}/#{trac_dir}/#{trac_file}"
|
158 |
180 |
end
|
159 |
181 |
end
|
160 |
182 |
|
... | ... | |
192 |
214 |
def time; Time.at(read_attribute(:time)) end
|
193 |
215 |
end
|
194 |
216 |
|
195 |
|
TRAC_WIKI_PAGES = %w(InterMapTxt InterTrac InterWiki RecentChanges SandBox TracAccessibility TracAdmin TracBackup TracBrowser TracCgi TracChangeset \
|
|
217 |
TRAC_WIKI_PAGES = %w(InterMapTxt InterTrac InterWiki RecentChanges SandBox TracAccessibility TracAdmin TracBackup \
|
|
218 |
TracBrowser TracCgi TracChangeset TracInstallPlatforms TracMultipleProjects TracModWSGI \
|
196 |
219 |
TracEnvironment TracFastCgi TracGuide TracImport TracIni TracInstall TracInterfaceCustomization \
|
197 |
220 |
TracLinks TracLogging TracModPython TracNotification TracPermissions TracPlugins TracQuery \
|
198 |
221 |
TracReports TracRevisionLog TracRoadmap TracRss TracSearch TracStandalone TracSupport TracSyntaxColoring TracTickets \
|
199 |
222 |
TracTicketsCustomFields TracTimeline TracUnicode TracUpgrade TracWiki WikiDeletePage WikiFormatting \
|
200 |
223 |
WikiHtml WikiMacros WikiNewPage WikiPageNames WikiProcessors WikiRestructuredText WikiRestructuredTextLinks \
|
201 |
|
CamelCase TitleIndex)
|
202 |
|
|
|
224 |
CamelCase TitleIndex TracNavigation TracFineGrainedPermissions TracWorkflow TimingAndEstimationPluginUserManual \
|
|
225 |
PageTemplates)
|
203 |
226 |
class TracWikiPage < ActiveRecord::Base
|
204 |
227 |
set_table_name :wiki
|
205 |
228 |
set_primary_key :name
|
... | ... | |
225 |
248 |
set_table_name :session_attribute
|
226 |
249 |
end
|
227 |
250 |
|
|
251 |
# TODO put your Login Mapping in this method and rename method below
|
|
252 |
# def self.find_or_create_user(username, project_member = false)
|
|
253 |
# TRAC_REDMINE_LOGIN_MAP = []
|
|
254 |
# return TRAC_REDMINE_LOGIN_MAP[username]
|
|
255 |
# OR more hard-coded:
|
|
256 |
# if username == 'TracX'
|
|
257 |
# username = 'RedmineX'
|
|
258 |
# elsif username == 'gilles'
|
|
259 |
# username = 'gcornu'
|
|
260 |
# #elseif ...
|
|
261 |
# else
|
|
262 |
# username = 'gcornu'
|
|
263 |
# end
|
|
264 |
# return User.find_by_login(username)
|
|
265 |
# end
|
|
266 |
|
228 |
267 |
def self.find_or_create_user(username, project_member = false)
|
229 |
268 |
return User.anonymous if username.blank?
|
230 |
269 |
|
... | ... | |
241 |
280 |
if name_attr = TracSessionAttribute.find_by_sid_and_name(username, 'name')
|
242 |
281 |
name = name_attr.value
|
243 |
282 |
end
|
244 |
|
name =~ (/(.*)(\s+\w+)?/)
|
|
283 |
name =~ (/(.+?)(?:[\ \t]+(.+)?|[\ \t]+|)$/)
|
245 |
284 |
fn = $1.strip
|
|
285 |
# Add a dash for lastname or the user is not saved (bugfix)
|
246 |
286 |
ln = ($2 || '-').strip
|
247 |
287 |
|
248 |
288 |
u = User.new :mail => mail.gsub(/[^-@a-z0-9\.]/i, '-'),
|
... | ... | |
271 |
311 |
|
272 |
312 |
# Basic wiki syntax conversion
|
273 |
313 |
def self.convert_wiki_text(text)
|
274 |
|
# Titles
|
275 |
|
text = text.gsub(/^(\=+)\s(.+)\s(\=+)/) {|s| "\nh#{$1.length}. #{$2}\n"}
|
276 |
|
# External Links
|
277 |
|
text = text.gsub(/\[(http[^\s]+)\s+([^\]]+)\]/) {|s| "\"#{$2}\":#{$1}"}
|
278 |
|
# Ticket links:
|
279 |
|
# [ticket:234 Text],[ticket:234 This is a test]
|
280 |
|
text = text.gsub(/\[ticket\:([^\ ]+)\ (.+?)\]/, '"\2":/issues/show/\1')
|
281 |
|
# ticket:1234
|
282 |
|
# #1 is working cause Redmine uses the same syntax.
|
283 |
|
text = text.gsub(/ticket\:([^\ ]+)/, '#\1')
|
284 |
|
# Milestone links:
|
285 |
|
# [milestone:"0.1.0 Mercury" Milestone 0.1.0 (Mercury)]
|
286 |
|
# The text "Milestone 0.1.0 (Mercury)" is not converted,
|
287 |
|
# cause Redmine's wiki does not support this.
|
288 |
|
text = text.gsub(/\[milestone\:\"([^\"]+)\"\ (.+?)\]/, 'version:"\1"')
|
289 |
|
# [milestone:"0.1.0 Mercury"]
|
290 |
|
text = text.gsub(/\[milestone\:\"([^\"]+)\"\]/, 'version:"\1"')
|
291 |
|
text = text.gsub(/milestone\:\"([^\"]+)\"/, 'version:"\1"')
|
292 |
|
# milestone:0.1.0
|
293 |
|
text = text.gsub(/\[milestone\:([^\ ]+)\]/, 'version:\1')
|
294 |
|
text = text.gsub(/milestone\:([^\ ]+)/, 'version:\1')
|
295 |
|
# Internal Links
|
296 |
|
text = text.gsub(/\[\[BR\]\]/, "\n") # This has to go before the rules below
|
297 |
|
text = text.gsub(/\[\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
|
298 |
|
text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
|
299 |
|
text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
|
300 |
|
text = text.gsub(/\[wiki:([^\s\]]+)\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
|
301 |
|
text = text.gsub(/\[wiki:([^\s\]]+)\s(.*)\]/) {|s| "[[#{$1.delete(',./?;|:')}|#{$2.delete(',./?;|:')}]]"}
|
302 |
|
|
303 |
|
# Links to pages UsingJustWikiCaps
|
304 |
|
text = text.gsub(/([^!]|^)(^| )([A-Z][a-z]+[A-Z][a-zA-Z]+)/, '\\1\\2[[\3]]')
|
305 |
|
# Normalize things that were supposed to not be links
|
306 |
|
# like !NotALink
|
307 |
|
text = text.gsub(/(^| )!([A-Z][A-Za-z]+)/, '\1\2')
|
308 |
|
# Revisions links
|
309 |
|
text = text.gsub(/\[(\d+)\]/, 'r\1')
|
310 |
|
# Ticket number re-writing
|
311 |
|
text = text.gsub(/#(\d+)/) do |s|
|
312 |
|
if $1.length < 10
|
313 |
|
# TICKET_MAP[$1.to_i] ||= $1
|
314 |
|
"\##{TICKET_MAP[$1.to_i] || $1}"
|
315 |
|
else
|
316 |
|
s
|
317 |
|
end
|
318 |
|
end
|
319 |
|
# We would like to convert the Code highlighting too
|
320 |
|
# This will go into the next line.
|
321 |
|
shebang_line = false
|
322 |
|
# Reguar expression for start of code
|
323 |
|
pre_re = /\{\{\{/
|
324 |
|
# Code hightlighing...
|
325 |
|
shebang_re = /^\#\!([a-z]+)/
|
326 |
|
# Regular expression for end of code
|
327 |
|
pre_end_re = /\}\}\}/
|
328 |
|
|
329 |
|
# Go through the whole text..extract it line by line
|
330 |
|
text = text.gsub(/^(.*)$/) do |line|
|
331 |
|
m_pre = pre_re.match(line)
|
332 |
|
if m_pre
|
333 |
|
line = '<pre>'
|
334 |
|
else
|
335 |
|
m_sl = shebang_re.match(line)
|
336 |
|
if m_sl
|
337 |
|
shebang_line = true
|
338 |
|
line = '<code class="' + m_sl[1] + '">'
|
339 |
|
end
|
340 |
|
m_pre_end = pre_end_re.match(line)
|
341 |
|
if m_pre_end
|
342 |
|
line = '</pre>'
|
343 |
|
if shebang_line
|
344 |
|
line = '</code>' + line
|
345 |
|
end
|
346 |
|
end
|
347 |
|
end
|
348 |
|
line
|
349 |
|
end
|
350 |
|
|
351 |
|
# Highlighting
|
352 |
|
text = text.gsub(/'''''([^\s])/, '_*\1')
|
353 |
|
text = text.gsub(/([^\s])'''''/, '\1*_')
|
354 |
|
text = text.gsub(/'''/, '*')
|
355 |
|
text = text.gsub(/''/, '_')
|
356 |
|
text = text.gsub(/__/, '+')
|
357 |
|
text = text.gsub(/~~/, '-')
|
358 |
|
text = text.gsub(/`/, '@')
|
359 |
|
text = text.gsub(/,,/, '~')
|
360 |
|
# Lists
|
361 |
|
text = text.gsub(/^([ ]+)\* /) {|s| '*' * $1.length + " "}
|
362 |
|
|
363 |
|
text
|
|
314 |
convert_wiki_text_mapping(text, TICKET_MAP)
|
364 |
315 |
end
|
365 |
316 |
|
366 |
317 |
def self.migrate
|
... | ... | |
377 |
328 |
migrated_wiki_edits = 0
|
378 |
329 |
migrated_wiki_attachments = 0
|
379 |
330 |
|
380 |
|
#Wiki system initializing...
|
|
331 |
# Wiki system initializing...
|
381 |
332 |
@target_project.wiki.destroy if @target_project.wiki
|
382 |
333 |
@target_project.reload
|
383 |
334 |
wiki = Wiki.new(:project => @target_project, :start_page => 'WikiStart')
|
384 |
335 |
wiki_edit_count = 0
|
385 |
336 |
|
386 |
337 |
# Components
|
387 |
|
print "Migrating components"
|
|
338 |
who = "Migrating components"
|
388 |
339 |
issues_category_map = {}
|
|
340 |
components_total = TracComponent.count
|
389 |
341 |
TracComponent.find(:all).each do |component|
|
390 |
|
print '.'
|
391 |
|
STDOUT.flush
|
392 |
342 |
c = IssueCategory.new :project => @target_project,
|
393 |
343 |
:name => encode(component.name[0, limit_for(IssueCategory, 'name')])
|
|
344 |
# Owner
|
|
345 |
unless component.owner.blank?
|
|
346 |
c.assigned_to = find_or_create_user(component.owner, true)
|
|
347 |
end
|
394 |
348 |
next unless c.save
|
395 |
349 |
issues_category_map[component.name] = c
|
396 |
350 |
migrated_components += 1
|
|
351 |
simplebar(who, migrated_components, components_total)
|
397 |
352 |
end
|
398 |
|
puts
|
|
353 |
puts if migrated_components < components_total
|
399 |
354 |
|
400 |
355 |
# Milestones
|
401 |
|
print "Migrating milestones"
|
|
356 |
who = "Migrating milestones"
|
402 |
357 |
version_map = {}
|
|
358 |
milestone_wiki = Array.new
|
|
359 |
milestones_total = TracMilestone.count
|
403 |
360 |
TracMilestone.find(:all).each do |milestone|
|
404 |
|
print '.'
|
405 |
|
STDOUT.flush
|
406 |
361 |
# First we try to find the wiki page...
|
407 |
362 |
p = wiki.find_or_new_page(milestone.name.to_s)
|
408 |
363 |
p.content = WikiContent.new(:page => p) if p.new_record?
|
... | ... | |
415 |
370 |
:name => encode(milestone.name[0, limit_for(Version, 'name')]),
|
416 |
371 |
:description => nil,
|
417 |
372 |
:wiki_page_title => milestone.name.to_s,
|
418 |
|
:effective_date => milestone.completed
|
|
373 |
:effective_date => (!milestone.completed.blank? ? milestone.completed : (!milestone.due.blank? ? milestone.due : nil))
|
419 |
374 |
|
420 |
375 |
next unless v.save
|
421 |
376 |
version_map[milestone.name] = v
|
|
377 |
milestone_wiki.push(milestone.name);
|
422 |
378 |
migrated_milestones += 1
|
|
379 |
simplebar(who, migrated_milestones, milestones_total)
|
423 |
380 |
end
|
424 |
|
puts
|
|
381 |
puts if migrated_milestones < milestones_total
|
425 |
382 |
|
426 |
383 |
# Custom fields
|
427 |
384 |
# TODO: read trac.ini instead
|
428 |
|
print "Migrating custom fields"
|
|
385 |
#print "Migrating custom fields"
|
429 |
386 |
custom_field_map = {}
|
430 |
387 |
TracTicketCustom.find_by_sql("SELECT DISTINCT name FROM #{TracTicketCustom.table_name}").each do |field|
|
431 |
|
print '.'
|
432 |
|
STDOUT.flush
|
|
388 |
# use line below and adapt the WHERE condifiton, if you want to skip some unused custom fields
|
|
389 |
# TracTicketCustom.find_by_sql("SELECT DISTINCT name FROM #{TracTicketCustom.table_name} WHERE name NOT IN ('duration', 'software')").each do |field|
|
|
390 |
#print '.' # Maybe not needed this out?
|
|
391 |
#STDOUT.flush
|
433 |
392 |
# Redmine custom field name
|
434 |
393 |
field_name = encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize
|
|
394 |
|
|
395 |
# # Ugly hack to skip custom field 'Browser', which is in 'list' format...
|
|
396 |
# next if field_name == 'browser'
|
|
397 |
|
435 |
398 |
# Find if the custom already exists in Redmine
|
436 |
399 |
f = IssueCustomField.find_by_name(field_name)
|
|
400 |
# Ugly hack to handle billable checkbox. Would require to read the ini file to be cleaner
|
|
401 |
if field_name == 'Billable'
|
|
402 |
format = 'bool'
|
|
403 |
else
|
|
404 |
format = 'string'
|
|
405 |
end
|
437 |
406 |
# Or create a new one
|
438 |
407 |
f ||= IssueCustomField.create(:name => encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize,
|
439 |
|
:field_format => 'string')
|
|
408 |
:field_format => format)
|
440 |
409 |
|
441 |
410 |
next if f.new_record?
|
442 |
411 |
f.trackers = Tracker.find(:all)
|
443 |
412 |
f.projects << @target_project
|
444 |
413 |
custom_field_map[field.name] = f
|
445 |
414 |
end
|
446 |
|
puts
|
|
415 |
#puts
|
|
416 |
|
|
417 |
# # Trac custom field 'Browser' field as a Redmine custom field
|
|
418 |
# b = IssueCustomField.find(:first, :conditions => { :name => "Browser" })
|
|
419 |
# b = IssueCustomField.new(:name => 'Browser',
|
|
420 |
# :field_format => 'list',
|
|
421 |
# :is_filter => true) if b.nil?
|
|
422 |
# b.trackers << [TRACKER_BUG, TRACKER_FEATURE, TRACKER_SUPPORT]
|
|
423 |
# b.projects << @target_project
|
|
424 |
# b.possible_values = (b.possible_values + %w(IE6 IE7 IE8 IE9 Firefox Chrome Safari Opera)).flatten.compact.uniq
|
|
425 |
# b.save!
|
|
426 |
# custom_field_map['browser'] = b
|
447 |
427 |
|
448 |
428 |
# Trac 'resolution' field as a Redmine custom field
|
449 |
429 |
r = IssueCustomField.find(:first, :conditions => { :name => "Resolution" })
|
450 |
430 |
r = IssueCustomField.new(:name => 'Resolution',
|
451 |
431 |
:field_format => 'list',
|
452 |
432 |
:is_filter => true) if r.nil?
|
453 |
|
r.trackers = Tracker.find(:all)
|
|
433 |
r.trackers << [TRACKER_BUG, TRACKER_FEATURE, TRACKER_SUPPORT]
|
454 |
434 |
r.projects << @target_project
|
455 |
435 |
r.possible_values = (r.possible_values + %w(fixed invalid wontfix duplicate worksforme)).flatten.compact.uniq
|
456 |
436 |
r.save!
|
457 |
437 |
custom_field_map['resolution'] = r
|
458 |
438 |
|
|
439 |
# Trac 'keywords' field as a Redmine custom field
|
|
440 |
k = IssueCustomField.find(:first, :conditions => { :name => "Keywords" })
|
|
441 |
k = IssueCustomField.new(:name => 'Keywords',
|
|
442 |
:field_format => 'string',
|
|
443 |
:is_filter => true) if k.nil?
|
|
444 |
k.trackers = Tracker.find(:all)
|
|
445 |
k.projects << @target_project
|
|
446 |
k.save!
|
|
447 |
custom_field_map['keywords'] = k
|
|
448 |
|
|
449 |
# Trac 'version' field as a Redmine custom field, taking advantage of feature #2096 (available since Redmine 1.2.0)
|
|
450 |
v = IssueCustomField.find(:first, :conditions => { :name => "Found in Version" })
|
|
451 |
v = IssueCustomField.new(:name => 'Found in Version',
|
|
452 |
:field_format => 'version',
|
|
453 |
:is_filter => true) if v.nil?
|
|
454 |
# Only apply to BUG tracker (?)
|
|
455 |
v.trackers << TRACKER_BUG
|
|
456 |
#v.trackers << [TRACKER_BUG, TRACKER_FEATURE]
|
|
457 |
|
|
458 |
# Affect custom field to current Project
|
|
459 |
v.projects << @target_project
|
|
460 |
|
|
461 |
v.save!
|
|
462 |
custom_field_map['found_in_version'] = v
|
|
463 |
|
|
464 |
# Trac ticket id as a Redmine custom field
|
|
465 |
tid = IssueCustomField.find(:first, :conditions => { :name => "TracID" })
|
|
466 |
tid = IssueCustomField.new(:name => 'TracID',
|
|
467 |
:field_format => 'string',
|
|
468 |
:is_filter => true) if tid.nil?
|
|
469 |
tid.trackers << [TRACKER_BUG, TRACKER_FEATURE, TRACKER_SUPPORT]
|
|
470 |
tid.projects << @target_project
|
|
471 |
tid.save!
|
|
472 |
custom_field_map['tracid'] = tid
|
|
473 |
|
459 |
474 |
# Tickets
|
460 |
|
print "Migrating tickets"
|
|
475 |
who = "Migrating tickets"
|
|
476 |
tickets_total = TracTicket.count
|
461 |
477 |
TracTicket.find_each(:batch_size => 200) do |ticket|
|
462 |
|
print '.'
|
463 |
|
STDOUT.flush
|
464 |
478 |
i = Issue.new :project => @target_project,
|
465 |
479 |
:subject => encode(ticket.summary[0, limit_for(Issue, 'subject')]),
|
466 |
|
:description => convert_wiki_text(encode(ticket.description)),
|
|
480 |
:description => encode(ticket.description),
|
467 |
481 |
:priority => PRIORITY_MAPPING[ticket.priority] || DEFAULT_PRIORITY,
|
468 |
482 |
:created_on => ticket.time
|
469 |
|
i.author = find_or_create_user(ticket.reporter)
|
|
483 |
# Add the ticket's author to project's reporter list (bugfix)
|
|
484 |
i.author = find_or_create_user(ticket.reporter,true)
|
|
485 |
# Extrapolate done_ratio from ticket's resolution
|
|
486 |
i.done_ratio = RATIO_MAPPING[ticket.resolution] || 0
|
470 |
487 |
i.category = issues_category_map[ticket.component] unless ticket.component.blank?
|
471 |
488 |
i.fixed_version = version_map[ticket.milestone] unless ticket.milestone.blank?
|
472 |
489 |
i.status = STATUS_MAPPING[ticket.status] || DEFAULT_STATUS
|
473 |
490 |
i.tracker = TRACKER_MAPPING[ticket.ticket_type] || DEFAULT_TRACKER
|
474 |
|
i.id = ticket.id unless Issue.exists?(ticket.id)
|
|
491 |
# Use the Redmine-genereated new ticket ID anyway (no Ticket ID recycling)
|
|
492 |
#i.id = ticket.id unless Issue.exists?(ticket.id)
|
475 |
493 |
next unless Time.fake(ticket.changetime) { i.save }
|
476 |
494 |
TICKET_MAP[ticket.id] = i.id
|
477 |
495 |
migrated_tickets += 1
|
478 |
|
|
|
496 |
simplebar(who, migrated_tickets, tickets_total)
|
479 |
497 |
# Owner
|
480 |
498 |
unless ticket.owner.blank?
|
481 |
499 |
i.assigned_to = find_or_create_user(ticket.owner, true)
|
482 |
500 |
Time.fake(ticket.changetime) { i.save }
|
483 |
501 |
end
|
484 |
|
|
485 |
|
# Comments and status/resolution changes
|
|
502 |
# Handle CC field
|
|
503 |
# Feature disabled (CC field almost never used, No time to validate/test this recent improvments from A. Callegaro)
|
|
504 |
# ticket.cc.split(',').each do |email|
|
|
505 |
# w = Watcher.new :watchable_type => 'Issue',
|
|
506 |
# :watchable_id => i.id,
|
|
507 |
# :user_id => find_or_create_user(email.strip).id
|
|
508 |
# w.save
|
|
509 |
# end
|
|
510 |
|
|
511 |
# Necessary to handle direct link to note from timelogs and putting the right start time in issue
|
|
512 |
noteid = 1
|
|
513 |
# Comments and status/resolution/keywords changes
|
486 |
514 |
ticket.changes.group_by(&:time).each do |time, changeset|
|
487 |
515 |
status_change = changeset.select {|change| change.field == 'status'}.first
|
488 |
516 |
resolution_change = changeset.select {|change| change.field == 'resolution'}.first
|
|
517 |
keywords_change = changeset.select {|change| change.field == 'keywords'}.first
|
489 |
518 |
comment_change = changeset.select {|change| change.field == 'comment'}.first
|
|
519 |
# Handle more ticket changes (owner, component, milestone, priority, summary, type, done_ratio and hours)
|
|
520 |
assigned_change = changeset.select {|change| change.field == 'owner'}.first
|
|
521 |
category_change = changeset.select {|change| change.field == 'component'}.first
|
|
522 |
version_change = changeset.select {|change| change.field == 'milestone'}.first
|
|
523 |
priority_change = changeset.select {|change| change.field == 'priority'}.first
|
|
524 |
subject_change = changeset.select {|change| change.field == 'summary'}.first
|
|
525 |
tracker_change = changeset.select {|change| change.field == 'type'}.first
|
|
526 |
time_change = changeset.select {|change| change.field == 'hours'}.first
|
|
527 |
|
|
528 |
# If it's the first note then we set the start working time to handle calendar and gantts
|
|
529 |
if noteid == 1
|
|
530 |
i.start_date = time
|
|
531 |
end
|
490 |
532 |
|
491 |
|
n = Journal.new :notes => (comment_change ? convert_wiki_text(encode(comment_change.newvalue)) : ''),
|
|
533 |
n = Journal.new :notes => (comment_change ? encode(comment_change.newvalue) : ''),
|
492 |
534 |
:created_on => time
|
493 |
535 |
n.user = find_or_create_user(changeset.first.author)
|
494 |
536 |
n.journalized = i
|
... | ... | |
497 |
539 |
STATUS_MAPPING[status_change.newvalue] &&
|
498 |
540 |
(STATUS_MAPPING[status_change.oldvalue] != STATUS_MAPPING[status_change.newvalue])
|
499 |
541 |
n.details << JournalDetail.new(:property => 'attr',
|
500 |
|
:prop_key => 'status_id',
|
|
542 |
:prop_key => PROP_MAPPING['status'],
|
501 |
543 |
:old_value => STATUS_MAPPING[status_change.oldvalue].id,
|
502 |
544 |
:value => STATUS_MAPPING[status_change.newvalue].id)
|
503 |
545 |
end
|
... | ... | |
506 |
548 |
:prop_key => custom_field_map['resolution'].id,
|
507 |
549 |
:old_value => resolution_change.oldvalue,
|
508 |
550 |
:value => resolution_change.newvalue)
|
|
551 |
# Add a change for the done_ratio
|
|
552 |
n.details << JournalDetail.new(:property => 'attr',
|
|
553 |
:prop_key => 'done_ratio',
|
|
554 |
:old_value => RATIO_MAPPING[resolution_change.oldvalue],
|
|
555 |
:value => RATIO_MAPPING[resolution_change.newvalue])
|
|
556 |
# Arbitrary set the due time to the day the ticket was resolved for calendar and gantts
|
|
557 |
case RATIO_MAPPING[resolution_change.newvalue]
|
|
558 |
when 0
|
|
559 |
i.due_date = nil
|
|
560 |
when 100
|
|
561 |
i.due_date = time
|
|
562 |
end
|
|
563 |
end
|
|
564 |
if keywords_change
|
|
565 |
n.details << JournalDetail.new(:property => 'cf',
|
|
566 |
:prop_key => custom_field_map['keywords'].id,
|
|
567 |
:old_value => keywords_change.oldvalue,
|
|
568 |
:value => keywords_change.newvalue)
|
|
569 |
end
|
|
570 |
# Handle assignement/owner changes
|
|
571 |
if assigned_change
|
|
572 |
n.details << JournalDetail.new(:property => 'attr',
|
|
573 |
:prop_key => PROP_MAPPING['owner'],
|
|
574 |
:old_value => find_or_create_user(assigned_change.oldvalue, true),
|
|
575 |
:value => find_or_create_user(assigned_change.newvalue, true))
|
|
576 |
end
|
|
577 |
# Handle component/category changes
|
|
578 |
if category_change
|
|
579 |
n.details << JournalDetail.new(:property => 'attr',
|
|
580 |
:prop_key => PROP_MAPPING['component'],
|
|
581 |
:old_value => issues_category_map[category_change.oldvalue],
|
|
582 |
:value => issues_category_map[category_change.newvalue])
|
|
583 |
end
|
|
584 |
# Handle version/mileston changes
|
|
585 |
if version_change
|
|
586 |
n.details << JournalDetail.new(:property => 'attr',
|
|
587 |
:prop_key => PROP_MAPPING['milestone'],
|
|
588 |
:old_value => version_map[version_change.oldvalue],
|
|
589 |
:value => version_map[version_change.newvalue])
|
|
590 |
end
|
|
591 |
# Handle priority changes
|
|
592 |
if priority_change
|
|
593 |
n.details << JournalDetail.new(:property => 'attr',
|
|
594 |
:prop_key => PROP_MAPPING['priority'],
|
|
595 |
:old_value => PRIORITY_MAPPING[priority_change.oldvalue],
|
|
596 |
:value => PRIORITY_MAPPING[priority_change.newvalue])
|
|
597 |
end
|
|
598 |
# Handle subject/summary changes
|
|
599 |
if subject_change
|
|
600 |
n.details << JournalDetail.new(:property => 'attr',
|
|
601 |
:prop_key => PROP_MAPPING['summary'],
|
|
602 |
:old_value => encode(subject_change.oldvalue[0, limit_for(Issue, 'subject')]),
|
|
603 |
:value => encode(subject_change.newvalue[0, limit_for(Issue, 'subject')]))
|
|
604 |
end
|
|
605 |
# Handle tracker/type (bug, feature) changes
|
|
606 |
if tracker_change
|
|
607 |
n.details << JournalDetail.new(:property => 'attr',
|
|
608 |
:prop_key => PROP_MAPPING['type'],
|
|
609 |
:old_value => TRACKER_MAPPING[tracker_change.oldvalue] || DEFAULT_TRACKER,
|
|
610 |
:value => TRACKER_MAPPING[tracker_change.newvalue] || DEFAULT_TRACKER)
|
|
611 |
end
|
|
612 |
# Add timelog entries for each time changes (from timeandestimation plugin)
|
|
613 |
if time_change && time_change.newvalue != '0' && time_change.newvalue != ''
|
|
614 |
t = TimeEntry.new(:project => @target_project,
|
|
615 |
:issue => i,
|
|
616 |
:user => n.user,
|
|
617 |
:spent_on => time,
|
|
618 |
:hours => time_change.newvalue,
|
|
619 |
:created_on => time,
|
|
620 |
:updated_on => time,
|
|
621 |
:activity_id => TimeEntryActivity.find_by_position(2).id,
|
|
622 |
:comments => "#{convert_wiki_text(n.notes.each_line.first.chomp)[0,100] unless !n.notes.each_line.first}... \"more\":/issues/#{i.id}#note-#{noteid}")
|
|
623 |
t.save
|
|
624 |
t.errors.each_full{|msg| puts msg }
|
509 |
625 |
end
|
|
626 |
# Set correct changetime of the issue
|
|
627 |
next unless Time.fake(ticket.changetime) { i.save }
|
510 |
628 |
n.save unless n.details.empty? && n.notes.blank?
|
|
629 |
noteid += 1
|
511 |
630 |
end
|
512 |
631 |
|
513 |
632 |
# Attachments
|
... | ... | |
534 |
653 |
if custom_field_map['resolution'] && !ticket.resolution.blank?
|
535 |
654 |
custom_values[custom_field_map['resolution'].id] = ticket.resolution
|
536 |
655 |
end
|
|
656 |
if custom_field_map['keywords'] && !ticket.keywords.blank?
|
|
657 |
custom_values[custom_field_map['keywords'].id] = ticket.keywords
|
|
658 |
end
|
|
659 |
if custom_field_map['tracid']
|
|
660 |
custom_values[custom_field_map['tracid'].id] = ticket.id
|
|
661 |
end
|
|
662 |
|
|
663 |
if !ticket.version.blank? && custom_field_map['found_in_version']
|
|
664 |
found_in = version_map[ticket.version]
|
|
665 |
if !found_in.nil?
|
|
666 |
puts "Issue #{i.id} found in #{found_in.name.to_s} (#{found_in.id.to_s}) - trac: #{ticket.version}"
|
|
667 |
else
|
|
668 |
#TODO: add better error management here...
|
|
669 |
puts "Issue #{i.id} : ouch... - trac: #{ticket.version}"
|
|
670 |
end
|
|
671 |
custom_values[custom_field_map['found_in_version'].id] = found_in.id.to_s
|
|
672 |
STDOUT.flush
|
|
673 |
end
|
|
674 |
|
537 |
675 |
i.custom_field_values = custom_values
|
538 |
676 |
i.save_custom_field_values
|
539 |
677 |
end
|
540 |
678 |
|
541 |
679 |
# update issue id sequence if needed (postgresql)
|
542 |
680 |
Issue.connection.reset_pk_sequence!(Issue.table_name) if Issue.connection.respond_to?('reset_pk_sequence!')
|
543 |
|
puts
|
|
681 |
puts if migrated_tickets < tickets_total
|
544 |
682 |
|
545 |
683 |
# Wiki
|
546 |
|
print "Migrating wiki"
|
|
684 |
who = "Migrating wiki"
|
547 |
685 |
if wiki.save
|
|
686 |
wiki_edits_total = TracWikiPage.count
|
548 |
687 |
TracWikiPage.find(:all, :order => 'name, version').each do |page|
|
549 |
688 |
# Do not migrate Trac manual wiki pages
|
550 |
|
next if TRAC_WIKI_PAGES.include?(page.name)
|
551 |
|
wiki_edit_count += 1
|
552 |
|
print '.'
|
553 |
|
STDOUT.flush
|
|
689 |
if TRAC_WIKI_PAGES.include?(page.name) then
|
|
690 |
wiki_edits_total -= 1
|
|
691 |
next
|
|
692 |
end
|
554 |
693 |
p = wiki.find_or_new_page(page.name)
|
555 |
694 |
p.content = WikiContent.new(:page => p) if p.new_record?
|
556 |
695 |
p.content.text = page.text
|
557 |
696 |
p.content.author = find_or_create_user(page.author) unless page.author.blank? || page.author == 'trac'
|
558 |
697 |
p.content.comments = page.comment
|
559 |
698 |
Time.fake(page.time) { p.new_record? ? p.save : p.content.save }
|
|
699 |
migrated_wiki_edits += 1
|
|
700 |
simplebar(who, migrated_wiki_edits, wiki_edits_total)
|
560 |
701 |
|
561 |
702 |
next if p.content.new_record?
|
562 |
|
migrated_wiki_edits += 1
|
563 |
703 |
|
564 |
704 |
# Attachments
|
565 |
705 |
page.attachments.each do |attachment|
|
... | ... | |
576 |
716 |
end
|
577 |
717 |
end
|
578 |
718 |
|
579 |
|
wiki.reload
|
580 |
|
wiki.pages.each do |page|
|
581 |
|
page.content.text = convert_wiki_text(page.content.text)
|
582 |
|
Time.fake(page.content.updated_on) { page.content.save }
|
|
719 |
end
|
|
720 |
puts if migrated_wiki_edits < wiki_edits_total
|
|
721 |
|
|
722 |
# Now load each wiki page and transform its content into textile format
|
|
723 |
puts "\nTransform texts to textile format:"
|
|
724 |
|
|
725 |
wiki_pages_count = 0
|
|
726 |
issues_count = 0
|
|
727 |
milestone_wiki_count = 0
|
|
728 |
|
|
729 |
who = " in Wiki pages"
|
|
730 |
wiki.reload
|
|
731 |
wiki_pages_total = wiki.pages.count
|
|
732 |
wiki.pages.each do |page|
|
|
733 |
page.content.text = convert_wiki_text(page.content.text)
|
|
734 |
Time.fake(page.content.updated_on) { page.content.save }
|
|
735 |
wiki_pages_count += 1
|
|
736 |
simplebar(who, wiki_pages_count, wiki_pages_total)
|
|
737 |
end
|
|
738 |
puts if wiki_pages_count < wiki_pages_total
|
|
739 |
|
|
740 |
who = " in Issues"
|
|
741 |
#issues_total = TICKET_MAP.length #works with Ruby <= 1.8.6
|
|
742 |
issues_total = TICKET_MAP.count #works with Ruby >= 1.8.7
|
|
743 |
TICKET_MAP.each do |newId|
|
|
744 |
issues_count += 1
|
|
745 |
simplebar(who, issues_count, issues_total)
|
|
746 |
next if newId.nil?
|
|
747 |
issue = findIssue(newId)
|
|
748 |
next if issue.nil?
|
|
749 |
# convert issue description
|
|
750 |
issue.description = convert_wiki_text(issue.description)
|
|
751 |
# Converted issue comments had their last updated time set to the day of the migration (bugfix)
|
|
752 |
next unless Time.fake(issue.updated_on) { issue.save }
|
|
753 |
# convert issue journals
|
|
754 |
issue.journals.find(:all).each do |journal|
|
|
755 |
journal.notes = convert_wiki_text(journal.notes)
|
|
756 |
journal.save
|
583 |
757 |
end
|
584 |
758 |
end
|
585 |
|
puts
|
|
759 |
puts if issues_count < issues_total
|
|
760 |
|
|
761 |
who = " in Milestone descriptions"
|
|
762 |
#milestone_wiki_total = milestone_wiki.length #works with Ruby <= 1.8.6
|
|
763 |
milestone_wiki_total = milestone_wiki.count #works with Ruby >= 1.8.7
|
|
764 |
milestone_wiki.each do |name|
|
|
765 |
milestone_wiki_count += 1
|
|
766 |
simplebar(who, milestone_wiki_count, milestone_wiki_total)
|
|
767 |
p = wiki.find_page(name)
|
|
768 |
next if p.nil?
|
|
769 |
p.content.text = convert_wiki_text(p.content.text)
|
|
770 |
p.content.save
|
|
771 |
end
|
|
772 |
puts if milestone_wiki_count < milestone_wiki_total
|
586 |
773 |
|
587 |
774 |
puts
|
588 |
|
puts "Components: #{migrated_components}/#{TracComponent.count}"
|
589 |
|
puts "Milestones: #{migrated_milestones}/#{TracMilestone.count}"
|
590 |
|
puts "Tickets: #{migrated_tickets}/#{TracTicket.count}"
|
|
775 |
puts "Components: #{migrated_components}/#{components_total}"
|
|
776 |
puts "Milestones: #{migrated_milestones}/#{milestones_total}"
|
|
777 |
puts "Tickets: #{migrated_tickets}/#{tickets_total}"
|
591 |
778 |
puts "Ticket files: #{migrated_ticket_attachments}/" + TracAttachment.count(:conditions => {:type => 'ticket'}).to_s
|
592 |
779 |
puts "Custom values: #{migrated_custom_values}/#{TracTicketCustom.count}"
|
593 |
|
puts "Wiki edits: #{migrated_wiki_edits}/#{wiki_edit_count}"
|
|
780 |
puts "Wiki edits: #{migrated_wiki_edits}/#{wiki_edits_total}"
|
594 |
781 |
puts "Wiki files: #{migrated_wiki_attachments}/" + TracAttachment.count(:conditions => {:type => 'wiki'}).to_s
|
595 |
782 |
end
|
596 |
|
|
|
783 |
|
|
784 |
def self.findIssue(id)
|
|
785 |
return Issue.find(id)
|
|
786 |
rescue ActiveRecord::RecordNotFound
|
|
787 |
puts "[#{id}] not found"
|
|
788 |
nil
|
|
789 |
end
|
|
790 |
|
597 |
791 |
def self.limit_for(klass, attribute)
|
598 |
792 |
klass.columns_hash[attribute.to_s].limit
|
599 |
793 |
end
|
... | ... | |
671 |
865 |
project.identifier = identifier
|
672 |
866 |
puts "Unable to create a project with identifier '#{identifier}'!" unless project.save
|
673 |
867 |
# enable issues and wiki for the created project
|
|
868 |
# Enable only a minimal set of modules by default
|
674 |
869 |
project.enabled_module_names = ['issue_tracking', 'wiki']
|
675 |
870 |
else
|
676 |
871 |
puts
|
... | ... | |
681 |
876 |
end
|
682 |
877 |
project.trackers << TRACKER_BUG unless project.trackers.include?(TRACKER_BUG)
|
683 |
878 |
project.trackers << TRACKER_FEATURE unless project.trackers.include?(TRACKER_FEATURE)
|
|
879 |
project.trackers << TRACKER_SUPPORT unless project.trackers.include?(TRACKER_SUPPORT)
|
684 |
880 |
@target_project = project.new_record? ? nil : project
|
685 |
881 |
@target_project.reload
|
686 |
882 |
end
|
... | ... | |
732 |
928 |
break unless STDIN.gets.match(/^y$/i)
|
733 |
929 |
puts
|
734 |
930 |
|
735 |
|
def prompt(text, options = {}, &block)
|
736 |
|
default = options[:default] || ''
|
737 |
|
while true
|
738 |
|
print "#{text} [#{default}]: "
|
739 |
|
STDOUT.flush
|
740 |
|
value = STDIN.gets.chomp!
|
741 |
|
value = default if value.blank?
|
742 |
|
break if yield value
|
743 |
|
end
|
744 |
|
end
|
745 |
|
|
746 |
931 |
DEFAULT_PORTS = {'mysql' => 3306, 'postgresql' => 5432}
|
747 |
932 |
|
748 |
933 |
prompt('Trac directory') {|directory| TracMigrate.set_trac_directory directory.strip}
|
749 |
|
prompt('Trac database adapter (sqlite, sqlite3, mysql, postgresql)', :default => 'sqlite') {|adapter| TracMigrate.set_trac_adapter adapter}
|
|
934 |
prompt('Trac database adapter (sqlite, sqlite3, mysql, postgresql)', :default => 'sqlite3') {|adapter| TracMigrate.set_trac_adapter adapter}
|
750 |
935 |
unless %w(sqlite sqlite3).include?(TracMigrate.trac_adapter)
|
751 |
936 |
prompt('Trac database host', :default => 'localhost') {|host| TracMigrate.set_trac_db_host host}
|
752 |
937 |
prompt('Trac database port', :default => DEFAULT_PORTS[TracMigrate.trac_adapter]) {|port| TracMigrate.set_trac_db_port port}
|
... | ... | |
756 |
941 |
prompt('Trac database password') {|password| TracMigrate.set_trac_db_password password}
|
757 |
942 |
end
|
758 |
943 |
prompt('Trac database encoding', :default => 'UTF-8') {|encoding| TracMigrate.encoding encoding}
|
759 |
|
prompt('Target project identifier') {|identifier| TracMigrate.target_project_identifier identifier}
|
|
944 |
prompt('Target project identifier') {|identifier| TracMigrate.target_project_identifier identifier.downcase}
|
760 |
945 |
puts
|
761 |
946 |
|
762 |
947 |
# Turn off email notifications
|
... | ... | |
764 |
949 |
|
765 |
950 |
TracMigrate.migrate
|
766 |
951 |
end
|
|
952 |
|
|
953 |
|
|
954 |
desc 'Subversion migration script'
|
|
955 |
task :migrate_svn_commit_properties => :environment do
|
|
956 |
|
|
957 |
require 'redmine/scm/adapters/abstract_adapter'
|
|
958 |
require 'redmine/scm/adapters/subversion_adapter'
|
|
959 |
require 'rexml/document'
|
|
960 |
require 'uri'
|
|
961 |
require 'tempfile'
|
|
962 |
|
|
963 |
module SvnMigrate
|
|
964 |
TICKET_MAP = []
|
|
965 |
|
|
966 |
class Commit
|
|
967 |
attr_accessor :revision, :message, :author
|
|
968 |
|
|
969 |
def initialize(attributes={})
|
|
970 |
self.author = attributes[:author] || ""
|
|
971 |
self.message = attributes[:message] || ""
|
|
972 |
self.revision = attributes[:revision]
|
|
973 |
end
|
|
974 |
end
|
|
975 |
|
|
976 |
class SvnExtendedAdapter < Redmine::Scm::Adapters::SubversionAdapter
|
|
977 |
|
|
978 |
def set_author(path=nil, revision=nil, author=nil)
|
|
979 |
path ||= ''
|
|
980 |
|
|
981 |
cmd = "#{SVN_BIN} propset svn:author --quiet --revprop -r #{revision} \"#{author}\" "
|
|
982 |
cmd << credentials_string
|
|
983 |
cmd << ' ' + target(URI.escape(path))
|
|
984 |
|
|
985 |
shellout(cmd) do |io|
|
|
986 |
begin
|
|
987 |
loop do
|
|
988 |
line = io.readline
|
|
989 |
puts line
|
|
990 |
end
|
|
991 |
rescue EOFError
|
|
992 |
end
|
|
993 |
end
|
|
994 |
|
|
995 |
raise if $? && $?.exitstatus != 0
|
|
996 |
|
|
997 |
end
|
|
998 |
|
|
999 |
def set_message(path=nil, revision=nil, msg=nil)
|
|
1000 |
path ||= ''
|
|
1001 |
|
|
1002 |
Tempfile.open('msg') do |tempfile|
|
|
1003 |
|
|
1004 |
# This is a weird thing. We need to cleanup cr/lf so we have uniform line separators
|
|
1005 |
tempfile.print msg.gsub(/\r\n/,'\n')
|
|
1006 |
tempfile.flush
|
|
1007 |
|
|
1008 |
filePath = tempfile.path.gsub(File::SEPARATOR, File::ALT_SEPARATOR || File::SEPARATOR)
|
|
1009 |
|
|
1010 |
cmd = "#{SVN_BIN} propset svn:log --quiet --revprop -r #{revision} -F \"#{filePath}\" "
|
|
1011 |
cmd << credentials_string
|
|
1012 |
cmd << ' ' + target(URI.escape(path))
|
|
1013 |
|
|
1014 |
shellout(cmd) do |io|
|
|
1015 |
begin
|
|
1016 |
loop do
|
|
1017 |
line = io.readline
|
|
1018 |
puts line
|
|
1019 |
end
|
|
1020 |
rescue EOFError
|
|
1021 |
end
|
|
1022 |
end
|
|
1023 |
|
|
1024 |
raise if $? && $?.exitstatus != 0
|
|
1025 |
|
|
1026 |
end
|
|
1027 |
|
|
1028 |
end
|
|
1029 |
|
|
1030 |
def messages(path=nil)
|
|
1031 |
path ||= ''
|
|
1032 |
|
|
1033 |
commits = Array.new
|
|
1034 |
|
|
1035 |
cmd = "#{SVN_BIN} log --xml -r 1:HEAD"
|
|
1036 |
cmd << credentials_string
|
|
1037 |
cmd << ' ' + target(URI.escape(path))
|
|
1038 |
|
|
1039 |
shellout(cmd) do |io|
|
|
1040 |
begin
|
|
1041 |
doc = REXML::Document.new(io)
|
|
1042 |
doc.elements.each("log/logentry") do |logentry|
|
|
1043 |
|
|
1044 |
commits << Commit.new(
|
|
1045 |
{
|
|
1046 |
:revision => logentry.attributes['revision'].to_i,
|
|
1047 |
:message => logentry.elements['msg'].text,
|
|
1048 |
:author => logentry.elements['author'].text
|
|
1049 |
})
|
|
1050 |
end
|
|
1051 |
rescue => e
|
|
1052 |
puts"Error !!!"
|
|
1053 |
puts e
|
|
1054 |
end
|
|
1055 |
end
|
|
1056 |
return nil if $? && $?.exitstatus != 0
|
|
1057 |
commits
|
|
1058 |
end
|
|
1059 |
|
|
1060 |
end
|
|
1061 |
|
|
1062 |
def self.migrate_authors
|
|
1063 |
svn = self.scm
|
|
1064 |
commits = svn.messages(@svn_url)
|
|
1065 |
commits.each do |commit|
|
|
1066 |
orig_author_name = commit.author
|
|
1067 |
new_author_name = orig_author_name
|
|
1068 |
|
|
1069 |
# TODO put your Trac/SVN/Redmine username mapping here:
|
|
1070 |
if (commit.author == 'TracX')
|
|
1071 |
new_author_name = 'RedmineY'
|
|
1072 |
elsif (commit.author == 'gilles')
|
|
1073 |
new_author_name = 'gcornu'
|
|
1074 |
#elsif (commit.author == 'seco')
|
|
1075 |
#...
|
|
1076 |
else
|
|
1077 |
new_author_name = 'RedmineY'
|
|
1078 |
end
|
|
1079 |
|
|
1080 |
if (new_author_name != orig_author_name)
|
|
1081 |
scm.set_author(@svn_url, commit.revision, new_author_name)
|
|
1082 |
puts "r#{commit.revision} - Author replaced: #{orig_author_name} -> #{new_author_name}"
|
|
1083 |
else
|
|
1084 |
puts "r#{commit.revision} - Author kept: #{orig_author_name} unchanged "
|
|
1085 |
end
|
|
1086 |
end
|
|
1087 |
end
|
|
1088 |
|
|
1089 |
def self.migrate_messages
|
|
1090 |
|
|
1091 |
project = Project.find(@@redmine_project)
|
|
1092 |
if !project
|
|
1093 |
puts "Could not find project identifier '#{@@redmine_project}'"
|
|
1094 |
raise
|
|
1095 |
end
|
|
1096 |
|
|
1097 |
tid = IssueCustomField.find(:first, :conditions => { :name => "TracID" })
|
|
1098 |
if !tid
|
|
1099 |
puts "Could not find issue custom field 'TracID'"
|
|
1100 |
raise
|
|
1101 |
end
|
|
1102 |
|
|
1103 |
Issue.find( :all, :conditions => { :project_id => project }).each do |issue|
|
|
1104 |
val = nil
|
|
1105 |
issue.custom_values.each do |value|
|
|
1106 |
if value.custom_field.id == tid.id
|
|
1107 |
val = value
|
|
1108 |
break
|
|
1109 |
end
|
|
1110 |
end
|
|
1111 |
|
|
1112 |
TICKET_MAP[val.value.to_i] = issue.id if !val.nil?
|
|
1113 |
end
|
|
1114 |
|
|
1115 |
svn = self.scm
|
|
1116 |
msgs = svn.messages(@svn_url)
|
|
1117 |
msgs.each do |commit|
|
|
1118 |
|
|
1119 |
newText = convert_wiki_text(commit.message)
|
|
1120 |
|
|
1121 |
if newText != commit.message
|
|
1122 |
puts "Updating message #{commit.revision}"
|
|
1123 |
|
|
1124 |
# Marcel Nadje enhancement, see http://www.redmine.org/issues/2748#note-3
|
|
1125 |
# Hint: enable charset conversion if needed...
|
|
1126 |
#newText = Iconv.conv('CP1252', 'UTF-8', newText)
|
|
1127 |
|
|
1128 |
scm.set_message(@svn_url, commit.revision, newText)
|
|
1129 |
end
|
|
1130 |
end
|
|
1131 |
|
|
1132 |
|
|
1133 |
end
|
|
1134 |
|
|
1135 |
# Basic wiki syntax conversion
|
|
1136 |
def self.convert_wiki_text(text)
|
|
1137 |
convert_wiki_text_mapping(text, TICKET_MAP)
|
|
1138 |
end
|
|
1139 |
|
|
1140 |
def self.set_svn_url(url)
|
|
1141 |
@@svn_url = url
|
|
1142 |
end
|
|
1143 |
|
|
1144 |
def self.set_svn_username(username)
|
|
1145 |
@@svn_username = username
|
|
1146 |
end
|
|
1147 |
|
|
1148 |
def self.set_svn_password(password)
|
|
1149 |
@@svn_password = password
|
|
1150 |
end
|
|
1151 |
|
|
1152 |
def self.set_redmine_project_identifier(identifier)
|
|
1153 |
@@redmine_project = identifier
|
|
1154 |
end
|
|
1155 |
|
|
1156 |
def self.scm
|
|
1157 |
# Thomas Recloux fix, see http://www.redmine.org/issues/2748#note-1
|
|
1158 |
# The constructor of the SvnExtendedAdapter has ony got four parameters,
|
|
1159 |
# => parameters 5,6 and 7 removed
|
|
1160 |
@scm ||= SvnExtendedAdapter.new @@svn_url, @@svn_url, @@svn_username, @@svn_password
|
|
1161 |
#@scm ||= SvnExtendedAdapter.new @@svn_url, @@svn_url, @@svn_username, @@svn_password, 0, "", nil
|
|
1162 |
@scm
|
|
1163 |
end
|
|
1164 |
end
|
|
1165 |
|
|
1166 |
puts
|
|
1167 |
prompt('Subversion repository url') {|repository| SvnMigrate.set_svn_url repository.strip}
|
|
1168 |
prompt('Subversion repository username') {|username| SvnMigrate.set_svn_username username}
|
|
1169 |
prompt('Subversion repository password') {|password| SvnMigrate.set_svn_password password}
|
|
1170 |
puts
|
|
1171 |
|
|
1172 |
author_migration_enabled = unsafe_prompt('1) Start Migration of SVN Commit Authors (y,n)?', {:default => 'n'}) == 'y'
|
|
1173 |
puts
|
|
1174 |
if author_migration_enabled
|
|
1175 |
puts "WARNING: Some (maybe all) commit authors will be replaced"
|
|
1176 |
print "Are you sure you want to continue ? [y/N] "
|
|
1177 |
break unless STDIN.gets.match(/^y$/i)
|
|
1178 |
|
|
1179 |
SvnMigrate.migrate_authors
|
|
1180 |
end
|
|
1181 |
|
|
1182 |
message_migration_enabled = unsafe_prompt('2) Start Migration of SVN Commit Messages (y,n)?', {:default => 'n'}) == 'y'
|
|
1183 |
puts
|
|
1184 |
if message_migration_enabled
|
|
1185 |
if Redmine::DefaultData::Loader.no_data?
|
|
1186 |
puts "Redmine configuration need to be loaded before importing data."
|
|
1187 |
puts "Please, run this first:"
|
|
1188 |
puts
|
|
1189 |
puts " rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
|
|
1190 |
exit
|
|
1191 |
end
|
|
1192 |
|
|
1193 |
puts "WARNING: all commit messages with references to trac pages will be modified"
|
|
1194 |
print "Are you sure you want to continue ? [y/N] "
|
|
1195 |
break unless STDIN.gets.match(/^y$/i)
|
|
1196 |
puts
|
|
1197 |
|
|
1198 |
prompt('Redmine project identifier') {|identifier| SvnMigrate.set_redmine_project_identifier identifier}
|
|
1199 |
puts
|
|
1200 |
|
|
1201 |
SvnMigrate.migrate_messages
|
|
1202 |
end
|
|
1203 |
end
|
|
1204 |
|
|
1205 |
# Prompt
|
|
1206 |
def prompt(text, options = {}, &block)
|
|
1207 |
default = options[:default] || ''
|
|
1208 |
while true
|
|
1209 |
print "#{text} [#{default}]: "
|
|
1210 |
STDOUT.flush
|
|
1211 |
value = STDIN.gets.chomp!
|
|
1212 |
value = default if value.blank?
|
|
1213 |
break if yield value
|
|
1214 |
end
|
|
1215 |
end
|
|
1216 |
|
|
1217 |
# Sorry, I had troubles to intagrate 'prompt' and quickly went this way...
|
|
1218 |
def unsafe_prompt(text, options = {})
|
|
1219 |
default = options[:default] || ''
|
|
1220 |
print "#{text} [#{default}]: "
|
|
1221 |
value = STDIN.gets.chomp!
|
|
1222 |
value = default if value.blank?
|
|
1223 |
value
|
|
1224 |
end
|
|
1225 |
|
|
1226 |
# Basic wiki syntax conversion
|
|
1227 |
def convert_wiki_text_mapping(text, ticket_map = [])
|
|
1228 |
# Hide links
|
|
1229 |
def wiki_links_hide(src)
|
|
1230 |
@wiki_links = []
|
|
1231 |
@wiki_links_hash = "####WIKILINKS#{src.hash.to_s}####"
|
|
1232 |
src.gsub(/(\[\[.+?\|.+?\]\])/) do
|
|
1233 |
@wiki_links << $1
|
|
1234 |
@wiki_links_hash
|
|
1235 |
end
|
|
1236 |
end
|
|
1237 |
# Restore links
|
|
1238 |
def wiki_links_restore(src)
|
|
1239 |
@wiki_links.each do |s|
|
|
1240 |
src = src.sub("#{@wiki_links_hash}", s.to_s)
|
|
1241 |
end
|
|
1242 |
src
|
|
1243 |
end
|
|
1244 |
# Hidding code blocks
|
|
1245 |
def code_hide(src)
|
|
1246 |
@code = []
|
|
1247 |
@code_hash = "####CODEBLOCK#{src.hash.to_s}####"
|
|
1248 |
src.gsub(/(\{\{\{.+?\}\}\}|`.+?`)/m) do
|
|
1249 |
@code << $1
|
|
1250 |
@code_hash
|
|
1251 |
end
|
|
1252 |
end
|
|
1253 |
# Convert code blocks
|
|
1254 |
def code_convert(src)
|
|
1255 |
@code.each do |s|
|
|
1256 |
s = s.to_s
|
|
1257 |
if s =~ (/`(.+?)`/m) || s =~ (/\{\{\{(.+?)\}\}\}/) then
|
|
1258 |
# inline code
|
|
1259 |
s = s.replace("@#{$1}@")
|
|
1260 |
else
|
|
1261 |
# We would like to convert the Code highlighting too
|
|
1262 |
# This will go into the next line.
|
|
1263 |
shebang_line = false
|
|
1264 |
# Reguar expression for start of code
|
|
1265 |
pre_re = /\{\{\{/
|
|
1266 |
# Code hightlighing...
|
|
1267 |
shebang_re = /^\#\!([a-z]+)/
|
|
1268 |
# Regular expression for end of code
|
|
1269 |
pre_end_re = /\}\}\}/
|
|
1270 |
|
|
1271 |
# Go through the whole text..extract it line by line
|
|
1272 |
s = s.gsub(/^(.*)$/) do |line|
|
|
1273 |
m_pre = pre_re.match(line)
|
|
1274 |
if m_pre
|
|
1275 |
line = '<pre>'
|
|
1276 |
else
|
|
1277 |
m_sl = shebang_re.match(line)
|
|
1278 |
if m_sl
|
|
1279 |
shebang_line = true
|
|
1280 |
line = '<code class="' + m_sl[1] + '">'
|
|
1281 |
end
|
|
1282 |
m_pre_end = pre_end_re.match(line)
|
|
1283 |
if m_pre_end
|
|
1284 |
line = '</pre>'
|
|
1285 |
if shebang_line
|
|
1286 |
line = '</code>' + line
|
|
1287 |
end
|
|
1288 |
end
|
|
1289 |
end
|
|
1290 |
line
|
|
1291 |
end
|
|
1292 |
end
|
|
1293 |
src = src.sub("#{@code_hash}", s)
|
|
1294 |
end
|
|
1295 |
src
|
|
1296 |
end
|
|
1297 |
|
|
1298 |
# Hide code blocks
|
|
1299 |
text = code_hide(text)
|
|
1300 |
# New line
|
|
1301 |
text = text.gsub(/\[\[[Bb][Rr]\]\]/, "\n") # This has to go before the rules below
|
|
1302 |
# Titles (only h1. to h6., and remove #...)
|
|
1303 |
text = text.gsub(/(?:^|^\ +)(\={1,6})\ (.+)\ (?:\1)(?:\ *(\ \#.*))?/) {|s| "\nh#{$1.length}. #{$2}#{$3}\n"}
|
|
1304 |
|
|
1305 |
# External Links:
|
|
1306 |
# [http://example.com/]
|
|
1307 |
text = text.gsub(/\[((?:https?|s?ftp)\:\S+)\]/, '\1')
|
|
1308 |
# [http://example.com/ Example],[http://example.com/ "Example"]
|
|
1309 |
# [http://example.com/ "Example for "Example""] -> "Example for 'Example'":http://example.com/
|
|
1310 |
text = text.gsub(/\[((?:https?|s?ftp)\:\S+)[\ \t]+([\"']?)(.+?)\2\]/) {|s| "\"#{$3.tr('"','\'')}\":#{$1}"}
|
|
1311 |
# [mailto:some@example.com],[mailto:"some@example.com"]
|
|
1312 |
text = text.gsub(/\[mailto\:([\"']?)(.+?)\1\]/, '\2')
|
|
1313 |
|
|
1314 |
# Ticket links:
|
|
1315 |
# [ticket:234 Text],[ticket:234 This is a test],[ticket:234 "This is a test"]
|
|
1316 |
# [ticket:234 "Test "with quotes""] -> "Test 'with quotes'":issues/show/234
|
|
1317 |
text = text.gsub(/\[ticket\:(\d+)[\ \t]+([\"']?)(.+?)\2\]/) {|s| "\"#{$3.tr('"','\'')}\":/issues/show/#{$1}"}
|
|
1318 |
# ticket:1234
|
|
1319 |
# excluding ticket:1234:file.txt (used in macros)
|
|
1320 |
# #1 - working cause Redmine uses the same syntax.
|
|
1321 |
text = text.gsub(/ticket\:(\d+?)([^\:])/, '#\1\2')
|
|
1322 |
|
|
1323 |
# Source & attachments links:
|
|
1324 |
# [source:/trunk/readme.txt Readme File],[source:"/trunk/readme.txt" Readme File],
|
|
1325 |
# [source:/trunk/readme.txt],[source:"/trunk/readme.txt"]
|
|
1326 |
# The text "Readme File" is not converted,
|
|
1327 |
# cause Redmine's wiki does not support this.
|
|
1328 |
# Attachments use same syntax.
|
|
1329 |
text = text.gsub(/\[(source|attachment)\:([\"']?)([^\"']+?)\2(?:\ +(.+?))?\]/, '\1:"\3"')
|
|
1330 |
# source:"/trunk/readme.txt"
|
|
1331 |
# source:/trunk/readme.txt - working cause Redmine uses the same syntax.
|
|
1332 |
text = text.gsub(/(source|attachment)\:([\"'])([^\"']+?)\2/, '\1:"\3"')
|
|
1333 |
|
|
1334 |
# Milestone links:
|
|
1335 |
# [milestone:"0.1.0 Mercury" Milestone 0.1.0 (Mercury)],
|
|
1336 |
# [milestone:"0.1.0 Mercury"],milestone:"0.1.0 Mercury"
|
|
1337 |
# The text "Milestone 0.1.0 (Mercury)" is not converted,
|
|
1338 |
# cause Redmine's wiki does not support this.
|
|
1339 |
text = text.gsub(/\[milestone\:([\"'])([^\"']+?)\1(?:\ +(.+?))?\]/, 'version:"\2"')
|
|
1340 |
text = text.gsub(/milestone\:([\"'])([^\"']+?)\1/, 'version:"\2"')
|
|
1341 |
# [milestone:0.1.0],milestone:0.1.0
|
|
1342 |
text = text.gsub(/\[milestone\:([^\ ]+?)\]/, 'version:\1')
|
|
1343 |
text = text.gsub(/milestone\:([^\ ]+?)/, 'version:\1')
|
|
1344 |
|
|
1345 |
# Internal Links:
|
|
1346 |
# ["Some Link"]
|
|
1347 |
text = text.gsub(/\[([\"'])(.+?)\1\]/) {|s| "[[#{$2.delete(',./?;|:')}]]"}
|
|
1348 |
# [wiki:"Some Link" "Link description"],[wiki:"Some Link" Link description]
|
|
1349 |
text = text.gsub(/\[wiki\:([\"'])([^\]\"']+?)\1[\ \t]+([\"']?)(.+?)\3\]/) {|s| "[[#{$2.delete(',./?;|:')}|#{$4}]]"}
|
|
1350 |
# [wiki:"Some Link"]
|
|
1351 |
text = text.gsub(/\[wiki\:([\"'])([^\]\"']+?)\1\]/) {|s| "[[#{$2.delete(',./?;|:')}]]"}
|
|
1352 |
# [wiki:SomeLink]
|
|
1353 |
text = text.gsub(/\[wiki\:([^\s\]]+?)\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
|
|
1354 |
# [wiki:SomeLink Link description],[wiki:SomeLink "Link description"]
|
|
1355 |
text = text.gsub(/\[wiki\:([^\s\]\"']+?)[\ \t]+([\"']?)(.+?)\2\]/) {|s| "[[#{$1.delete(',./?;|:')}|#{$3}]]"}
|
|
1356 |
|
|
1357 |
# Before convert CamelCase links, must hide wiki links with description.
|
|
1358 |
# Like this: [[http://www.freebsd.org|Hello FreeBSD World]]
|
|
1359 |
text = wiki_links_hide(text)
|
|
1360 |
# Links to CamelCase pages (not work for unicode)
|
|
1361 |
# UsingJustWikiCaps,UsingJustWikiCaps/Subpage
|
|
1362 |
text = text.gsub(/([^!]|^)(^| )([A-Z][a-z]+[A-Z][a-zA-Z]+(?:\/[^\s[:punct:]]+)*)/) {|s| "#{$1}#{$2}[[#{$3.delete('/')}]]"}
|
|
1363 |
# Normalize things that were supposed to not be links
|
|
1364 |
# like !NotALink
|
|
1365 |
text = text.gsub(/(^| )!([A-Z][A-Za-z]+)/, '\1\2')
|
|
1366 |
# Now restore hidden links
|
|
1367 |
text = wiki_links_restore(text)
|
|
1368 |
|
|
1369 |
# Revisions links
|
|
1370 |
text = text.gsub(/\[(\d+)\]/, 'r\1')
|
|
1371 |
# Ticket number re-writing
|
|
1372 |
text = text.gsub(/#(\d+)/) do |s|
|
|
1373 |
if $1.length < 10
|
|
1374 |
#ticket_map[$1.to_i] ||= $1
|
|
1375 |
"\##{ticket_map[$1.to_i] || $1}"
|
|
1376 |
else
|
|
1377 |
s
|
|
1378 |
end
|
|
1379 |
end
|
|
1380 |
|
|
1381 |
# Highlighting
|
|
1382 |
text = text.gsub(/'''''([^\s])/, '_*\1')
|
|
1383 |
text = text.gsub(/([^\s])'''''/, '\1*_')
|
|
1384 |
text = text.gsub(/'''/, '*')
|
|
1385 |
text = text.gsub(/''/, '_')
|
|
1386 |
text = text.gsub(/__/, '+')
|
|
1387 |
text = text.gsub(/~~/, '-')
|
|
1388 |
text = text.gsub(/`/, '@')
|
|
1389 |
text = text.gsub(/,,/, '~')
|
|
1390 |
# Tables
|
|
1391 |
text = text.gsub(/\|\|/, '|')
|
|
1392 |
# Lists:
|
|
1393 |
# bullet
|
|
1394 |
text = text.gsub(/^(\ +)[\*-] /) {|s| '*' * $1.length + " "}
|
|
1395 |
# numbered
|
|
1396 |
text = text.gsub(/^(\ +)\d+\. /) {|s| '#' * $1.length + " "}
|
|
1397 |
# Images (work for only attached in current page [[Image(picture.gif)]])
|
|
1398 |
# need rules for: * [[Image(wiki:WikiFormatting:picture.gif)]] (referring to attachment on another page)
|
|
1399 |
# * [[Image(ticket:1:picture.gif)]] (file attached to a ticket)
|
|
1400 |
# * [[Image(htdocs:picture.gif)]] (referring to a file inside project htdocs)
|
|
1401 |
# * [[Image(source:/trunk/trac/htdocs/trac_logo_mini.png)]] (a file in repository)
|
|
1402 |
text = text.gsub(/\[\[image\((.+?)(?:,.+?)?\)\]\]/i, '!\1!')
|
|
1403 |
# TOC (is right-aligned, because that in Trac)
|
|
1404 |
text = text.gsub(/\[\[TOC(?:\((.*?)\))?\]\]/m) {|s| "{{>toc}}\n"}
|
|
1405 |
|
|
1406 |
# Thomas Recloux enhancements, see http://www.redmine.org/issues/2748#note-1
|
|
1407 |
# Redmine needs a space between keywords "refs,ref,fix" and the issue number (#1234) in subversion commit messages.
|
|
1408 |
# TODO: rewrite it in a more regex-style way
|
|
1409 |
|
|
1410 |
text = text.gsub("refs#", "refs #")
|
|
1411 |
text = text.gsub("Refs#", "refs #")
|
|
1412 |
text = text.gsub("REFS#", "refs #")
|
|
1413 |
text = text.gsub("ref#", "refs #")
|
|
1414 |
text = text.gsub("Ref#", "refs #")
|
|
1415 |
text = text.gsub("REF#", "refs #")
|
|
1416 |
|
|
1417 |
text = text.gsub("fix#", "fixes #")
|
|
1418 |
text = text.gsub("Fix#", "fixes #")
|
|
1419 |
text = text.gsub("FIX#", "fixes #")
|
|
1420 |
text = text.gsub("fixes#", "fixes #")
|
|
1421 |
text = text.gsub("Fixes#", "fixes #")
|
|
1422 |
text = text.gsub("FIXES#", "fixes #")
|
|
1423 |
|
|
1424 |
# Restore and convert code blocks
|
|
1425 |
text = code_convert(text)
|
|
1426 |
|
|
1427 |
text
|
|
1428 |
end
|
|
1429 |
|
|
1430 |
# Simple progress bar
|
|
1431 |
def simplebar(title, current, total, out = STDOUT)
|
|
1432 |
def get_width
|
|
1433 |
default_width = 80
|
|
1434 |
begin
|
|
1435 |
tiocgwinsz = 0x5413
|
|
1436 |
data = [0, 0, 0, 0].pack("SSSS")
|
|
1437 |
if out.ioctl(tiocgwinsz, data) >= 0 then
|
|
1438 |
rows, cols, xpixels, ypixels = data.unpack("SSSS")
|
|
1439 |
if cols >= 0 then cols else default_width end
|
|
1440 |
else
|
|
1441 |
default_width
|
|
1442 |
end
|
|
1443 |
rescue Exception
|
|
1444 |
default_width
|
|
1445 |
end
|
|
1446 |
end
|
|
1447 |
mark = "*"
|
|
1448 |
title_width = 40
|
|
1449 |
max = get_width - title_width - 10
|
|
1450 |
format = "%-#{title_width}s [%-#{max}s] %3d%% %s"
|
|
1451 |
bar = current * max / total
|
|
1452 |
percentage = bar * 100 / max
|
|
1453 |
current == total ? eol = "\n" : eol ="\r"
|
|
1454 |
printf(format, title, mark * bar, percentage, eol)
|
|
1455 |
out.flush
|
|
1456 |
end
|
767 |
1457 |
end
|
768 |
1458 |
|
769 |
|
-
|