21 opts = OptionParser.new
26 opts.on('-k REDMINE_API_KEY', '--key=REDMINE_API_KEY', 'specify your REDMINE_API_KEY') {|v| api_key = v}
27 opts.on('-t TARGET_VERSION', '--target=TARGET_VARSION', /\A\d(?:\.\d)+\z/, 'specify target version (ex: 2.1)') {|v| target_version = v}
28 opts.on('-r RUBY_REPO_PATH', '--repository=RUBY_REPO_PATH', 'specify repository path') {|v| repo_path = v}
29 opts.on('--[no-]ssl-verify', TrueClass, 'use / not use SSL verify') {|v| ssl_verify = v}
30 opts.version = VERSION
33 http_options = {use_ssl: true}
34 http_options[:verify_mode] = OpenSSL::SSL::VERIFY_NONE unless ssl_verify
36 $openuri_options[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE unless ssl_verify
38 TARGET_VERSION = target_version || ENV['TARGET_VERSION'] || (raise 'need to specify TARGET_VERSION')
39 RUBY_REPO_PATH = repo_path || ENV['RUBY_REPO_PATH']
40 BACKPORT_CF_KEY = 'cf_5'
42 REDMINE_API_KEY = api_key || ENV['REDMINE_API_KEY'] || (raise 'need to specify REDMINE_API_KEY')
43 REDMINE_BASE = 'https://bugs.ruby-lang.org'
46 'f[]' => BACKPORT_CF_KEY,
47 "op[#{BACKPORT_CF_KEY}]" => '~',
48 "v[#{BACKPORT_CF_KEY}][]" => "\"#{TARGET_VERSION}: REQUIRED\"",
50 'status_id' => STATUS_CLOSE,
51 'sort' => 'updated_on'
55 'Low' => [:white, :blue],
58 'Urgent' => [:red, :white],
59 'Immediate' => [:red, :white, {underscore: true}],
73 def color(fore=nil, back=nil, bold: false, underscore: false)
83 raise "unknown foreground color #{fore}" unless c
88 raise "unknown background color #{back}" unless c
89 seq << "\e[#{c + 10}m"
94 seq << self << "\e[0m"
100 # lx: limit of x (columns of screen)
101 # ly: limit of y (rows of screen)
129 return string[cp1...charpos]
138 ly, lx = console.winsize
141 cls = "\r" + (" " * lx) + "\r"
143 ss = StringScanner.new(str)
145 rows = ss.getrows(lx, ly)
149 case c = console.getch
151 rows = ss.getrows(lx, ly)
154 rows = ss.getrows(lx, 1)
166 def readline(prompt = '')
169 _, lx = console.winsize
170 if /mswin|mingw/ =~ RUBY_PLATFORM or /^(?:vt\d\d\d|xterm)/i =~ ENV["TERM"]
173 cls = "\r" << (" " * lx)
175 cls << "\r" << prompt
180 case c = console.getch
185 when "\C-?", "\b" # DEL/BS
186 print "\b \b" if line.chop!
191 return nil if line.empty?
195 line = HISTORY.current
200 line = HISTORY.current
221 def HISTORY.pos=(val)
225 elsif @pos >= self.size
231 if @pos < 0 || @pos >= self.size
237 end unless defined?(Readline.readline)
239 def find_svn_log(pattern)
240 `svn log --xml --stop-on-copy --search="#{pattern}" #{RUBY_REPO_PATH}`
243 def find_git_log(pattern)
244 `git #{RUBY_REPO_PATH ? "-C #{RUBY_REPO_PATH.shellescape}" : ""} log --grep="#{pattern}"`
247 def has_commit(commit, branch)
248 base = RUBY_REPO_PATH ? ["-C", RUBY_REPO_PATH.shellescape] : nil
249 system("git", *base, "merge-base", "--is-ancestor", commit, branch)
252 def show_last_journal(http, uri)
253 res = http.get("#{uri.path}?include=journals")
257 raise "no issue" unless x
259 raise "no journals" unless x
261 puts "== #{x["user"]["name"]} (#{x["created_on"]})"
262 x["details"].each do |y|
269 RUBY_PLATFORM =~ /mswin|mingw/ ? 'merger' : File.expand_path('../merger.rb', __FILE__)
272 def backport_command_string
273 unless @changesets.respond_to?(:validated)
274 @changesets = @changesets.select do |c|
275 next false if c.match(/\A\d{1,6}\z/) # skip SVN revision
277 # check if the Git revision is included in master
278 has_commit(c, "master")
280 @changesets.define_singleton_method(:validated){true}
282 " #{merger_path} --ticket=#{@issue} #{@changesets.sort.join(',')}"
288 "C".color(bold: true)
295 row, = console.winsize
296 @query['limit'] = row - 2
297 puts "Backporter #{VERSION}".color(bold: true) + " for #{TARGET_VERSION}"
299 class CommandSyntaxError < RuntimeError; end
302 raise CommandSyntaxError unless /\A(\d+)?\z/ =~ args
303 uri = URI(REDMINE_BASE+'/projects/ruby-master/issues.json?'+URI.encode_www_form(@query.dup.merge('page' => ($1 ? $1.to_i : 1))))
305 res = JSON(uri.read($openuri_options))
306 @issues = issues = res["issues"]
307 from = res["offset"] + 1
308 total = res["total_count"]
309 to = from + issues.size - 1
310 puts "#{from}-#{to} / #{total}"
311 issues.each_with_index do |x, i|
312 id = "##{x["id"]}".color(*PRIORITIES[x["priority"]["name"]])
313 puts "#{'%2d' % i} #{id} #{x["priority"]["name"][0]} #{status_char(x["status"])} #{x["subject"][0,80]}"
317 "show" => proc{|args|
318 if /\A(\d+)\z/ =~ args
320 id = @issues[id]["id"] if @issues && id < @issues.size
325 raise CommandSyntaxError
327 uri = "#{REDMINE_BASE}/issues/#{id}"
328 uri = URI(uri+".json?include=children,attachments,relations,changesets,journals")
329 res = JSON(uri.read($openuri_options))
331 unless i["changesets"]
332 abort "You don't have view_changesets permission"
334 unless i["custom_fields"]
335 puts "The specified ticket \##{@issue} seems to be a feature ticket"
339 id = "##{i["id"]}".color(*PRIORITIES[i["priority"]["name"]])
341 sio.set_encoding("utf-8")
343 #{i["subject"].color(bold: true, underscore: true)}
344 #{i["project"]["name"]} [#{i["tracker"]["name"]} #{id}] #{i["status"]["name"]} (#{i["created_on"]})
345 author: #{i["author"]["name"]}
346 assigned: #{i["assigned_to"].to_h["name"]}
348 i["custom_fields"].each do |x|
349 sio.puts "%-10s: %s" % [x["name"], x["value"]]
351 #res["attachments"].each do |x|
353 sio.puts i["description"]
355 sio.puts "= changesets".color(bold: true, underscore: true)
357 i["changesets"].each do |x|
358 @changesets << x["revision"]
359 sio.puts "== #{x["revision"]} #{x["committed_on"]} #{x["user"]["name"] rescue nil}".color(bold: true, underscore: true)
360 sio.puts x["comments"]
362 @changesets = @changesets.sort.uniq
363 if i["journals"] && !i["journals"].empty?
364 sio.puts "= journals".color(bold: true, underscore: true)
365 i["journals"].each do |x|
366 sio.puts "== #{x["user"]["name"]} (#{x["created_on"]})".color(bold: true, underscore: true)
367 x["details"].each do |y|
377 # this feature requires custom redmine which allows add_related_issue API
379 when /\Ar?(\d+)\z/ # SVN
381 uri = URI("#{REDMINE_BASE}/projects/ruby-master/repository/trunk/revisions/#{rev}/issues.json")
382 when /\A\h{7,40}\z/ # Git
384 uri = URI("#{REDMINE_BASE}/projects/ruby-master/repository/git/revisions/#{rev}/issues.json")
386 raise CommandSyntaxError
389 puts "ticket not selected"
393 Net::HTTP.start(uri.host, uri.port, http_options) do |http|
394 res = http.post(uri.path, "issue_id=#@issue",
395 'X-Redmine-API-Key' => REDMINE_API_KEY)
399 if $!.respond_to?(:response) && $!.response.is_a?(Net::HTTPConflict)
400 $stderr.puts "the revision has already related to the ticket"
402 $stderr.puts "#{$!.class}: #{$!.message}\n\ndeployed redmine doesn't have https://github.com/ruby/bugs.ruby-lang.org/commit/01fbba60d68cb916ddbccc8a8710e68c5217171d\nask naruse or hsbt"
409 remove_method(:validated) rescue nil
414 "backport" => proc{|args|
415 # this feature implies backport command which wraps tool/merger.rb
416 raise CommandSyntaxError unless args.empty?
418 puts "ticket not selected"
421 puts backport_command_string
424 "done" => proc{|args|
425 raise CommandSyntaxError unless /\A(\d+)?(?: *by (\h+))?(?:\s*-- +(.*))?\z/ =~ args
427 notes.strip! if notes
431 i = @issues[i]["id"] if @issues && i < @issues.size
435 puts "ticket not selected"
440 elsif system("svn info #{RUBY_REPO_PATH&.shellescape}", %i(out err) => IO::NULL) # SVN
441 if (log = find_svn_log("##@issue]")) && (/revision="(?<rev>\d+)/ =~ log)
445 if log = find_git_log("##@issue]")
446 /^commit (?<rev>\h{40})$/ =~ log
450 str = log[/merge revision\(s\) ([^:]+)(?=:)/]
453 str = "ruby_#{TARGET_VERSION.tr('.','_')} #{rev} #{str}."
455 str = "ruby_#{TARGET_VERSION.tr('.','_')} #{rev}."
462 elsif rev && has_commit(rev, "ruby_#{TARGET_VERSION.tr('.','_')}")
463 # Backport commit's log doesn't have the issue number.
464 # Instead of that manually it's provided.
465 notes = "ruby_#{TARGET_VERSION.tr('.','_')} commit:#{rev}."
467 puts "no commit is found whose log include ##@issue"
472 uri = URI("#{REDMINE_BASE}/issues/#{@issue}.json")
473 Net::HTTP.start(uri.host, uri.port, http_options) do |http|
474 res = http.get(uri.path)
475 data = JSON(res.body)
476 h = data["issue"]["custom_fields"].find{|x|x["id"]==5}
477 if h and val = h["value"] and val != ""
478 case val[/(?:\A|, )#{Regexp.quote TARGET_VERSION}: ([^,]+)/, 1]
479 when 'REQUIRED', 'UNKNOWN', 'DONTNEED', 'WONTFIX'
480 val[$~.offset(1)[0]...$~.offset(1)[1]] = 'DONE'
481 when 'DONE' # , /\A\d+\z/
482 puts 'already backport is done'
485 val << ", #{TARGET_VERSION}: DONE"
487 raise "unknown status '#$1'"
490 val = "#{TARGET_VERSION}: DONE"
493 data = { "issue" => { "custom_fields" => [ {"id"=>5, "value" => val} ] } }
494 data['issue']['notes'] = notes if notes
495 res = http.put(uri.path, JSON(data),
496 'X-Redmine-API-Key' => REDMINE_API_KEY,
497 'Content-Type' => 'application/json')
500 show_last_journal(http, uri)
504 "close" => proc{|args|
505 raise CommandSyntaxError unless /\A(\d+)?\z/ =~ args
508 i = @issues[i]["id"] if @issues && i < @issues.size
512 puts "ticket not selected"
516 uri = URI("#{REDMINE_BASE}/issues/#{@issue}.json")
517 Net::HTTP.start(uri.host, uri.port, http_options) do |http|
518 data = { "issue" => { "status_id" => STATUS_CLOSE } }
519 res = http.put(uri.path, JSON(data),
520 'X-Redmine-API-Key' => REDMINE_API_KEY,
521 'Content-Type' => 'application/json')
524 show_last_journal(http, uri)
528 "last" => proc{|args|
529 raise CommandSyntaxError unless /\A(\d+)?\z/ =~ args
532 i = @issues[i]["id"] if @issues && i < @issues.size
536 puts "ticket not selected"
540 uri = URI("#{REDMINE_BASE}/issues/#{@issue}.json")
541 Net::HTTP.start(uri.host, uri.port, http_options) do |http|
542 show_last_journal(http, uri)
550 "quit" => proc{|args|
551 raise CommandSyntaxError unless args.empty?
556 "help" => proc{|args|
557 puts 'ls [PAGE] '.color(bold: true) + ' show all required tickets'
558 puts '[show] TICKET '.color(bold: true) + ' show the detail of the TICKET, and select it'
559 puts 'backport '.color(bold: true) + ' show the option of selected ticket for merger.rb'
560 puts 'rel REVISION '.color(bold: true) + ' add the selected ticket as related to the REVISION'
561 puts 'done [TICKET] [-- NOTE]'.color(bold: true) + ' set Backport field of the TICKET to DONE'
562 puts 'close [TICKET] '.color(bold: true) + ' close the TICKET'
563 puts 'last [TICKET] '.color(bold: true) + ' show the last journal of the TICKET'
564 puts '! COMMAND '.color(bold: true) + ' execute COMMAND'
567 list = Abbrev.abbrev(commands.keys)
574 l = Readline.readline "#{('#' + @issue.to_s).color(bold: true) if @issue}> "
579 cmd, args = l.strip.split(/\s+|\b/, 2)
581 if (!args || args.empty?) && /\A\d+\z/ =~ cmd
586 if commands[cmd].is_a? String
587 cmd = list[commands[cmd]]
591 commands[cmd].call(args)
593 raise CommandSyntaxError
595 rescue CommandSyntaxError
596 puts "error #{l.inspect}"