tractive 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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