# -*- coding: utf-8 -*- module GitIssue class Redmine < GitIssue::Base def initialize(args, options = {}) super(args, options) @apikey = options[:apikey] || configured_value('issue.apikey') configure_error('apikey', "git config issue.apikey some_api_key") if @apikey.blank? @url = options[:url] || configured_value('issue.url') configure_error('url', "git config issue.url http://example.com/redmine") if @url.blank? end def default_cmd Helper.configured_value('issue.project').blank? ? :list : :project end def commands cl = super cl << GitIssue::Command.new(:local, :loc, 'listing local branches tickets') cl << GitIssue::Command.new(:project, :pj, 'listing ticket belongs to sspecified project ') end def show(options = {}) ticket = options[:ticket_id] raise 'ticket_id is required.' unless ticket issue = fetch_issue(ticket, options) if options[:oneline] puts oneline_issue(issue, options) else puts "" puts format_issue(issue, options) end end def list(options = {}) url = to_url('issues') params = {"limit" => options[:max_count] || 100 } params.merge!("assigned_to_id" => "me") if options[:mine] params.merge!(Hash[*(options[:query].split("&").map{|s| s.split("=") }.flatten)]) if options[:query] json = fetch_json(url, params) output_issues(json['issues']) end def mine(options = {}) list(options.merge(:mine => true)) end def commit(options = {}) ticket = options[:ticket_id] raise 'ticket_id is required.' unless ticket issue = fetch_issue(ticket) f = File.open("./commit_msg_#{ticket}", 'w') f.write("refs ##{ticket} #{issue['subject']}") f.close cmd = "git commit --edit #{options[:all] ? '-a' : ''} --file #{f.path}" system(cmd) File.unlink f.path if f.path end def add(options = {}) property_names = [:project_id, :subject, :description, :done_ratio, :status_id, :priority_id, :tracker_id, :assigned_to_id, :category_id, :fixed_version_id, :notes] project_id = options[:project_id] || Helper.configured_value('issue.project') if options.slice(*property_names).empty? issue = read_issue_from_editor({"project" => {"id" => project_id}}, options) description = issue.delete(:notes) issue[:description] = description options.merge!(issue) end required_properties = [:subject, :description] required_properties.each do |name| options[name] = prompt(name) unless options[name] end json = build_issue_json(options, property_names) json["issue"][:project_id] ||= Helper.configured_value('issue.project') url = to_url('issues') json = post_json(url, json, options) puts "created issue #{oneline_issue(json["issue"])}" end def update(options = {}) ticket = options[:ticket_id] raise 'ticket_id is required.' unless ticket property_names = [:subject, :done_ratio, :status_id, :priority_id, :tracker_id, :assigned_to_id, :category_id, :fixed_version_id, :notes] if options.slice(*property_names).empty? org_issue = fetch_issue(ticket, options) update_attrs = read_issue_from_editor(org_issue, options) update_attrs = update_attrs.reject{|k,v| v.present? && org_issue[k] == v} options.merge!(update_attrs) end json = build_issue_json(options, property_names) url = to_url('issues', ticket) put_json(url, json, options) issue = fetch_issue(ticket) puts "updated issue #{oneline_issue(issue)}" end def branch(options = {}) ticket = options[:ticket_id] raise 'ticket_id is required.' unless ticket branch_name = ticket_branch(ticket) if options[:force] system "git branch -D #{branch_name}" if options[:force] system "git checkout -b #{branch_name}" else if %x(git branch -l | grep "#{branch_name}").strip.empty? system "git checkout -b #{branch_name}" else system "git checkout #{branch_name}" end end show(options) end def local(option = {}) brances = %x(git branch).split(/\n/).map{|b| b.scan(/.*ticket\D*(\d+).*/).first }.reject{|r| r.nil?}.map{|r| r.first } issues = brances.map{|ticket_id| fetch_issue(ticket_id) } output_issues(issues) end def project(options = {}) project_id = Helper.configured_value('issue.project') project_id = options[:ticket_id] if project_id.blank? raise 'project_id is required.' unless project_id list(options.merge(:query => "project_id=#{project_id}")) end private def to_url(*path_list) URI.join(@url, path_list.join("/")) end def fetch_json(url, params = {}) url = "#{url}.json?key=#{@apikey}" url += "&" + params.map{|k,v| "#{k}=#{v}"}.join("&") unless params.empty? json = open(url) {|io| JSON.parse(io.read) } if @debug puts '-' * 80 puts url pp json puts '-' * 80 end json end def fetch_issue(ticket_id, options = {}) url = to_url("issues", ticket_id) includes = issue_includes(options) params = includes.empty? ? {} : {"include" => includes } json = fetch_json(url, params) issue = json['issue'] || json raise "no such issue #{ticket} : #{base}" unless issue issue end def post_json(url, json, options, params = {}) response = send_json(url, json, options, params, :post) JSON.parse(response.body) if response_success?(response) end def put_json(url, json, options, params = {}) send_json(url, json, options, params, :put) end def send_json(url, json, options, params = {}, method = :post) url = "#{url}.json" uri = URI.parse(url) if @debug puts '-' * 80 puts url pp json puts '-' * 80 end http = Net::HTTP.new(uri.host, uri.port) if uri.scheme == 'https' http.use_ssl = true http.verify_mode = OpenSSL::SSL::VERIFY_NONE end http.set_debug_output $stderr if @debug && http.respond_to?(:set_debug_output) http.start{|http| path = "#{uri.path}?key=#{@apikey}" path += "&" + params.map{|k,v| "#{k}=#{v}"}.join("&") unless params.empty? request = case method when :post then Net::HTTP::Post.new(path) when :put then Net::HTTP::Put.new(path) else raise "unknown method #{method}" end request.set_content_type("application/json") request.body = json.to_json response = http.request(request) if @debug puts "#{response.code}: #{response.msg}" puts response.body end response } end def issue_includes(options) includes = [] includes << "journals" if ! options[:supperss_journals] || options[:verbose] includes << "changesets" if ! options[:supperss_changesets] || options[:verbose] includes << "relations" if ! options[:supperss_relations] || options[:verbose] includes.join(",") end def issue_title(issue) "[#{apply_colors(issue['project']['name'], :green)}] #{apply_colors(issue['tracker']['name'], :yellow)} #{apply_fmt_colors(:id, "##{issue['id']}")} #{issue['subject']}" end def issue_author(issue) author = issue['author']['name'] created_on = issue['created_on'] updated_on = issue['updated_on'] msg = "#{apply_fmt_colors(:assigned_to, author)}が#{time_ago_in_words(created_on)}に追加" msg += ", #{time_ago_in_words(updated_on)}に更新" unless created_on == updated_on msg end PROPERTY_TITLES= {"status"=>"ステータス", "start_date"=>"開始日", "category"=>"カテゴリ", "assigned_to"=>"担当者", "estimated_hours"=>"予定工数", "priority"=>"優先度", "fixed_version"=>"対象バージョン", "due_date"=>"期日", "done_ratio"=>"進捗"} def property_title(name) PROPERTY_TITLES[name] || name end def oneline_issue(issue, options = {}) "##{issue['id']} #{issue['subject']}" end def format_issue(issue, options) msg = [""] msg << issue_title(issue) msg << "-" * 80 msg << issue_author(issue) msg << "" props = [] prop_name = Proc.new{|name| "#{issue[name]['name']}(#{issue[name]['id']})" if issue[name] && issue[name]['name'] } add_prop = Proc.new{|name| title = property_title(name) value = issue[name] || "" props << [title, value, name] } add_prop_name = Proc.new{|name| title = property_title(name) value = '' value = prop_name.call(name) props << [title, value, name] } add_prop_name.call('status') add_prop.call("start_date") add_prop_name.call('priority') add_prop.call('due_date') add_prop_name.call('assigned_to') add_prop.call('done_ratio') add_prop_name.call('category') add_prop.call('estimated_hours') # acd custom_fields if it have value. if custom_fields = issue[:custom_fields] && custom_fields.reject{|cf| cf['value'].nil? || cf['value'].empty? } custom_fields.each do |cf| props << [cf['name'], cf['value'], cf['name']] end end props.each_with_index do |p,n| title, value, name = p row = sprintf("%s : %s", mljust(title, 18), apply_fmt_colors(name, mljust(value.to_s, 24))) if n % 2 == 0 msg << row else msg[-1] = "#{msg.last} #{row}" end end msg << sprintf("%s : %s", mljust(property_title('fixed_version'),18), mljust(prop_name.call('fixed_version'), 66)) # display relations tickets if ! options[:supperss_relations] || options[:verbose] relations = issue['relations'] if relations && !relations.empty? msg << "関連するチケット" msg << "-" * 80 rels = format_relations(relations) msg += rels end end # display description msg << "-" * 80 msg << "#{issue['description']}" msg << "" # display journals if ! options[:supperss_journals] || options[:verbose] journals = issue['journals'] if journals && !journals.empty? msg << "履歴" msg << "-" * 80 msg << "" jnl = format_jounals(journals) msg += jnl.map{|s| " #{s}"} end end # display changesets if ! options[:supperss_changesets] || options[:verbose] changesets = issue['changesets'] if changesets && !changesets.empty? msg << "関係しているリビジョン" msg << "-" * 80 msg << "" cs = format_changesets(changesets) msg += cs.map{|s| " #{s}"} end end msg.join("\n") end def format_jounals(journals) jnl = [] journals.sort_by{|j| j['created_on']}.each_with_index do |j,n| jnl += format_jounal(j,n) end jnl end def format_jounal(j, n) jnl = [] jnl << "##{n + 1} - #{apply_fmt_colors(:assigned_to, j['user']['name'])}が#{time_ago_in_words(j['created_on'])}に更新" jnl << "-" * 78 j['details'].each do |d| log = "#{property_title(d['name'])}を" if d['old_value'] log += "\"#{d['old_value']}\"から\"#{d['new_value']}\"へ変更" else log += "\"#{d['new_value']}\"にセット" end jnl << log end jnl += j['notes'].split("\n").to_a if j['notes'] jnl << "" end def format_changesets(changesets) cs = [] changesets.sort_by{|c| c['committed_on'] }.each do |c| cs << "リビジョン: #{apply_colors(c['revision'][0..10], :cyan)} #{apply_fmt_colors(:assigned_to, c['user']['name'])}が#{time_ago_in_words(c['committed_on'])}に追加" cs += c['comments'].split("\n").to_a cs << "" end cs end def format_relations(relations) relations.map{|r| issue = fetch_issue(r['issue_id']) "#{relations_label(r['relation_type'])} #{issue_title(issue)} #{apply_fmt_colors(:status, issue['status']['name'])} #{issue['start_date']} " } end DEFAULT_FORMAT = "%I %S | %A | %s %T %P | %V %C |" def format_issue_tables(issues_json) name_of = lambda{|issue, name| issue[name]['name'] rescue ""} issues = issues_json.map{ |issue|{ :id => sprintf("#%-4d", issue['id']), :subject => issue['subject'], :project => name_of.call(issue, 'project'), :tracker => name_of.call(issue, 'tracker'), :status => name_of.call(issue, 'status'), :assigned_to => name_of.call(issue, 'assigned_to'), :version => name_of.call(issue, 'fixed_version'), :priority => name_of.call(issue, 'priority'), :category => name_of.call(issue, 'category'), :updated_on => issue['updated_on'].to_date }} max_of = lambda{|name, limit| max = issues.map{|i| mlength(i[name])}.max [max, limit].compact.min } max_length = { :project => max_of.call(:project, 20), :tracker => max_of.call(:tracker, 20), :status => max_of.call(:status, 20), :assigned_to => max_of.call(:assigned_to, 20), :version => max_of.call(:version, 20), :priority => max_of.call(:priority, 20), :category => max_of.call(:category, 20), :subject => 80 } fmt = configured_value('issue.defaultformat', false) fmt = DEFAULT_FORMAT unless fmt.present? fmt_chars = { :I => :id, :S => :subject, :A => :assigned_to, :s => :status, :T => :tracker, :P => :priority, :p => :project, :V => :version, :C => :category, :U => :updated_on } format_to = lambda{|i| res = fmt.dup fmt_chars.each do |k, v| res.gsub!(/\%(\d*)#{k}/) do |s| max = $1.blank? ? max_length[v] : $1.to_i str = max ? mljust(i[v], max) : i[v] colored = fmt_colors[v] ? apply_fmt_colors(v, str) : str colored end end res } issues.map{|i| format_to.call(i) } end def apply_fmt_colors(key, str) fmt_colors[key.to_sym] ? apply_colors(str, *Array(fmt_colors[key.to_sym])) : str end def fmt_colors @fmt_colors ||= { :id => [:bold, :cyan], :status => :blue, :priority => :green, :assigned_to => :magenta, :tracker => :yellow} end def output_issues(issues) if options[:raw_id] issues.each do |i| puts i['id'] end else format_issue_tables(issues).each do |i| puts i end end end RELATIONS_LABEL = { "relates" => "関係している", "duplicates" => "重複している", "duplicated" => "重複されている", "blocks" => "ブロックしている", "blocked" => "ブロックされている", "precedes" => "先行する", "follows" => "後続する", } def relations_label(rel) RELATIONS_LABEL[rel] || rel end def build_issue_json(options, property_names) json = {"issue" => property_names.inject({}){|h,k| h[k] = options[k] if options[k].present?; h} } if custom_fields = options[:custom_fields] json['custom_fields'] = custom_fields.split(",").map{|s| k,*v = s.split(":");{'id' => k.to_i, 'value' => v.join }} end json end def read_issue_from_editor(issue, options = {}) id_of = lambda{|name| issue[name] ? sprintf('%2s : %s', issue[name]["id"] , issue[name]['name'] ): ""} memofile = configured_value('issue.memofile') memo = File.open(memofile).read.lines.map{|l| "# #{l}"}.join("") unless memofile.blank? message = <<-MSG #{issue["subject"].present? ? issue["subject"].chomp : "### subject here ###"} Project : #{id_of.call("project")} Tracker : #{id_of.call("tracker")} Status : #{id_of.call("status")} Priority : #{id_of.call("priority")} Category : #{id_of.call("category")} Assigned : #{id_of.call("assigned_to")} Version : #{id_of.call("fixed_version")} # Please enter the notes for your changes. Lines starting # with '#' will be ignored, and an empty message aborts. #{memo} MSG body = get_body_from_editor(message) subject, dummy, project_id, tracker_id, status_id, priority_id, category_id, assigned_to_id, fixed_version_id, dummy, *notes = body.lines.to_a notes = if notes.present? notes.reject{|l| l =~ /^#/}.join("") else nil end if @debug puts "------" puts "sub: #{subject}" puts "pid: #{project_id}" puts "tid: #{tracker_id}" puts "sid: #{status_id}" puts "prd: #{priority_id}" puts "cat: #{category_id}" puts "ass: #{assigned_to_id}" puts "vss: #{fixed_version_id}" puts "nos: #{notes}" puts "------" end take_id = lambda{|s| x, i, name = s.chomp.split(":") i.present? ? i.strip.to_i : nil } { :subject => subject.chomp, :project_id => take_id.call(project_id), :tracker_id => take_id.call(tracker_id), :status_id => take_id.call(status_id), :priority_id => take_id.call(priority_id), :category_id => take_id.call(category_id), :assigned_to_id => take_id.call(assigned_to_id), :fixed_version_id => take_id.call(fixed_version_id), :notes => notes } end def opt_parser opts = super opts.on("--supperss_journals", "-j", "do not show issue journals"){|v| @options[:supperss_journals] = true} opts.on("--supperss_relations", "-r", "do not show issue relations tickets"){|v| @options[:supperss_relations] = true} opts.on("--supperss_changesets", "-c", "do not show issue changesets"){|v| @options[:supperss_changesets] = true} opts.on("--query=VALUE",'-q=VALUE', "filter query of listing tickets") {|v| @options[:query] = v} opts.on("--project_id=VALUE", "use the given value to create subject"){|v| @options[:project_id] = v} opts.on("--description=VALUE", "use the given value to create subject"){|v| @options[:description] = v} opts.on("--subject=VALUE", "use the given value to create/update subject"){|v| @options[:subject] = v} opts.on("--ratio=VALUE", "use the given value to create/update done-ratio(%)"){|v| @options[:done_ratio] = v.to_i} opts.on("--status=VALUE", "use the given value to create/update issue statues id"){|v| @options[:status_id] = v } opts.on("--priority=VALUE", "use the given value to create/update issue priority id"){|v| @options[:priority_id] = v } opts.on("--tracker=VALUE", "use the given value to create/update tracker id"){|v| @options[:tracker_id] = v } opts.on("--assigned_to_id=VALUE", "use the given value to create/update assigned_to id"){|v| @options[:assigned_to_id] = v } opts.on("--category=VALUE", "use the given value to create/update category id"){|v| @options[:category_id] = v } opts.on("--fixed_version=VALUE", "use the given value to create/update fixed_version id"){|v| @options[:fixed_version_id] = v } opts.on("--custom_fields=VALUE", "value should be specifies '<custom_fields_id1>:<value2>,<custom_fields_id2>:<value2>, ...' "){|v| @options[:custom_fields] = v } opts.on("--notes=VALUE", "add notes to issue"){|v| @options[:notes] = v} opts end end end