tractive 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +24 -0
  3. data/.gitignore +11 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +47 -0
  6. data/CODE_OF_CONDUCT.md +84 -0
  7. data/Gemfile +14 -0
  8. data/LICENSE.md +69 -0
  9. data/README.adoc +742 -0
  10. data/Rakefile +12 -0
  11. data/bin/console +15 -0
  12. data/bin/setup +8 -0
  13. data/config.example.yaml +73 -0
  14. data/db/trac-test.db +0 -0
  15. data/docker/Dockerfile +19 -0
  16. data/docker/docker-compose.yml +68 -0
  17. data/exe/tractive +111 -0
  18. data/lib/tractive/attachment_exporter.rb +62 -0
  19. data/lib/tractive/github_api/client/issues.rb +78 -0
  20. data/lib/tractive/github_api/client/milestones.rb +35 -0
  21. data/lib/tractive/github_api/client.rb +16 -0
  22. data/lib/tractive/github_api.rb +3 -0
  23. data/lib/tractive/graceful_quit.rb +30 -0
  24. data/lib/tractive/info.rb +46 -0
  25. data/lib/tractive/main.rb +81 -0
  26. data/lib/tractive/migrator/converter/trac_to_github.rb +307 -0
  27. data/lib/tractive/migrator/converter/twf_to_markdown.rb +125 -0
  28. data/lib/tractive/migrator/converter.rb +3 -0
  29. data/lib/tractive/migrator/engine/migrate_from_db.rb +95 -0
  30. data/lib/tractive/migrator/engine/migrate_from_file.rb +100 -0
  31. data/lib/tractive/migrator/engine/migrate_to_file.rb +68 -0
  32. data/lib/tractive/migrator/engine.rb +131 -0
  33. data/lib/tractive/migrator.rb +3 -0
  34. data/lib/tractive/models/attachment.rb +10 -0
  35. data/lib/tractive/models/milestone.rb +6 -0
  36. data/lib/tractive/models/report.rb +6 -0
  37. data/lib/tractive/models/revision.rb +6 -0
  38. data/lib/tractive/models/session.rb +6 -0
  39. data/lib/tractive/models/ticket.rb +36 -0
  40. data/lib/tractive/models/ticket_change.rb +7 -0
  41. data/lib/tractive/revmap_generator.rb +111 -0
  42. data/lib/tractive/trac.rb +16 -0
  43. data/lib/tractive/utilities.rb +68 -0
  44. data/lib/tractive/version.rb +5 -0
  45. data/lib/tractive.rb +29 -0
  46. data/tractive.gemspec +37 -0
  47. metadata +189 -0
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migrator
4
+ class Engine
5
+ module MigrateToFile
6
+ def migrate_to_file
7
+ Tractive::GracefulQuit.enable
8
+ migrate_tickets_to_file(@start_ticket, @filter_closed)
9
+ rescue RuntimeError => e
10
+ $logger.error e.message
11
+ end
12
+
13
+ private
14
+
15
+ # Creates github issues for trac tickets.
16
+ def migrate_tickets_to_file(start_ticket, filterout_closed)
17
+ $logger.info("migrating issues")
18
+ # We match the issue title to determine whether an issue exists already.
19
+ tractickets = @trac.tickets
20
+ .for_migration(start_ticket, filterout_closed, @filter_options)
21
+ .all
22
+ begin
23
+ lasttracid = tractickets.last[:id]
24
+ rescue StandardError
25
+ raise("trac has no ticket #{start_ticket}")
26
+ end
27
+
28
+ (start_ticket.to_i..lasttracid).each do |ticket_id|
29
+ ticket = tractickets.select { |i| i[:id] == ticket_id }.first
30
+
31
+ @current_ticket_id = ticket_id # used to build filename for attachments
32
+
33
+ if ticket.nil?
34
+ next unless @mockdeleted
35
+
36
+ ticket = mock_ticket_details(ticket_id)
37
+ end
38
+
39
+ raise("tickets out of sync #{ticket_id} - #{ticket[:id]}") if ticket[:id] != ticket_id
40
+
41
+ next if filterout_closed && (ticket[:status] == "closed")
42
+
43
+ Tractive::GracefulQuit.check("quitting after processing ticket ##{@last_created_issue}") do
44
+ @output_file.puts "}"
45
+ end
46
+
47
+ $logger.info(%{creating issue for trac #{ticket[:id]} "#{ticket[:summary]}" (#{ticket[:reporter]})})
48
+ # API details: https://gist.github.com/jonmagic/5282384165e0f86ef105
49
+ request = Migrator::Converter::TracToGithub.new(@config).compose(ticket)
50
+
51
+ @output_file.puts @delimiter
52
+ @output_file.puts({ @current_ticket_id => request }.to_json[1...-1])
53
+ @delimiter = "," if @delimiter != ","
54
+ response = { "status" => "added to file", "issue_url" => "/#{ticket[:id]}" }
55
+
56
+ $logger.info("Status: #{response["status"]}")
57
+
58
+ issue_id = response["issue_url"].match(/\d+$/).to_s.to_i
59
+ $logger.info("created issue ##{issue_id} for trac ticket #{ticket[:id]}")
60
+
61
+ @last_created_issue = ticket[:id]
62
+ end
63
+
64
+ @output_file.puts "}"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "engine/migrate_from_db"
4
+ require_relative "engine/migrate_from_file"
5
+ require_relative "engine/migrate_to_file"
6
+
7
+ require_relative "converter/trac_to_github"
8
+ require_relative "converter/twf_to_markdown"
9
+
10
+ # Service to perform migrations
11
+ module Migrator
12
+ class Engine
13
+ include Migrator::Engine::MigrateFromDb
14
+ include Migrator::Engine::MigrateToFile
15
+ include Migrator::Engine::MigrateFromFile
16
+
17
+ def initialize(args)
18
+ # def initialize(trac, github, users, labels, revmap, attachurl, singlepost, safetychecks, mockdeleted = false)
19
+ @config = args
20
+
21
+ db = args[:db]
22
+ github = args[:cfg]["github"]
23
+ safetychecks = !(args[:opts][:fast])
24
+ mockdeleted = args[:opts][:mockdeleted]
25
+ start_ticket = args[:opts][:start]
26
+ filter_closed = args[:opts][:openedonly]
27
+ input_file_name = args[:opts][:importfromfile]
28
+
29
+ @filter_applied = args[:opts][:filter]
30
+ @filter_options = { column_name: args[:opts][:columnname], operator: args[:opts][:operator], column_value: args[:opts][:columnvalue] }
31
+
32
+ @trac = Tractive::Trac.new(db)
33
+ @repo = github["repo"]
34
+ @client = GithubApi::Client.new(access_token: github["token"])
35
+
36
+ if input_file_name
37
+ @from_file = input_file_name
38
+ file = File.open(@from_file, "r")
39
+ @input_file = JSON.parse(file.read)
40
+ file.close
41
+ end
42
+
43
+ @ticket_to_issue = {}
44
+ @mockdeleted = mockdeleted || @filter_applied
45
+
46
+ $logger.debug("Get highest in #{@repo}")
47
+ issues = @client.issues(@repo, { filter: "all",
48
+ state: "all",
49
+ sort: "created",
50
+ direction: "desc" })
51
+
52
+ @last_created_issue = issues.empty? ? 0 : issues[0]["number"].to_i
53
+
54
+ $logger.info("created issue on GitHub is '#{@last_created_issue}' #{issues.count}")
55
+
56
+ dry_run_output_file = args[:cfg][:dry_run_output_file] || "#{Dir.pwd}/dryrun_out.json"
57
+
58
+ @dry_run = args[:opts][:dryrun]
59
+ @output_file = File.new(dry_run_output_file, "w+")
60
+ @delimiter = "{"
61
+ @revmap = load_revmap_file(args[:opts][:revmapfile] || args[:cfg]["revmapfile"])
62
+ @safetychecks = safetychecks
63
+ @start_ticket = (start_ticket || (@last_created_issue + 1)).to_i
64
+ @filter_closed = filter_closed
65
+ end
66
+
67
+ def migrate
68
+ if @dry_run
69
+ migrate_to_file
70
+ elsif @from_file
71
+ migrate_from_file
72
+ else
73
+ migrate_from_db
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def load_revmap_file(revmapfile)
80
+ # load revision mapping file and convert it to a hash.
81
+ # This revmap file allows to map between SVN revisions (rXXXX)
82
+ # and git commit sha1 hashes.
83
+ revmap = nil
84
+ if revmapfile
85
+ File.open(revmapfile, "r:UTF-8") do |f|
86
+ $logger.info("loading revision map #{revmapfile}")
87
+
88
+ revmap = f.each_line
89
+ .map { |line| line.split(/\s+\|\s+/) }
90
+ .map { |rev, sha, _| [rev.gsub(/^r/, ""), sha] }.to_h # remove leading "r" if present
91
+ end
92
+ end
93
+
94
+ revmap
95
+ end
96
+
97
+ def mock_ticket_details(ticket_id)
98
+ summary = if @filter_applied
99
+ "Not available in trac #{ticket_id}"
100
+ else
101
+ "DELETED in trac #{ticket_id}"
102
+ end
103
+ {
104
+ id: ticket_id,
105
+ summary: summary,
106
+ time: Time.now.to_i,
107
+ status: "closed",
108
+ reporter: "tractive"
109
+ }
110
+ end
111
+
112
+ def update_comment_ref(issue_id)
113
+ comments = @client.issue_comments(@repo, issue_id)
114
+ comments.each do |comment|
115
+ next unless comment["body"].include?("Replying to [comment:")
116
+
117
+ updated_comment_body = create_update_comment_params(comment, comments, issue_id)
118
+ @client.update_issue_comment(@repo, comment["id"], updated_comment_body)
119
+ end
120
+ end
121
+
122
+ def create_update_comment_params(comment, comments, issue_id)
123
+ body = comment["body"]
124
+ matcher = body.match(/Replying to \[comment:(?<comment_number>\d+).*\]/)
125
+ matched_comment = comments[matcher[:comment_number].to_i - 1]
126
+ body.gsub!(/Replying to \[comment:(\d+).*\]/, "Replying to [#{@repo}##{issue_id} (comment:\\1)](#{matched_comment["html_url"]})")
127
+
128
+ body
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "migrator/engine"
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tractive
4
+ class Attachment < Sequel::Model(:attachment)
5
+ dataset_module do
6
+ where(:tickets_attachments, type: "ticket")
7
+ select(:for_export, :id, :filename)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tractive
4
+ class Milestone < Sequel::Model(:milestone)
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tractive
4
+ class Report < Sequel::Model(:report)
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tractive
4
+ class Revision < Sequel::Model(:revision)
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tractive
4
+ class Session < Sequel::Model(:session_attribute)
5
+ end
6
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tractive
4
+ class Ticket < Sequel::Model(:ticket)
5
+ one_to_many :changes, class: Tractive::TicketChange, key: :ticket
6
+ one_to_many :attachments, class: Attachment, key: :id, conditions: { type: "ticket" }
7
+
8
+ dataset_module do
9
+ def for_migration(start_ticket, filterout_closed, filter_options)
10
+ tickets = order(:id)
11
+ .where { id >= start_ticket }
12
+ .filter_column(filter_options)
13
+
14
+ tickets = tickets.exclude(status: "closed") if filterout_closed
15
+
16
+ tickets
17
+ end
18
+
19
+ def filter_column(options)
20
+ return self if options.nil? || options.values.compact.empty?
21
+
22
+ if options[:operator].downcase == "like"
23
+ where { Sequel.like(options[:column_name].to_sym, options[:column_value]) }
24
+ else
25
+ where { Sequel.lit("#{options[:column_name]} #{options[:operator]} '#{options[:column_value]}'") }
26
+ end
27
+ end
28
+ end
29
+
30
+ def all_changes
31
+ # combine the changes and attachment table results and sort them by date
32
+ change_arr = changes + attachments
33
+ change_arr.sort_by { |change| change[:time] }
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tractive
4
+ class TicketChange < Sequel::Model(:ticket_change)
5
+ many_to_one :ticket, key: :ticket
6
+ end
7
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tractive
4
+ class RevmapGenerator
5
+ def initialize(input_file, svn_url, svn_local_path, git_local_repo_path, output_file = "revmap.txt")
6
+ @input_file = input_file
7
+ @git_local_repo_path = git_local_repo_path
8
+ @svn_url = svn_url
9
+ @svn_local_path = svn_local_path
10
+ @duplicate_commits = {}
11
+ @duplicate_message_commits = {}
12
+ @last_revision = nil
13
+ @pinwheel = %w[| / - \\]
14
+ @output_file = output_file
15
+ end
16
+
17
+ def generate
18
+ line_count = File.read(@input_file).scan(/\n/).count
19
+ i = 0
20
+
21
+ File.open(@output_file, "w+") do |file|
22
+ File.foreach(@input_file) do |line|
23
+ info = extract_info_from_line(line)
24
+ next if @last_revision == info[:revision]
25
+
26
+ @last_revision = info[:revision]
27
+ print_revmap_info(info, file)
28
+
29
+ percent = ((i.to_f / line_count) * 100).round(2)
30
+ progress = "=" * (percent.to_i / 2) unless i < 2
31
+ printf("\rProgress: [%<progress>-50s] %<percent>.2f%% %<spinner>s", progress: progress, percent: percent, spinner: @pinwheel.rotate!.first)
32
+ i += 1
33
+ end
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def extract_info_from_line(line)
40
+ info = {}
41
+
42
+ info[:revision], timestamp_author = line.split
43
+ info[:revision], info[:revision_count] = info[:revision].split(".")
44
+ info[:revision].gsub!("SVN:", "r")
45
+ info[:timestamp], author_count = timestamp_author.split("!")
46
+ info[:author], info[:count] = author_count.split(":")
47
+
48
+ info
49
+ end
50
+
51
+ def print_revmap_info(info, file)
52
+ # get sha from git api
53
+ commits = git_commits(info)
54
+
55
+ if commits.count == 1
56
+ file.puts "#{info[:revision]} | #{commits.values[0].join(",")}"
57
+ else
58
+ message = commit_message_from_svn(info[:revision])
59
+ file.puts "#{info[:revision]} | #{@duplicate_commits[info[:timestamp]][message].join(",")}"
60
+ end
61
+ end
62
+
63
+ def git_commits(info)
64
+ return @duplicate_commits[info[:timestamp]] if @duplicate_commits[info[:timestamp]]
65
+
66
+ # get commits from git dir
67
+ commits = commits_from_git_repo(info)
68
+
69
+ commits_hash = {}
70
+ commits.each do |commit|
71
+ message = commit[:message]
72
+ sha = commit[:sha]
73
+
74
+ if commits_hash[message]
75
+ commits_hash[message] << sha
76
+ else
77
+ $logger.warn("'#{sha}' has same timestamp, commiter and commit messgae as '#{commits_hash[message]}'") unless commits_hash[message].nil?
78
+ commits_hash[message] = [sha]
79
+ end
80
+ end
81
+
82
+ @duplicate_commits[info[:timestamp]] = commits_hash if commits.count > 1
83
+
84
+ commits_hash
85
+ end
86
+
87
+ def commit_message_from_svn(revision)
88
+ svn_logs = Tractive::Utilities.svn_log(@svn_url, @svn_local_path, "-r": revision, "--xml": "")
89
+ h = Ox.load(svn_logs, mode: :hash)
90
+ h[:log][:logentry][3][:msg]
91
+ end
92
+
93
+ def commits_from_git_repo(info)
94
+ command = "git rev-list --after=#{info[:timestamp]} --until=#{info[:timestamp]} --committer=#{info[:author]} --all --format='%cd|%h~|~%s' --date=format:'%Y-%m-%dT%H:%M:%SZ'"
95
+ commits = Dir.chdir(@git_local_repo_path) do
96
+ `#{command}`
97
+ end
98
+
99
+ commits_arr = []
100
+ commits.split("\n").each_slice(2) do |sha_hash, commit_info|
101
+ commit_hash = {}
102
+ commit_hash[:sha] = sha_hash.split.last
103
+ commit_hash[:short_sha], commit_hash[:message] = commit_info.split("~|~")
104
+
105
+ commits_arr << commit_hash
106
+ end
107
+
108
+ commits_arr
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tractive
4
+ class Trac
5
+ attr_reader :tickets, :changes, :sessions, :attachments
6
+
7
+ def initialize(db)
8
+ $logger.info("loading tickets")
9
+ @db = db
10
+ @tickets = Ticket
11
+ @changes = TicketChange
12
+ @sessions = Session
13
+ @attachments = Attachment
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tractive
4
+ class Utilities
5
+ class << self
6
+ def make_hash(prefix, array)
7
+ array.map { |i| [i, "#{prefix}#{i}"] }.to_h
8
+ end
9
+
10
+ def setup_db!(db_url)
11
+ files_to_load = [
12
+ "lib/tractive/models/attachment.rb",
13
+ "lib/tractive/models/milestone.rb",
14
+ "lib/tractive/models/report.rb",
15
+ "lib/tractive/models/revision.rb",
16
+ "lib/tractive/models/session.rb",
17
+ "lib/tractive/models/ticket_change.rb",
18
+ "lib/tractive/models/ticket.rb"
19
+ ]
20
+ db = Sequel.connect(db_url) if db_url
21
+
22
+ raise("could not connect to tractive database") unless db
23
+
24
+ files_to_load.each do |file|
25
+ require_relative "../../#{file}"
26
+ end
27
+
28
+ db
29
+ end
30
+
31
+ def setup_logger(options = {})
32
+ $logger = Logger.new(options[:output_stream])
33
+ $logger.level = options[:verbose] ? Logger::DEBUG : Logger::INFO
34
+ $logger.formatter = proc do |severity, datetime, _progname, msg|
35
+ time = datetime.strftime("%Y-%m-%d %H:%M:%S")
36
+ "[#{time}] #{severity}#{" " * (5 - severity.size + 1)}| #{msg}\n"
37
+ end
38
+ end
39
+
40
+ # returns the git commit hash for a specified revision (using revmap hash)
41
+ def map_changeset(str)
42
+ if @revmap&.key?(str)
43
+ "[r#{str}](../commit/#{@revmap[str]}) #{@revmap[str]}"
44
+ else
45
+ str
46
+ end
47
+ end
48
+
49
+ def svn_log(url, local_path, flags = {})
50
+ command = "svn log"
51
+ command += " #{url}" if url
52
+
53
+ flags.each do |key, value|
54
+ command += " #{key}"
55
+ command += " #{value}" if value
56
+ end
57
+
58
+ if local_path
59
+ Dir.chdir(local_path) do
60
+ `#{command}`
61
+ end
62
+ else
63
+ `#{command}`
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tractive
4
+ VERSION = "1.0.0"
5
+ end
data/lib/tractive.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tractive/graceful_quit"
4
+ require_relative "tractive/attachment_exporter"
5
+ require_relative "tractive/migrator"
6
+ require_relative "tractive/trac"
7
+ require_relative "tractive/info"
8
+ require_relative "tractive/version"
9
+ require_relative "tractive/main"
10
+ require_relative "tractive/utilities"
11
+ require_relative "tractive/github_api"
12
+ require_relative "tractive/revmap_generator"
13
+ require "json"
14
+ require "logger"
15
+ require "yaml"
16
+ require "rest-client"
17
+ require "optparse"
18
+ require "sequel"
19
+ require "set"
20
+ require "singleton"
21
+ require "uri"
22
+ require "pry"
23
+ require "thor"
24
+ require "ox"
25
+
26
+ module Tractive
27
+ class Error < StandardError; end
28
+ # Your code goes here...
29
+ end
data/tractive.gemspec ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/tractive/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "tractive"
7
+ spec.version = Tractive::VERSION
8
+ spec.authors = ["Ribose"]
9
+ spec.email = ["open.source@ribose.com"]
10
+
11
+ spec.summary = "Exporting tool for Trac"
12
+ # spec.description = "TODO: Write a longer description or delete this line."
13
+ spec.homepage = "https://github.com/ietf-ribose/tractive"
14
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.4.0")
15
+
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+ spec.metadata["source_code_uri"] = "https://github.com/ietf-ribose/tractive"
18
+ spec.metadata["changelog_uri"] = "https://github.com/ietf-ribose/tractive"
19
+
20
+ # Specify which files should be added to the gem when it is released.
21
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
23
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
24
+ end
25
+ spec.bindir = "exe"
26
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
27
+ spec.require_paths = ["lib"]
28
+
29
+ spec.add_dependency "mysql2"
30
+ spec.add_dependency "ox"
31
+ spec.add_dependency "rest-client"
32
+ spec.add_dependency "sequel"
33
+ spec.add_dependency "sqlite3"
34
+ spec.add_dependency "thor"
35
+
36
+ spec.add_development_dependency "pry"
37
+ end