зеркало из https://github.com/github/ruby.git
599 строки
16 KiB
Ruby
Executable File
599 строки
16 KiB
Ruby
Executable File
#!/usr/bin/env ruby
|
|
require 'open-uri'
|
|
require 'openssl'
|
|
require 'net/http'
|
|
require 'json'
|
|
require 'io/console'
|
|
require 'stringio'
|
|
require 'strscan'
|
|
require 'optparse'
|
|
require 'abbrev'
|
|
require 'pp'
|
|
require 'shellwords'
|
|
begin
|
|
require 'readline'
|
|
rescue LoadError
|
|
module Readline; end
|
|
end
|
|
|
|
VERSION = '0.0.1'
|
|
|
|
opts = OptionParser.new
|
|
target_version = nil
|
|
repo_path = nil
|
|
api_key = nil
|
|
ssl_verify = true
|
|
opts.on('-k REDMINE_API_KEY', '--key=REDMINE_API_KEY', 'specify your REDMINE_API_KEY') {|v| api_key = v}
|
|
opts.on('-t TARGET_VERSION', '--target=TARGET_VARSION', /\A\d(?:\.\d)+\z/, 'specify target version (ex: 2.1)') {|v| target_version = v}
|
|
opts.on('-r RUBY_REPO_PATH', '--repository=RUBY_REPO_PATH', 'specify repository path') {|v| repo_path = v}
|
|
opts.on('--[no-]ssl-verify', TrueClass, 'use / not use SSL verify') {|v| ssl_verify = v}
|
|
opts.version = VERSION
|
|
opts.parse!(ARGV)
|
|
|
|
http_options = {use_ssl: true}
|
|
http_options[:verify_mode] = OpenSSL::SSL::VERIFY_NONE unless ssl_verify
|
|
$openuri_options = {}
|
|
$openuri_options[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE unless ssl_verify
|
|
|
|
TARGET_VERSION = target_version || ENV['TARGET_VERSION'] || (raise 'need to specify TARGET_VERSION')
|
|
RUBY_REPO_PATH = repo_path || ENV['RUBY_REPO_PATH']
|
|
BACKPORT_CF_KEY = 'cf_5'
|
|
STATUS_CLOSE = 5
|
|
REDMINE_API_KEY = api_key || ENV['REDMINE_API_KEY'] || (raise 'need to specify REDMINE_API_KEY')
|
|
REDMINE_BASE = 'https://bugs.ruby-lang.org'
|
|
|
|
@query = {
|
|
'f[]' => BACKPORT_CF_KEY,
|
|
"op[#{BACKPORT_CF_KEY}]" => '~',
|
|
"v[#{BACKPORT_CF_KEY}][]" => "\"#{TARGET_VERSION}: REQUIRED\"",
|
|
'limit' => 40,
|
|
'status_id' => STATUS_CLOSE,
|
|
'sort' => 'updated_on'
|
|
}
|
|
|
|
PRIORITIES = {
|
|
'Low' => [:white, :blue],
|
|
'Normal' => [],
|
|
'High' => [:red],
|
|
'Urgent' => [:red, :white],
|
|
'Immediate' => [:red, :white, {underscore: true}],
|
|
}
|
|
COLORS = {
|
|
black: 30,
|
|
red: 31,
|
|
green: 32,
|
|
yellow: 33,
|
|
blue: 34,
|
|
magenta: 35,
|
|
cyan: 36,
|
|
white: 37,
|
|
}
|
|
|
|
class String
|
|
def color(fore=nil, back=nil, bold: false, underscore: false)
|
|
seq = ""
|
|
if bold
|
|
seq << "\e[1m"
|
|
end
|
|
if underscore
|
|
seq << "\e[2m"
|
|
end
|
|
if fore
|
|
c = COLORS[fore]
|
|
raise "unknown foreground color #{fore}" unless c
|
|
seq << "\e[#{c}m"
|
|
end
|
|
if back
|
|
c = COLORS[back]
|
|
raise "unknown background color #{back}" unless c
|
|
seq << "\e[#{c + 10}m"
|
|
end
|
|
if seq.empty?
|
|
self
|
|
else
|
|
seq << self << "\e[0m"
|
|
end
|
|
end
|
|
end
|
|
|
|
class StringScanner
|
|
# lx: limit of x (columns of screen)
|
|
# ly: limit of y (rows of screen)
|
|
def getrows(lx, ly)
|
|
cp1 = charpos
|
|
x = 0
|
|
y = 0
|
|
until eos?
|
|
case c = getch
|
|
when "\r"
|
|
x = 0
|
|
when "\n"
|
|
x = 0
|
|
y += 1
|
|
when "\t"
|
|
x += 8
|
|
when /[\x00-\x7f]/
|
|
# halfwidth
|
|
x += 1
|
|
else
|
|
# fullwidth
|
|
x += 2
|
|
end
|
|
|
|
if x > lx
|
|
x = 0
|
|
y += 1
|
|
unscan
|
|
end
|
|
if y >= ly
|
|
return string[cp1...charpos]
|
|
end
|
|
end
|
|
string[cp1..-1]
|
|
end
|
|
end
|
|
|
|
def more(sio)
|
|
console = IO.console
|
|
ly, lx = console.winsize
|
|
ly -= 1
|
|
str = sio.string
|
|
cls = "\r" + (" " * lx) + "\r"
|
|
|
|
ss = StringScanner.new(str)
|
|
|
|
rows = ss.getrows(lx, ly)
|
|
puts rows
|
|
until ss.eos?
|
|
print ":"
|
|
case c = console.getch
|
|
when ' '
|
|
rows = ss.getrows(lx, ly)
|
|
puts cls + rows
|
|
when 'j', "\r"
|
|
rows = ss.getrows(lx, 1)
|
|
puts cls + rows
|
|
when "q"
|
|
print cls
|
|
break
|
|
else
|
|
print "\b"
|
|
end
|
|
end
|
|
end
|
|
|
|
class << Readline
|
|
def readline(prompt = '')
|
|
console = IO.console
|
|
console.binmode
|
|
_, lx = console.winsize
|
|
if /mswin|mingw/ =~ RUBY_PLATFORM or /^(?:vt\d\d\d|xterm)/i =~ ENV["TERM"]
|
|
cls = "\r\e[2K"
|
|
else
|
|
cls = "\r" << (" " * lx)
|
|
end
|
|
cls << "\r" << prompt
|
|
console.print prompt
|
|
console.flush
|
|
line = ''
|
|
while true
|
|
case c = console.getch
|
|
when "\r", "\n"
|
|
puts
|
|
HISTORY << line
|
|
return line
|
|
when "\C-?", "\b" # DEL/BS
|
|
print "\b \b" if line.chop!
|
|
when "\C-u"
|
|
print cls
|
|
line.clear
|
|
when "\C-d"
|
|
return nil if line.empty?
|
|
line << c
|
|
when "\C-p"
|
|
HISTORY.pos -= 1
|
|
line = HISTORY.current
|
|
print cls
|
|
print line
|
|
when "\C-n"
|
|
HISTORY.pos += 1
|
|
line = HISTORY.current
|
|
print cls
|
|
print line
|
|
else
|
|
if c >= " "
|
|
print c
|
|
line << c
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
HISTORY = []
|
|
def HISTORY.<<(val)
|
|
HISTORY.push(val)
|
|
@pos = self.size
|
|
self
|
|
end
|
|
def HISTORY.pos
|
|
@pos ||= 0
|
|
end
|
|
def HISTORY.pos=(val)
|
|
@pos = val
|
|
if @pos < 0
|
|
@pos = -1
|
|
elsif @pos >= self.size
|
|
@pos = self.size
|
|
end
|
|
end
|
|
def HISTORY.current
|
|
@pos ||= 0
|
|
if @pos < 0 || @pos >= self.size
|
|
''
|
|
else
|
|
self[@pos]
|
|
end
|
|
end
|
|
end unless defined?(Readline.readline)
|
|
|
|
def find_svn_log(pattern)
|
|
`svn log --xml --stop-on-copy --search="#{pattern}" #{RUBY_REPO_PATH}`
|
|
end
|
|
|
|
def find_git_log(pattern)
|
|
`git #{RUBY_REPO_PATH ? "-C #{RUBY_REPO_PATH.shellescape}" : ""} log --grep="#{pattern}"`
|
|
end
|
|
|
|
def has_commit(commit, branch)
|
|
base = RUBY_REPO_PATH ? ["-C", RUBY_REPO_PATH.shellescape] : nil
|
|
system("git", *base, "merge-base", "--is-ancestor", commit, branch)
|
|
end
|
|
|
|
def show_last_journal(http, uri)
|
|
res = http.get("#{uri.path}?include=journals")
|
|
res.value
|
|
h = JSON(res.body)
|
|
x = h["issue"]
|
|
raise "no issue" unless x
|
|
x = x["journals"]
|
|
raise "no journals" unless x
|
|
x = x.last
|
|
puts "== #{x["user"]["name"]} (#{x["created_on"]})"
|
|
x["details"].each do |y|
|
|
puts JSON(y)
|
|
end
|
|
puts x["notes"]
|
|
end
|
|
|
|
def merger_path
|
|
RUBY_PLATFORM =~ /mswin|mingw/ ? 'merger' : File.expand_path('../merger.rb', __FILE__)
|
|
end
|
|
|
|
def backport_command_string
|
|
unless @changesets.respond_to?(:validated)
|
|
@changesets = @changesets.select do |c|
|
|
next false if c.match(/\A\d{1,6}\z/) # skip SVN revision
|
|
|
|
# check if the Git revision is included in master
|
|
has_commit(c, "master")
|
|
end
|
|
@changesets.define_singleton_method(:validated){true}
|
|
end
|
|
" #{merger_path} --ticket=#{@issue} #{@changesets.sort.join(',')}"
|
|
end
|
|
|
|
def status_char(obj)
|
|
case obj["name"]
|
|
when "Closed"
|
|
"C".color(bold: true)
|
|
else
|
|
obj["name"][0]
|
|
end
|
|
end
|
|
|
|
console = IO.console
|
|
row, = console.winsize
|
|
@query['limit'] = row - 2
|
|
puts "Backporter #{VERSION}".color(bold: true) + " for #{TARGET_VERSION}"
|
|
|
|
class CommandSyntaxError < RuntimeError; end
|
|
commands = {
|
|
"ls" => proc{|args|
|
|
raise CommandSyntaxError unless /\A(\d+)?\z/ =~ args
|
|
uri = URI(REDMINE_BASE+'/projects/ruby-master/issues.json?'+URI.encode_www_form(@query.dup.merge('page' => ($1 ? $1.to_i : 1))))
|
|
# puts uri
|
|
res = JSON(uri.read($openuri_options))
|
|
@issues = issues = res["issues"]
|
|
from = res["offset"] + 1
|
|
total = res["total_count"]
|
|
to = from + issues.size - 1
|
|
puts "#{from}-#{to} / #{total}"
|
|
issues.each_with_index do |x, i|
|
|
id = "##{x["id"]}".color(*PRIORITIES[x["priority"]["name"]])
|
|
puts "#{'%2d' % i} #{id} #{x["priority"]["name"][0]} #{status_char(x["status"])} #{x["subject"][0,80]}"
|
|
end
|
|
},
|
|
|
|
"show" => proc{|args|
|
|
if /\A(\d+)\z/ =~ args
|
|
id = $1.to_i
|
|
id = @issues[id]["id"] if @issues && id < @issues.size
|
|
@issue = id
|
|
elsif @issue
|
|
id = @issue
|
|
else
|
|
raise CommandSyntaxError
|
|
end
|
|
uri = "#{REDMINE_BASE}/issues/#{id}"
|
|
uri = URI(uri+".json?include=children,attachments,relations,changesets,journals")
|
|
res = JSON(uri.read($openuri_options))
|
|
i = res["issue"]
|
|
unless i["changesets"]
|
|
abort "You don't have view_changesets permission"
|
|
end
|
|
unless i["custom_fields"]
|
|
puts "The specified ticket \##{@issue} seems to be a feature ticket"
|
|
@issue = nil
|
|
next
|
|
end
|
|
id = "##{i["id"]}".color(*PRIORITIES[i["priority"]["name"]])
|
|
sio = StringIO.new
|
|
sio.set_encoding("utf-8")
|
|
sio.puts <<eom
|
|
#{i["subject"].color(bold: true, underscore: true)}
|
|
#{i["project"]["name"]} [#{i["tracker"]["name"]} #{id}] #{i["status"]["name"]} (#{i["created_on"]})
|
|
author: #{i["author"]["name"]}
|
|
assigned: #{i["assigned_to"].to_h["name"]}
|
|
eom
|
|
i["custom_fields"].each do |x|
|
|
sio.puts "%-10s: %s" % [x["name"], x["value"]]
|
|
end
|
|
#res["attachments"].each do |x|
|
|
#end
|
|
sio.puts i["description"]
|
|
sio.puts
|
|
sio.puts "= changesets".color(bold: true, underscore: true)
|
|
@changesets = []
|
|
i["changesets"].each do |x|
|
|
@changesets << x["revision"]
|
|
sio.puts "== #{x["revision"]} #{x["committed_on"]} #{x["user"]["name"] rescue nil}".color(bold: true, underscore: true)
|
|
sio.puts x["comments"]
|
|
end
|
|
@changesets = @changesets.sort.uniq
|
|
if i["journals"] && !i["journals"].empty?
|
|
sio.puts "= journals".color(bold: true, underscore: true)
|
|
i["journals"].each do |x|
|
|
sio.puts "== #{x["user"]["name"]} (#{x["created_on"]})".color(bold: true, underscore: true)
|
|
x["details"].each do |y|
|
|
sio.puts JSON(y)
|
|
end
|
|
sio.puts x["notes"]
|
|
end
|
|
end
|
|
more(sio)
|
|
},
|
|
|
|
"rel" => proc{|args|
|
|
# this feature requires custom redmine which allows add_related_issue API
|
|
case args
|
|
when /\Ar?(\d+)\z/ # SVN
|
|
rev = $1
|
|
uri = URI("#{REDMINE_BASE}/projects/ruby-master/repository/trunk/revisions/#{rev}/issues.json")
|
|
when /\A\h{7,40}\z/ # Git
|
|
rev = args
|
|
uri = URI("#{REDMINE_BASE}/projects/ruby-master/repository/git/revisions/#{rev}/issues.json")
|
|
else
|
|
raise CommandSyntaxError
|
|
end
|
|
unless @issue
|
|
puts "ticket not selected"
|
|
next
|
|
end
|
|
|
|
Net::HTTP.start(uri.host, uri.port, http_options) do |http|
|
|
res = http.post(uri.path, "issue_id=#@issue",
|
|
'X-Redmine-API-Key' => REDMINE_API_KEY)
|
|
begin
|
|
res.value
|
|
rescue
|
|
if $!.respond_to?(:response) && $!.response.is_a?(Net::HTTPConflict)
|
|
$stderr.puts "the revision has already related to the ticket"
|
|
else
|
|
$stderr.puts "#{$!.class}: #{$!.message}\n\ndeployed redmine doesn't have https://github.com/ruby/bugs.ruby-lang.org/commit/01fbba60d68cb916ddbccc8a8710e68c5217171d\nask naruse or hsbt"
|
|
end
|
|
next
|
|
end
|
|
puts res.body
|
|
@changesets << rev
|
|
class << @changesets
|
|
remove_method(:validated) rescue nil
|
|
end
|
|
end
|
|
},
|
|
|
|
"backport" => proc{|args|
|
|
# this feature implies backport command which wraps tool/merger.rb
|
|
raise CommandSyntaxError unless args.empty?
|
|
unless @issue
|
|
puts "ticket not selected"
|
|
next
|
|
end
|
|
puts backport_command_string
|
|
},
|
|
|
|
"done" => proc{|args|
|
|
raise CommandSyntaxError unless /\A(\d+)?(?: *by (\h+))?(?:\s*-- +(.*))?\z/ =~ args
|
|
notes = $3
|
|
notes.strip! if notes
|
|
rev = $2
|
|
if $1
|
|
i = $1.to_i
|
|
i = @issues[i]["id"] if @issues && i < @issues.size
|
|
@issue = i
|
|
end
|
|
unless @issue
|
|
puts "ticket not selected"
|
|
next
|
|
end
|
|
|
|
if rev
|
|
elsif system("svn info #{RUBY_REPO_PATH&.shellescape}", %i(out err) => IO::NULL) # SVN
|
|
if (log = find_svn_log("##@issue]")) && (/revision="(?<rev>\d+)/ =~ log)
|
|
rev = "r#{rev}"
|
|
end
|
|
else # Git
|
|
if log = find_git_log("##@issue]")
|
|
/^commit (?<rev>\h{40})$/ =~ log
|
|
end
|
|
end
|
|
if log && rev
|
|
str = log[/merge revision\(s\) ([^:]+)(?=:)/]
|
|
if str
|
|
str.insert(5, "d")
|
|
str = "ruby_#{TARGET_VERSION.tr('.','_')} #{rev} #{str}."
|
|
else
|
|
str = "ruby_#{TARGET_VERSION.tr('.','_')} #{rev}."
|
|
end
|
|
if notes
|
|
str << "\n"
|
|
str << notes
|
|
end
|
|
notes = str
|
|
elsif rev && has_commit(rev, "ruby_#{TARGET_VERSION.tr('.','_')}")
|
|
# Backport commit's log doesn't have the issue number.
|
|
# Instead of that manually it's provided.
|
|
notes = "ruby_#{TARGET_VERSION.tr('.','_')} commit:#{rev}."
|
|
else
|
|
puts "no commit is found whose log include ##@issue"
|
|
next
|
|
end
|
|
puts notes
|
|
|
|
uri = URI("#{REDMINE_BASE}/issues/#{@issue}.json")
|
|
Net::HTTP.start(uri.host, uri.port, http_options) do |http|
|
|
res = http.get(uri.path)
|
|
data = JSON(res.body)
|
|
h = data["issue"]["custom_fields"].find{|x|x["id"]==5}
|
|
if h and val = h["value"] and val != ""
|
|
case val[/(?:\A|, )#{Regexp.quote TARGET_VERSION}: ([^,]+)/, 1]
|
|
when 'REQUIRED', 'UNKNOWN', 'DONTNEED', 'WONTFIX'
|
|
val[$~.offset(1)[0]...$~.offset(1)[1]] = 'DONE'
|
|
when 'DONE' # , /\A\d+\z/
|
|
puts 'already backport is done'
|
|
next # already done
|
|
when nil
|
|
val << ", #{TARGET_VERSION}: DONE"
|
|
else
|
|
raise "unknown status '#$1'"
|
|
end
|
|
else
|
|
val = "#{TARGET_VERSION}: DONE"
|
|
end
|
|
|
|
data = { "issue" => { "custom_fields" => [ {"id"=>5, "value" => val} ] } }
|
|
data['issue']['notes'] = notes if notes
|
|
res = http.put(uri.path, JSON(data),
|
|
'X-Redmine-API-Key' => REDMINE_API_KEY,
|
|
'Content-Type' => 'application/json')
|
|
res.value
|
|
|
|
show_last_journal(http, uri)
|
|
end
|
|
},
|
|
|
|
"close" => proc{|args|
|
|
raise CommandSyntaxError unless /\A(\d+)?\z/ =~ args
|
|
if $1
|
|
i = $1.to_i
|
|
i = @issues[i]["id"] if @issues && i < @issues.size
|
|
@issue = i
|
|
end
|
|
unless @issue
|
|
puts "ticket not selected"
|
|
next
|
|
end
|
|
|
|
uri = URI("#{REDMINE_BASE}/issues/#{@issue}.json")
|
|
Net::HTTP.start(uri.host, uri.port, http_options) do |http|
|
|
data = { "issue" => { "status_id" => STATUS_CLOSE } }
|
|
res = http.put(uri.path, JSON(data),
|
|
'X-Redmine-API-Key' => REDMINE_API_KEY,
|
|
'Content-Type' => 'application/json')
|
|
res.value
|
|
|
|
show_last_journal(http, uri)
|
|
end
|
|
},
|
|
|
|
"last" => proc{|args|
|
|
raise CommandSyntaxError unless /\A(\d+)?\z/ =~ args
|
|
if $1
|
|
i = $1.to_i
|
|
i = @issues[i]["id"] if @issues && i < @issues.size
|
|
@issue = i
|
|
end
|
|
unless @issue
|
|
puts "ticket not selected"
|
|
next
|
|
end
|
|
|
|
uri = URI("#{REDMINE_BASE}/issues/#{@issue}.json")
|
|
Net::HTTP.start(uri.host, uri.port, http_options) do |http|
|
|
show_last_journal(http, uri)
|
|
end
|
|
},
|
|
|
|
"!" => proc{|args|
|
|
system(args.strip)
|
|
},
|
|
|
|
"quit" => proc{|args|
|
|
raise CommandSyntaxError unless args.empty?
|
|
exit
|
|
},
|
|
"exit" => "quit",
|
|
|
|
"help" => proc{|args|
|
|
puts 'ls [PAGE] '.color(bold: true) + ' show all required tickets'
|
|
puts '[show] TICKET '.color(bold: true) + ' show the detail of the TICKET, and select it'
|
|
puts 'backport '.color(bold: true) + ' show the option of selected ticket for merger.rb'
|
|
puts 'rel REVISION '.color(bold: true) + ' add the selected ticket as related to the REVISION'
|
|
puts 'done [TICKET] [-- NOTE]'.color(bold: true) + ' set Backport field of the TICKET to DONE'
|
|
puts 'close [TICKET] '.color(bold: true) + ' close the TICKET'
|
|
puts 'last [TICKET] '.color(bold: true) + ' show the last journal of the TICKET'
|
|
puts '! COMMAND '.color(bold: true) + ' execute COMMAND'
|
|
}
|
|
}
|
|
list = Abbrev.abbrev(commands.keys)
|
|
|
|
@issues = nil
|
|
@issue = nil
|
|
@changesets = nil
|
|
while true
|
|
begin
|
|
l = Readline.readline "#{('#' + @issue.to_s).color(bold: true) if @issue}> "
|
|
rescue Interrupt
|
|
break
|
|
end
|
|
break unless l
|
|
cmd, args = l.strip.split(/\s+|\b/, 2)
|
|
next unless cmd
|
|
if (!args || args.empty?) && /\A\d+\z/ =~ cmd
|
|
args = cmd
|
|
cmd = "show"
|
|
end
|
|
cmd = list[cmd]
|
|
if commands[cmd].is_a? String
|
|
cmd = list[commands[cmd]]
|
|
end
|
|
begin
|
|
if cmd
|
|
commands[cmd].call(args)
|
|
else
|
|
raise CommandSyntaxError
|
|
end
|
|
rescue CommandSyntaxError
|
|
puts "error #{l.inspect}"
|
|
end
|
|
end
|