[rubygems/rubygems] Use a constant empty tar header to avoid extra allocations
[ruby.git] / tool / redmine-backporter.rb
blob44b44920d43abb2a232a4a95033178b53e74c942
1 #!/usr/bin/env ruby
2 require 'open-uri'
3 require 'openssl'
4 require 'net/http'
5 require 'json'
6 require 'io/console'
7 require 'stringio'
8 require 'strscan'
9 require 'optparse'
10 require 'abbrev'
11 require 'pp'
12 require 'shellwords'
13 begin
14   require 'readline'
15 rescue LoadError
16   module Readline; end
17 end
19 VERSION = '0.0.1'
21 opts = OptionParser.new
22 target_version = nil
23 repo_path = nil
24 api_key = nil
25 ssl_verify = true
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
31 opts.parse!(ARGV)
33 http_options = {use_ssl: true}
34 http_options[:verify_mode] = OpenSSL::SSL::VERIFY_NONE unless ssl_verify
35 $openuri_options = {}
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'
41 STATUS_CLOSE = 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'
45 @query = {
46   'f[]' => BACKPORT_CF_KEY,
47   "op[#{BACKPORT_CF_KEY}]" => '~',
48   "v[#{BACKPORT_CF_KEY}][]" => "\"#{TARGET_VERSION}: REQUIRED\"",
49   'limit' => 40,
50   'status_id' => STATUS_CLOSE,
51   'sort' => 'updated_on'
54 PRIORITIES = {
55   'Low' => [:white, :blue],
56   'Normal' => [],
57   'High' => [:red],
58   'Urgent' => [:red, :white],
59   'Immediate' => [:red, :white, {underscore: true}],
61 COLORS = {
62   black: 30,
63   red: 31,
64   green: 32,
65   yellow: 33,
66   blue: 34,
67   magenta: 35,
68   cyan: 36,
69   white: 37,
72 class String
73   def color(fore=nil, back=nil, bold: false, underscore: false)
74     seq = ""
75     if bold
76       seq << "\e[1m"
77     end
78     if underscore
79       seq << "\e[2m"
80     end
81     if fore
82       c = COLORS[fore]
83       raise "unknown foreground color #{fore}" unless c
84       seq << "\e[#{c}m"
85     end
86     if back
87       c = COLORS[back]
88       raise "unknown background color #{back}" unless c
89       seq << "\e[#{c + 10}m"
90     end
91     if seq.empty?
92       self
93     else
94       seq << self << "\e[0m"
95     end
96   end
97 end
99 class StringScanner
100   # lx: limit of x (columns of screen)
101   # ly: limit of y (rows of screen)
102   def getrows(lx, ly)
103     cp1 = charpos
104     x = 0
105     y = 0
106     until eos?
107       case c = getch
108       when "\r"
109         x = 0
110       when "\n"
111         x = 0
112         y += 1
113       when "\t"
114         x += 8
115       when /[\x00-\x7f]/
116         # halfwidth
117         x += 1
118       else
119         # fullwidth
120         x += 2
121       end
123       if x > lx
124         x = 0
125         y += 1
126         unscan
127       end
128       if y >= ly
129         return string[cp1...charpos]
130       end
131     end
132     string[cp1..-1]
133   end
136 def more(sio)
137   console = IO.console
138   ly, lx = console.winsize
139   ly -= 1
140   str = sio.string
141   cls = "\r" + (" " * lx) + "\r"
143   ss = StringScanner.new(str)
145   rows = ss.getrows(lx, ly)
146   puts rows
147   until ss.eos?
148     print ":"
149     case c = console.getch
150     when ' '
151       rows = ss.getrows(lx, ly)
152       puts cls + rows
153     when 'j', "\r"
154       rows = ss.getrows(lx, 1)
155       puts cls + rows
156     when "q"
157       print cls
158       break
159     else
160       print "\b"
161     end
162   end
165 class << Readline
166   def readline(prompt = '')
167     console = IO.console
168     console.binmode
169     _, lx = console.winsize
170     if /mswin|mingw/ =~ RUBY_PLATFORM or /^(?:vt\d\d\d|xterm)/i =~ ENV["TERM"]
171       cls = "\r\e[2K"
172     else
173       cls = "\r" << (" " * lx)
174     end
175     cls << "\r" << prompt
176     console.print prompt
177     console.flush
178     line = ''
179     while true
180       case c = console.getch
181       when "\r", "\n"
182         puts
183         HISTORY << line
184         return line
185       when "\C-?", "\b" # DEL/BS
186         print "\b \b" if line.chop!
187       when "\C-u"
188         print cls
189         line.clear
190       when "\C-d"
191         return nil if line.empty?
192         line << c
193       when "\C-p"
194         HISTORY.pos -= 1
195         line = HISTORY.current
196         print cls
197         print line
198       when "\C-n"
199         HISTORY.pos += 1
200         line = HISTORY.current
201         print cls
202         print line
203       else
204         if c >= " "
205           print c
206           line << c
207         end
208       end
209     end
210   end
212   HISTORY = []
213   def HISTORY.<<(val)
214     HISTORY.push(val)
215     @pos = self.size
216     self
217   end
218   def HISTORY.pos
219     @pos ||= 0
220   end
221   def HISTORY.pos=(val)
222     @pos = val
223     if @pos < 0
224       @pos = -1
225     elsif @pos >= self.size
226       @pos = self.size
227     end
228   end
229   def HISTORY.current
230     @pos ||= 0
231     if @pos < 0 || @pos >= self.size
232       ''
233     else
234       self[@pos]
235     end
236   end
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")
254   res.value
255   h = JSON(res.body)
256   x = h["issue"]
257   raise "no issue" unless x
258   x = x["journals"]
259   raise "no journals" unless x
260   x = x.last
261   puts "== #{x["user"]["name"]} (#{x["created_on"]})"
262   x["details"].each do |y|
263     puts JSON(y)
264   end
265   puts x["notes"]
268 def merger_path
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")
279     end
280     @changesets.define_singleton_method(:validated){true}
281   end
282   " #{merger_path} --ticket=#{@issue} #{@changesets.sort.join(',')}"
285 def status_char(obj)
286   case obj["name"]
287   when "Closed"
288     "C".color(bold: true)
289   else
290     obj["name"][0]
291   end
294 console = IO.console
295 row, = console.winsize
296 @query['limit'] = row - 2
297 puts "Backporter #{VERSION}".color(bold: true) + " for #{TARGET_VERSION}"
299 class CommandSyntaxError < RuntimeError; end
300 commands = {
301   "ls" => proc{|args|
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))))
304     # puts uri
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]}"
314     end
315   },
317   "show" => proc{|args|
318     if /\A(\d+)\z/ =~ args
319       id = $1.to_i
320       id = @issues[id]["id"] if @issues && id < @issues.size
321       @issue = id
322     elsif @issue
323       id = @issue
324     else
325       raise CommandSyntaxError
326     end
327     uri = "#{REDMINE_BASE}/issues/#{id}"
328     uri = URI(uri+".json?include=children,attachments,relations,changesets,journals")
329     res = JSON(uri.read($openuri_options))
330     i = res["issue"]
331     unless i["changesets"]
332       abort "You don't have view_changesets permission"
333     end
334     unless i["custom_fields"]
335       puts "The specified ticket \##{@issue} seems to be a feature ticket"
336       @issue = nil
337       next
338     end
339     id = "##{i["id"]}".color(*PRIORITIES[i["priority"]["name"]])
340     sio = StringIO.new
341     sio.set_encoding("utf-8")
342     sio.puts <<eom
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"]]
350     end
351     #res["attachments"].each do |x|
352     #end
353     sio.puts i["description"]
354     sio.puts
355     sio.puts "= changesets".color(bold: true, underscore: true)
356     @changesets = []
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"]
361     end
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|
368           sio.puts JSON(y)
369         end
370         sio.puts x["notes"]
371       end
372     end
373     more(sio)
374   },
376   "rel" => proc{|args|
377     # this feature requires custom redmine which allows add_related_issue API
378     case args
379     when /\Ar?(\d+)\z/ # SVN
380       rev = $1
381       uri = URI("#{REDMINE_BASE}/projects/ruby-master/repository/trunk/revisions/#{rev}/issues.json")
382     when /\A\h{7,40}\z/ # Git
383       rev = args
384       uri = URI("#{REDMINE_BASE}/projects/ruby-master/repository/git/revisions/#{rev}/issues.json")
385     else
386       raise CommandSyntaxError
387     end
388     unless @issue
389       puts "ticket not selected"
390       next
391     end
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)
396       begin
397         res.value
398       rescue
399         if $!.respond_to?(:response) && $!.response.is_a?(Net::HTTPConflict)
400           $stderr.puts "the revision has already related to the ticket"
401         else
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"
403         end
404         next
405       end
406       puts res.body
407       @changesets << rev
408       class << @changesets
409         remove_method(:validated) rescue nil
410       end
411     end
412   },
414   "backport" => proc{|args|
415     # this feature implies backport command which wraps tool/merger.rb
416     raise CommandSyntaxError unless args.empty?
417     unless @issue
418       puts "ticket not selected"
419       next
420     end
421     puts backport_command_string
422   },
424   "done" => proc{|args|
425     raise CommandSyntaxError unless /\A(\d+)?(?: *by (\h+))?(?:\s*-- +(.*))?\z/ =~ args
426     notes = $3
427     notes.strip! if notes
428     rev = $2
429     if $1
430       i = $1.to_i
431       i = @issues[i]["id"] if @issues && i < @issues.size
432       @issue = i
433     end
434     unless @issue
435       puts "ticket not selected"
436       next
437     end
439     if rev
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)
442         rev = "r#{rev}"
443       end
444     else # Git
445       if log = find_git_log("##@issue]")
446         /^commit (?<rev>\h{40})$/ =~ log
447       end
448     end
449     if log && rev
450       str = log[/merge revision\(s\) ([^:]+)(?=:)/]
451       if str
452         str.insert(5, "d")
453         str = "ruby_#{TARGET_VERSION.tr('.','_')} #{rev} #{str}."
454       else
455         str = "ruby_#{TARGET_VERSION.tr('.','_')} #{rev}."
456       end
457       if notes
458         str << "\n"
459         str << notes
460       end
461       notes = str
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}."
466     else
467       puts "no commit is found whose log include ##@issue"
468       next
469     end
470     puts notes
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'
483           next # already done
484         when nil
485           val << ", #{TARGET_VERSION}: DONE"
486         else
487           raise "unknown status '#$1'"
488         end
489       else
490         val = "#{TARGET_VERSION}: DONE"
491       end
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')
498       res.value
500       show_last_journal(http, uri)
501     end
502   },
504   "close" => proc{|args|
505     raise CommandSyntaxError unless /\A(\d+)?\z/ =~ args
506     if $1
507       i = $1.to_i
508       i = @issues[i]["id"] if @issues && i < @issues.size
509       @issue = i
510     end
511     unless @issue
512       puts "ticket not selected"
513       next
514     end
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')
522       res.value
524       show_last_journal(http, uri)
525     end
526   },
528   "last" => proc{|args|
529     raise CommandSyntaxError unless /\A(\d+)?\z/ =~ args
530     if $1
531       i = $1.to_i
532       i = @issues[i]["id"] if @issues && i < @issues.size
533       @issue = i
534     end
535     unless @issue
536       puts "ticket not selected"
537       next
538     end
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)
543     end
544   },
546   "!" => proc{|args|
547     system(args.strip)
548   },
550   "quit" => proc{|args|
551     raise CommandSyntaxError unless args.empty?
552     exit
553   },
554   "exit" => "quit",
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'
565   }
567 list = Abbrev.abbrev(commands.keys)
569 @issues = nil
570 @issue = nil
571 @changesets = nil
572 while true
573   begin
574     l = Readline.readline "#{('#' + @issue.to_s).color(bold: true) if @issue}> "
575   rescue Interrupt
576     break
577   end
578   break unless l
579   cmd, args = l.strip.split(/\s+|\b/, 2)
580   next unless cmd
581   if (!args || args.empty?) && /\A\d+\z/ =~ cmd
582     args = cmd
583     cmd = "show"
584   end
585   cmd = list[cmd]
586   if commands[cmd].is_a? String
587     cmd = list[commands[cmd]]
588   end
589   begin
590     if cmd
591       commands[cmd].call(args)
592     else
593       raise CommandSyntaxError
594     end
595   rescue CommandSyntaxError
596     puts "error #{l.inspect}"
597   end