├── .document ├── .gitignore ├── Gemfile ├── LICENSE ├── README.markdown ├── Rakefile ├── VERSION ├── bin └── git-issue ├── git-issue.gemspec ├── images ├── git-issue_screenshot-1.png └── git-issue_screenshot-2.png ├── lib ├── git_issue.rb └── git_issue │ ├── base.rb │ ├── bitbucket.rb │ ├── github.rb │ ├── redmine.rb │ └── version.rb └── spec ├── git_issue ├── base_spec.rb ├── github_spec.rb └── redmine_spec.rb ├── git_issue_spec.rb ├── spec.opts └── spec_helper.rb /.document: -------------------------------------------------------------------------------- 1 | README.rdoc 2 | lib/**/*.rb 3 | bin/* 4 | features/**/*.feature 5 | LICENSE 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sw? 2 | .DS_Store 3 | coverage 4 | rdoc 5 | pkg 6 | *.gem 7 | .bundle 8 | Gemfile.lock 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in named_let.gemspec 4 | gem 'activesupport' 5 | gem 'pit' 6 | gem 'term-ansicolor' 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Tomohito Ozaki 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | git-issue 2 | ==================================================== 3 | 4 | git subcommand of browse/modify issue traker's tickets. 5 | 6 | now available issue-tracker system is Redmine and Github-issues. 7 | 8 | ## ScreenShots 9 | 10 | 11 | 12 | 13 | ## Installation 14 | 15 | $ gem install git-issue 16 | 17 | or 18 | 19 | $ git clone https://github.com/yuroyoro/git-issue.git 20 | $ cd git-issue 21 | $ gem install jeweler 22 | $ rake install 23 | 24 | ## Configuration(Redmine) 25 | 26 | set type of issue traking system(redmine or github) 27 | 28 | $ git config issue.type redmine 29 | 30 | set url of issue traking system's api endopoint. 31 | 32 | $ git config issue.url http://redmine.example.com 33 | 34 | set api-key for accessing issue traking system. 35 | 36 | $ git config issue.apikey FWeaj3I9laei03A.... 37 | 38 | set repository name if using github. 39 | 40 | $ git config issue.repo gitterb 41 | 42 | set your account name if using github. 43 | 44 | $ git config issue.user yuroyoro 45 | 46 | ## Configuration(Github Issues) 47 | 48 | set type of issue traking system(redmine or github) 49 | 50 | $ git config issue.type github 51 | 52 | set user and password of github(for authentication) 53 | 54 | $ EDITOR=vim pit set github 55 | 56 | ## Usage(Redmine) 57 | 58 | git issue [ticket_id] [] 59 | 60 | Commnads: 61 | show s show given issue summary. if given no id, geuss id from current branch name. 62 | view v view issue in browser. if given no id, geuss id from current branch name. 63 | list l listing issues. 64 | mine m display issues that assigned to you. 65 | commit c commit with filling issue subject to messsage.if given no id, geuss id from current branch name. 66 | add a create issue. 67 | update u update issue properties. if given no id, geuss id from current branch name. 68 | branch b checkout to branch using specified issue id. if branch dose'nt exisits, create it. (ex ticket/id/) 69 | publish pub push branch to remote repository and set upstream 70 | rebase rb rebase branch onto specific newbase 71 | help h show usage. 72 | local loc listing local branches tickets 73 | project pj listing ticket belongs to sspecified project 74 | 75 | Options: 76 | -a, --all update all paths in the index file 77 | -f, --force force create branch 78 | -v, --verbose show issue details 79 | -n, --max-count=VALUE maximum number of issues 80 | --oneline display short info 81 | --raw-id output ticket number only 82 | --remote=VALUE on publish, remote repository to push branch 83 | --onto=VALUE on rebase, start new branch with HEAD equal to "newbase" 84 | --debug debug print 85 | -j, --supperss_journals do not show issue journals 86 | -r, --supperss_relations do not show issue relations tickets 87 | -c, --supperss_changesets do not show issue changesets 88 | -q, --query=VALUE filter query of listing tickets 89 | --project_id=VALUE use the given value to create subject 90 | --description=VALUE use the given value to create subject 91 | --subject=VALUE use the given value to create/update subject 92 | --ratio=VALUE use the given value to create/update done-ratio(%) 93 | --status=VALUE use the given value to create/update issue statues id 94 | --priority=VALUE use the given value to create/update issue priority id 95 | --tracker=VALUE use the given value to create/update tracker id 96 | --assigned_to_id=VALUE use the given value to create/update assigned_to id 97 | --category=VALUE use the given value to create/update category id 98 | --fixed_version=VALUE use the given value to create/update fixed_version id 99 | --custom_fields=VALUE value should be specifies ':,:, ...' 100 | --notes=VALUE add notes to issue 101 | 102 | ## Usage(Github Issues) 103 | 104 | git issue [ticket_id] [] 105 | 106 | Commnads: 107 | show s show given issue summary. if given no id, geuss id from current branch name. 108 | view v view issue in browser. if given no id, geuss id from current branch name. 109 | list l listing issues. 110 | mine m display issues that assigned to you. 111 | commit c commit with filling issue subject to messsage.if given no id, geuss id from current branch name. 112 | add a create issue. 113 | update u update issue properties. if given no id, geuss id from current branch name. 114 | branch b checkout to branch using specified issue id. if branch dose'nt exisits, create it. (ex ticket/id/) 115 | publish pub push branch to remote repository and set upstream 116 | rebase rb rebase branch onto specific newbase 117 | help h show usage. 118 | mention men create a comment to given issue 119 | 120 | Options: 121 | -a, --all update all paths in the index file 122 | -f, --force force create branch 123 | -v, --verbose show issue details 124 | -n, --max-count=VALUE maximum number of issues 125 | --oneline display short info 126 | --raw-id output ticket number only 127 | --remote=VALUE on publish, remote repository to push branch 128 | --onto=VALUE on rebase, start new branch with HEAD equal to "newbase" 129 | --debug debug print 130 | -s, --supperss_commentsc show issue journals 131 | --title=VALUE Title of issue.Use the given value to create/update issue. 132 | --body=VALUE Body content of issue.Use the given value to create/update issue. 133 | --state=VALUE Use the given value to create/update issue. or query of listing issues.Where 'state' is either 'open' or 'closed' 134 | --milestone=VALUE Use the given value to create/update issue. or query of listing issues, (Integer Milestone number) 135 | --assignee=VALUE Use the given value to create/update issue. or query of listing issues, (String User login) 136 | --mentioned=VALUE Query of listing issues, (String User login) 137 | --labels=VALUE Use the given value to create/update issue. or query of listing issues, (String list of comma separated Label names) 138 | --sort=VALUE Query of listing issues, (created, updated, comments, default: created) 139 | --direction=VALUE Query of listing issues, (asc or desc, default: desc.) 140 | --since=VALUE Query of listing issue, (Optional string of a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ) 141 | --password=VALUE For Authorizaion of create/update issue. Github API v3 doesn't supports API token base authorization for now. then, use Basic Authorizaion instead token. 142 | --sslnoverify don't verify SSL 143 | 144 | ## Copyright 145 | 146 | Copyright (c) 2011 Tomohito Ozaki. See LICENSE for details. 147 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.7.7 -------------------------------------------------------------------------------- /bin/git-issue: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # -*- coding: utf-8 -*- 3 | 4 | $LOAD_PATH.unshift File.dirname(__FILE__) + '/../lib' 5 | require 'git_issue' 6 | 7 | GitIssue.main(ARGV) 8 | 9 | -------------------------------------------------------------------------------- /git-issue.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "git_issue/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "git-issue" 7 | s.version = GitIssue::VERSION 8 | s.authors = ["Tomohito Ozaki"] 9 | s.email = ["ozaki@yuroyoro.com"] 10 | s.homepage = "https://github.com/yuroyoro/git-issue" 11 | s.summary = %q{git extention command for issue tracker system.} 12 | 13 | s.rubyforge_project = "git-issue" 14 | 15 | s.files = `git ls-files`.split("\n") 16 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 17 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 18 | s.require_paths = ["lib"] 19 | 20 | # dependencies 21 | s.add_dependency 'activesupport' 22 | s.add_dependency 'pit' 23 | s.add_dependency 'term-ansicolor' 24 | s.add_development_dependency 'rake' 25 | end 26 | 27 | -------------------------------------------------------------------------------- /images/git-issue_screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuroyoro/git-issue/55a03cefe54f37a9d614f127b3b20968f8c73751/images/git-issue_screenshot-1.png -------------------------------------------------------------------------------- /images/git-issue_screenshot-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuroyoro/git-issue/55a03cefe54f37a9d614f127b3b20968f8c73751/images/git-issue_screenshot-2.png -------------------------------------------------------------------------------- /lib/git_issue.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # -*- coding: utf-8 -*- 3 | 4 | if RUBY_VERSION < '1.9.0' 5 | $KCODE = "UTF8" 6 | else 7 | Encoding.default_external = Encoding.find('UTF-8') 8 | end 9 | 10 | require 'pp' 11 | require 'rubygems' 12 | require 'uri' 13 | require 'open-uri' 14 | require "net/http" 15 | require "net/https" 16 | require "uri" 17 | require 'fileutils' 18 | require 'json' 19 | require 'optparse' 20 | require 'tempfile' 21 | require 'active_support/all' 22 | require 'shellwords' 23 | require 'term/ansicolor' 24 | 25 | Term::ANSIColor::coloring = STDOUT.isatty && RUBY_PLATFORM.downcase !~ /mswin(?!ce)|mingw|bccwin/ 26 | 27 | 28 | module GitIssue 29 | class Command 30 | attr_reader :name, :short_name, :description 31 | def initialize(name, short_name, description) 32 | @name, @short_name, @description = name, short_name, description 33 | end 34 | end 35 | 36 | module Helper 37 | 38 | CONFIGURE_MESSAGE = <<-END 39 | please set issue tracker %s. 40 | 41 | %s 42 | END 43 | 44 | def configure_error(attr_name, example) 45 | raise CONFIGURE_MESSAGE % [attr_name, example] 46 | end 47 | 48 | 49 | def configured_value(name, trim = true) 50 | res = `git config #{name}` 51 | res = trim ? res.strip : res 52 | res 53 | end 54 | 55 | def global_configured_value(name) 56 | res = `git config --global #{name}` 57 | res.strip 58 | end 59 | 60 | def its_klass_of(its_type) 61 | case its_type 62 | when /redmine/i then GitIssue::Redmine 63 | when /github/i then GitIssue::Github 64 | when /bitbucket/i then GitIssue::Bitbucket 65 | else 66 | raise "unknown issue tracker type : #{its_type}" 67 | end 68 | end 69 | 70 | def git_editor 71 | # possible: ~/bin/vi, $SOME_ENVIRONMENT_VARIABLE, "C:\Program Files\Vim\gvim.exe" --nofork 72 | editor = `git var GIT_EDITOR` 73 | editor = ENV[$1] if editor =~ /^\$(\w+)$/ 74 | editor = File.expand_path editor if (editor =~ /^[~.]/ or editor.index('/')) and editor !~ /["']/ 75 | editor.shellsplit 76 | end 77 | 78 | def work_dir 79 | dir = RUBY_PLATFORM.downcase =~ /mswin(?!ce)|mingw|bccwin/ ? 80 | `git rev-parse -q --git-dir 2> NUL`.strip : 81 | `git rev-parse -q --git-dir 2> /dev/null`.strip 82 | dir.empty? ? Dir.tmpdir : dir 83 | end 84 | 85 | def split_head_and_body(text) 86 | title, body = '', '' 87 | text.each_line do |line| 88 | next if line.index('#') == 0 89 | ((body.empty? and line =~ /\S/) ? title : body) << line 90 | end 91 | title.tr!("\n", ' ') 92 | title.strip! 93 | body.strip! 94 | 95 | [title =~ /\S/ ? title : nil, body =~ /\S/ ? body : nil] 96 | end 97 | 98 | def read_body(file) 99 | f = open(file) 100 | body = f.read 101 | f.close 102 | body 103 | end 104 | 105 | def get_title_and_body_from_editor(message=nil) 106 | open_editor(message) do |text| 107 | title, body = split_head_and_body(text) 108 | abort "Aborting due to empty issue title" unless title 109 | [title, body] 110 | end 111 | end 112 | 113 | def get_body_from_editor(message=nil) 114 | open_editor(message) do |text| 115 | abort "Aborting due to empty message" if text.empty? 116 | text 117 | end 118 | end 119 | 120 | def open_editor(message = nil, abort_if_not_modified = true , &block) 121 | message_file = File.join(work_dir, 'ISSUE_MESSAGE') 122 | File.open(message_file, 'w') { |msg| 123 | msg.puts message 124 | } 125 | begin 126 | edit_cmd = Array(git_editor).dup 127 | edit_cmd << '-c' << 'set ft=gitcommit' if edit_cmd[0] =~ /^[mg]?vim$/ 128 | edit_cmd << message_file 129 | 130 | system(*edit_cmd) 131 | abort "can't open text editor for issue message" unless $?.success? 132 | 133 | text = read_body(message_file) 134 | abort "Aborting cause messages didn't modified." if message == text && abort_if_not_modified 135 | ensure 136 | File.unlink(message_file) 137 | end 138 | 139 | yield text.strip 140 | end 141 | 142 | module_function :configured_value, :global_configured_value, :configure_error, :its_klass_of, :get_title_and_body_from_editor, :get_body_from_editor 143 | end 144 | 145 | def self.main(argv) 146 | status = true 147 | 148 | begin 149 | its_type = Helper.configured_value('issue.type') 150 | 151 | # Use global config for hub 152 | if its_type.blank? 153 | github_user = Helper.global_configured_value('github.user') 154 | unless github_user.blank? 155 | its_type = 'github' 156 | else 157 | bitbucket_user = Helper.global_configured_value('bitbucket.user') 158 | unless bitbucket_user.blank? 159 | its_type = 'bitbucket' 160 | end 161 | end 162 | end 163 | 164 | Helper.configure_error('type (redmine | github | bitbucket)', "git config issue.type redmine") if its_type.blank? 165 | 166 | its_klass = Helper.its_klass_of(its_type) 167 | status = its_klass.new(ARGV).execute || true 168 | rescue => e 169 | puts e 170 | puts e.backtrace.join("\n") 171 | status = false 172 | end 173 | 174 | exit(status) 175 | end 176 | 177 | end 178 | 179 | require File.dirname(__FILE__) + '/git_issue/base' 180 | require File.dirname(__FILE__) + '/git_issue/bitbucket' 181 | require File.dirname(__FILE__) + '/git_issue/github' 182 | require File.dirname(__FILE__) + '/git_issue/redmine' 183 | require File.dirname(__FILE__) + '/git_issue/version' 184 | 185 | # define version number as global constant for optparse 186 | Version = GitIssue::VERSION 187 | -------------------------------------------------------------------------------- /lib/git_issue/base.rb: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | class GitIssue::Base 4 | include GitIssue::Helper 5 | include Term::ANSIColor 6 | 7 | attr_reader :apikey, :command, :tickets, :options 8 | attr_accessor :sysout, :syserr 9 | 10 | def initialize(args, options = {}) 11 | 12 | @opt_parse_obj = opt_parser 13 | args = parse_options(args) 14 | 15 | @sysout = options[:sysout] || $stdout 16 | @syserr = options[:syserr] || $stderr 17 | 18 | 19 | parse_command_and_tickets(args) 20 | end 21 | 22 | def parse_command_and_tickets(args) 23 | @tickets = [] 24 | 25 | # search "-" options from args 26 | @options[:from_stdin] = args.find{|v| v == "-"} 27 | args.delete_if{|v| v == "-"} 28 | 29 | cmd = args.shift 30 | 31 | split_ticket = lambda{|s| s.nil? || s.empty? ? nil : s.split(/,/).map{|v| v.strip.to_i} } 32 | 33 | if options[:from_stdin] 34 | # read tickets from stdin 35 | $stdin.each_line do |l| 36 | @tickets += split_ticket.call(l) 37 | end 38 | else 39 | # parse args 40 | if cmd =~ /(\d+,?\s?)+/ 41 | @tickets = split_ticket.call(cmd) 42 | cmd = nil 43 | end 44 | 45 | @tickets += args.map{|s| split_ticket.call(s)}.flatten.uniq 46 | @tickets = [guess_ticket].compact if @tickets.empty? 47 | end 48 | 49 | cmd ||= (@tickets.nil? || @tickets.empty?) ? default_cmd : :show 50 | cmd = cmd.to_sym 51 | 52 | @command = find_command(cmd) 53 | 54 | exit_with_message("invalid command <#{cmd}>") unless @command 55 | end 56 | 57 | def default_cmd 58 | :list 59 | end 60 | 61 | def execute 62 | if @tickets.nil? || @tickets.empty? 63 | self.send(@command.name, @options) 64 | else 65 | @tickets.each do |ticket| 66 | self.send(@command.name, @options.merge(:ticket_id => ticket)) 67 | end 68 | end 69 | true 70 | end 71 | 72 | def help(options = {}) 73 | puts @opt_parse_obj.banner 74 | puts " Commnads:" 75 | puts usage 76 | puts "" 77 | puts " Options:" 78 | puts @opt_parse_obj.summarize 79 | end 80 | 81 | def publish(options = {}) 82 | ticket, branch_name = ticket_and_branch(options) 83 | remote = options[:remote] || "origin" 84 | system "git push -u #{remote} #{branch_name}" 85 | end 86 | 87 | def rebase(options = {}) 88 | raise '--onto is required.' unless options[:onto] 89 | ticket, branch_name = ticket_and_branch(options) 90 | onto = options[:onto] 91 | 92 | cb = current_branch 93 | 94 | system "git rebase --onto #{onto} #{onto} #{branch_name}" 95 | system "git checkout #{cb}" 96 | end 97 | 98 | def cherry(option = {}) 99 | upstream = options[:upstream] 100 | head = options[:head] 101 | 102 | commits = %x(git cherry -v #{upstream} #{head}).split(/\n/).map{|s| 103 | s.scan(/^([+-])\s(\w+)\s(.*)/).first 104 | }.select{|_, _, msg| msg =~ /#[0-9]+/ }.map{|diff, sha1, msg| 105 | msg.scan(/#([0-9]+)/).flatten.map{|ticket| [diff, sha1, msg, ticket]} 106 | }.flatten(1) 107 | 108 | commits.group_by{|d, _, _, n| [d, n]}.each do |k, records| 109 | diff, ticket = k 110 | c = case diff 111 | when "-" then :red 112 | when "+" then :green 113 | end 114 | 115 | issue = fetch_issue(ticket, options) 116 | 117 | puts "#{apply_colors(diff, c)} #{oneline_issue(issue, options)}" 118 | if options[:verbose] 119 | records.each {|_, sha1, msg| puts " #{sha1} #{msg}" } 120 | puts "" 121 | end 122 | end 123 | end 124 | 125 | def commands 126 | [ 127 | GitIssue::Command.new(:show, :s, 'show given issue summary. if given no id, geuss id from current branch name.'), 128 | GitIssue::Command.new(:view, :v, 'view issue in browser. if given no id, geuss id from current branch name.'), 129 | GitIssue::Command.new(:list, :l, 'listing issues.'), 130 | GitIssue::Command.new(:mine, :m, 'display issues that assigned to you.'), 131 | GitIssue::Command.new(:commit, :c, 'commit with filling issue subject to messsage.if given no id, geuss id from current branch name.'), 132 | GitIssue::Command.new(:add, :a, 'create issue.'), 133 | GitIssue::Command.new(:update, :u, 'update issue properties. if given no id, geuss id from current branch name.'), 134 | GitIssue::Command.new(:branch, :b, "checkout to branch using specified issue id. if branch dose'nt exisits, create it. (ex ticket/id/)"), 135 | GitIssue::Command.new(:cherry, :chr, 'find issue not merged upstream.'), 136 | 137 | GitIssue::Command.new(:publish,:pub, "push branch to remote repository and set upstream "), 138 | GitIssue::Command.new(:rebase, :rb, "rebase branch onto specific newbase"), 139 | 140 | GitIssue::Command.new(:help, :h, "show usage.") 141 | ] 142 | end 143 | 144 | def find_command(cmd) 145 | cmd = cmd.to_sym 146 | commands.find{|c| c.name == cmd || c.short_name == cmd } 147 | end 148 | 149 | def usage 150 | commands.map{|c| "%-8s %s %s" % [c.name, c.short_name, c.description ] }.join("\n") 151 | end 152 | 153 | def time_ago_in_words(time) 154 | t = Time.parse(time) 155 | a = (Time.now - t).to_i 156 | 157 | case a 158 | when 0 then return 'just now' 159 | when 1..59 then return a.to_s + '秒前' 160 | when 60..119 then return '1分前' 161 | when 120..3540 then return (a/60).to_i.to_s + '分前' 162 | when 3541..7100 then return '1時間前' 163 | when 7101..82800 then return ((a+99)/3600).to_i.to_s + '時間前' 164 | when 82801..172000 then return '1日前' 165 | when 172001..432000 then return ((a+800)/(60*60*24)).to_i.to_s + '日前' 166 | else return ((a+800)/(60*60*24)).to_i.to_s + '日前' 167 | end 168 | end 169 | 170 | def exit_with_message(msg, status=1) 171 | err msg 172 | exit(status) 173 | end 174 | 175 | BRANCH_NAME_FORMAT = "ticket/id/%s" 176 | 177 | def ticket_branch(ticket_id) 178 | BRANCH_NAME_FORMAT % ticket_id 179 | end 180 | 181 | def current_branch 182 | RUBY_PLATFORM.downcase =~ /mswin(?!ce)|mingw|bccwin/ ? 183 | %x(git branch -l 2> NUL | grep "*" | cut -d " " -f 2).strip : 184 | %x(git branch -l 2> /dev/null | grep "*" | cut -d " " -f 2).strip 185 | end 186 | 187 | def guess_ticket 188 | branch = current_branch 189 | if branch =~ %r!id/(\d+)! || branch =~ /^(\d+)_/ || branch =~ /_(\d+)$/ 190 | ticket = $1 191 | end 192 | end 193 | 194 | def ticket_and_branch(options) 195 | if options[:ticket_id] 196 | ticket = options[:ticket_id] 197 | branch_name = ticket_branch(ticket) 198 | else 199 | branch_name = current_branch 200 | ticket = guess_ticket 201 | end 202 | [ticket, branch_name] 203 | end 204 | 205 | def response_success?(response) 206 | code = response.code.to_i 207 | code >= 200 && code < 300 208 | end 209 | 210 | def prompt(name) 211 | print "#{name}: " 212 | $stdin.gets.chop 213 | end 214 | 215 | # this is unnecessary hacks for multibytes charactors handling... 216 | def mlength(s) 217 | width = 0 218 | cnt = 0 219 | bytesize_method = (RUBY_VERSION >= "1.9") ? :bytesize : :length 220 | s.split(//u).each{|c| cnt += 1 ;width += 1 if c.send(bytesize_method) > 1 } 221 | cnt + width 222 | end 223 | 224 | # this is unnecessary hacks for multibytes charactors handling... 225 | def mljust(s, n) 226 | return "" unless s 227 | cnt = 0 228 | chars = [] 229 | 230 | s.split(//u).each do |c| 231 | next if cnt > n 232 | chars << c 233 | cnt += c =~ /^[^ -~。-゚]*$/ ? 2 : 1 234 | end 235 | if cnt > n 236 | chars.pop 237 | cnt -= chars.last =~ /^[^ -~。-゚]*$/ ? 2 : 1 238 | end 239 | chars << " " * (n - cnt) if n > cnt 240 | chars.join 241 | end 242 | 243 | # for 1.8.6... 244 | def mktmpdir(prefix_suffix=nil, tmpdir=nil) 245 | case prefix_suffix 246 | when nil 247 | prefix = "d" 248 | suffix = "" 249 | when String 250 | prefix = prefix_suffix 251 | suffix = "" 252 | when Array 253 | prefix = prefix_suffix[0] 254 | suffix = prefix_suffix[1] 255 | else 256 | raise ArgumentError, "unexpected prefix_suffix: #{prefix_suffix.inspect}" 257 | end 258 | tmpdir ||= Dir.tmpdir 259 | t = Time.now.strftime("%Y%m%d") 260 | n = nil 261 | begin 262 | path = "#{tmpdir}/#{prefix}#{t}-#{$$}-#{rand(0x100000000).to_s(36)}" 263 | path << "-#{n}" if n 264 | path << suffix 265 | Dir.mkdir(path, 0700) 266 | rescue Errno::EEXIST 267 | n ||= 0 268 | n += 1 269 | retry 270 | end 271 | 272 | if block_given? 273 | begin 274 | yield path 275 | ensure 276 | FileUtils.remove_entry_secure path 277 | end 278 | else 279 | path 280 | end 281 | end 282 | 283 | def to_date(d) 284 | Date.parse(d).strftime('%Y/%m/%d') rescue d 285 | end 286 | 287 | def parse_options(args) 288 | @options = {} 289 | @opt_parse_obj.parse!(args) 290 | args 291 | end 292 | 293 | def opt_parser 294 | OptionParser.new{|opts| 295 | opts.banner = 'git issue [ticket_id] []' 296 | # Register "-" only to be shown in help. Actualy this definition is not used 297 | opts.on("-", /^$/, "read ticket-ids from stdin"){ @options[:from_stdin] = true } 298 | opts.on("--all", "-a", "update all paths in the index file "){ @options[:all] = true } 299 | opts.on("--force", "-f", "force create branch"){ @options[:force] = true } 300 | opts.on("--verbose", "-v", "show issue details"){|v| @options[:verbose] = true} 301 | opts.on("--max-count=VALUE", "-n=VALUE", "maximum number of issues "){|v| @options[:max_count] = v.to_i} 302 | opts.on("--oneline", "display short info"){|v| @options[:oneline] = true} 303 | opts.on("--raw-id", "output ticket number only"){|v| @options[:raw_id] = true} 304 | opts.on("--remote=VALUE", 'on publish, remote repository to push branch ') {|v| @options[:remote] = v} 305 | opts.on("--onto=VALUE", 'on rebase, start new branch with HEAD equal to "newbase" ') {|v| @options[:onto] = v} 306 | 307 | opts.on("--upstream=VALUE", 'on cherry, upstream branch to compare against. default is tracked remote branch') {|v| @options[:upstream] = v} 308 | opts.on("--head=VALUE", 'on cherry, working branch. defaults to HEAD') {|v| @options[:head] = v} 309 | 310 | opts.on("--no-color", "turn off colored output"){@no_color = true } 311 | opts.on("--debug", "debug print"){@debug= true } 312 | } 313 | 314 | end 315 | 316 | def puts(msg) 317 | @sysout.puts msg 318 | end 319 | 320 | def err(msg) 321 | @syserr.puts msg 322 | end 323 | 324 | def apply_colors(str, *colors) 325 | @no_color.present? ? str : (colors.map(&method(:send)) + [str, reset]).join 326 | end 327 | 328 | def connection(host, port) 329 | env = ENV['http_proxy'] || ENV['HTTP_PROXY'] 330 | if env 331 | uri = URI(env) 332 | proxy_host, proxy_port, proxy_user, proxy_pass = uri.host, uri.port, uri.user, uri.password 333 | Net::HTTP::Proxy(proxy_host, proxy_port, proxy_user, proxy_pass).new(host, port) 334 | else 335 | Net::HTTP.new(host, port) 336 | end 337 | end 338 | end 339 | 340 | -------------------------------------------------------------------------------- /lib/git_issue/bitbucket.rb: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | require 'base64' 3 | require 'pit' 4 | 5 | module GitIssue 6 | class GitIssue::Bitbucket < GitIssue::Base 7 | def initialize(args, options = {}) 8 | super(args, options) 9 | 10 | @repo = configured_value('issue.repo') 11 | if @repo.blank? 12 | url = `git config remote.origin.url`.strip 13 | @repo = url.match(/bitbucket.org[:\/](.+)\.git/)[1] 14 | end 15 | 16 | @user = options[:user] || configured_value('issue.user') 17 | @user = global_configured_value('bitbucket.user') if @user.blank? 18 | @user = Pit.get("bitbucket", :require => { 19 | "user" => "Your user name in Bitbucket", 20 | })["user"] if @user.blank? 21 | 22 | configure_error('user', "git config issue.user yuroyoro") if @user.blank? 23 | @ssl_options = {} 24 | if @options.key?(:sslNoVerify) && RUBY_VERSION < "1.9.0" 25 | @ssl_options[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE 26 | elsif configured_value('http.sslVerify') == "false" 27 | @ssl_options[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE 28 | end 29 | if (ssl_cert = configured_value('http.sslCert')) 30 | @ssl_options[:ssl_ca_cert] = ssl_cert 31 | end 32 | end 33 | 34 | def commands 35 | cl = super 36 | cl << GitIssue::Command.new(:mention, :men, 'create a comment to given issue') 37 | cl << GitIssue::Command.new(:close , :cl, 'close an issue with comment. comment is optional.') 38 | end 39 | 40 | def show(options = {}) 41 | ticket = options[:ticket_id] 42 | raise 'ticket_id is required.' unless ticket 43 | issue = fetch_issue(ticket, options) 44 | 45 | if options[:oneline] 46 | puts oneline_issue(issue, options) 47 | else 48 | comments = [] 49 | 50 | if issue['comment_count'] > 0 51 | comments = fetch_comments(ticket) unless options[:supperss_comments] 52 | end 53 | puts "" 54 | puts format_issue(issue, comments, options) 55 | end 56 | end 57 | 58 | def list(options = {}) 59 | state = options[:status] || "open" 60 | 61 | query_names = [:status, :milestone, :assignee, :mentioned, :labels, :sort, :direction] 62 | params = query_names.inject({}){|h,k| h[k] = options[k] if options[k];h} 63 | params[:status] ||= "open" 64 | params[:per_page] = options[:max_count] || 30 65 | 66 | url = to_url("repositories", @repo, 'issues') 67 | 68 | issues = fetch_json(url, options, params) 69 | issues = issues['issues'] 70 | issues = issues.sort_by{|i| i['local_id']} unless params[:sort] || params[:direction] 71 | 72 | t_max = issues.map{|i| mlength(i['title'])}.max 73 | l_max = issues.map{|i| mlength(i['metadata']['kind'])}.max 74 | u_max = issues.map{|i| mlength(i['reported_by']['username'])}.max 75 | 76 | or_zero = lambda{|v| v.blank? ? "0" : v } 77 | 78 | issues.each do |i| 79 | puts sprintf("%s %s %s %s %s c:%s v:%s p:%s %s %s", 80 | apply_fmt_colors(:id, sprintf('#%-4d', i['local_id'])), 81 | apply_fmt_colors(:state, i['status']), 82 | mljust(i['title'], t_max), 83 | apply_fmt_colors(:login, mljust(i['reported_by']['username'], u_max)), 84 | apply_fmt_colors(:labels, mljust(i['metadata']['kind'], l_max)), 85 | or_zero.call(i['comment_count']), 86 | or_zero.call(i['votes']), 87 | or_zero.call(i['position']), 88 | to_date(i['created_on']), 89 | to_date(i['utc_last_updated']) 90 | ) 91 | end 92 | 93 | end 94 | 95 | def mine(options = {}) 96 | raise "Not implemented yet." 97 | 98 | list(options.merge(:assignee => @user)) 99 | end 100 | 101 | def add(options = {}) 102 | property_names = [:title, :content, :assignee, :milestone, :labels] 103 | 104 | message = <<-MSG 105 | ### Write title here ### 106 | 107 | ### descriptions here ### 108 | MSG 109 | 110 | unless options[:title] 111 | options[:title], options[:content] = get_title_and_body_from_editor(message) 112 | end 113 | 114 | url = to_url("repositories", @repo, 'issues') 115 | 116 | issue = post_json(url, nil, options) 117 | puts "created issue #{oneline_issue(issue)}" 118 | end 119 | 120 | def update(options = {}) 121 | 122 | ticket = options[:ticket_id] 123 | raise 'ticket_id is required.' unless ticket 124 | 125 | property_names = [:title, :content, :assignee, :milestone, :labels, :status] 126 | 127 | if options.slice(*property_names).empty? 128 | issue = fetch_issue(ticket) 129 | message = "#{issue['title']}\n\n#{issue['content']}" 130 | options[:title], options[:content] = get_title_and_body_from_editor(message) 131 | end 132 | 133 | url = to_url("repositories", @repo, 'issues', ticket) 134 | 135 | issue = put_json(url, nil, options) # use POST instead of PATCH. 136 | puts "updated issue #{oneline_issue(issue)}" 137 | end 138 | 139 | 140 | def mention(options = {}) 141 | 142 | ticket = options[:ticket_id] 143 | raise 'ticket_id is required.' unless ticket 144 | 145 | unless options[:content] 146 | options[:content] = get_body_from_editor("### comment here ###") 147 | end 148 | raise 'comment content is required.' unless options[:content] 149 | 150 | url = to_url("repositories", @repo, 'issues', ticket, 'comments') 151 | 152 | issue = post_json(url, nil, options) 153 | 154 | issue = fetch_issue(ticket) 155 | puts "commented issue #{oneline_issue(issue)}" 156 | end 157 | 158 | def branch(options = {}) 159 | ticket = options[:ticket_id] 160 | raise 'ticket_id is required.' unless ticket 161 | 162 | branch_name = ticket_branch(ticket) 163 | 164 | if options[:force] 165 | system "git branch -D #{branch_name}" if options[:force] 166 | system "git checkout -b #{branch_name}" 167 | else 168 | if %x(git branch -l | grep "#{branch_name}").strip.empty? 169 | system "git checkout -b #{branch_name}" 170 | else 171 | system "git checkout #{branch_name}" 172 | end 173 | end 174 | 175 | show(options) 176 | end 177 | 178 | def close(options = {}) 179 | 180 | ticket = options[:ticket_id] 181 | raise 'ticket_id is required.' unless ticket 182 | 183 | unless options[:content] 184 | options[:content] = get_body_from_editor("### comment here ###") 185 | end 186 | 187 | options[:status] = "resolved" unless options[:status] 188 | 189 | url = to_url("repositories", @repo, 'issues', ticket) 190 | 191 | issue = put_json(url, nil, options) 192 | 193 | comment_url = to_url("repositories", @repo, 'issues', ticket, 'comments') 194 | post_json(comment_url, nil, options) 195 | 196 | puts "closed issue #{oneline_issue(issue)}" 197 | end 198 | 199 | private 200 | 201 | ROOT = 'https://api.bitbucket.org/1.0/' 202 | def to_url(*path_list) 203 | URI.join(ROOT, path_list.join("/")) 204 | end 205 | 206 | def fetch_json(url, options = {}, params = {}) 207 | response = send_request(url, {},options, params, :get) 208 | json = JSON.parse(response.body) 209 | 210 | raise error_message(json) unless response_success?(response) 211 | 212 | if @debug 213 | puts '-' * 80 214 | puts url 215 | pp json 216 | puts '-' * 80 217 | end 218 | 219 | json 220 | end 221 | 222 | def fetch_issue(ticket_id, params = {}) 223 | url = to_url("repositories", @repo, 'issues', ticket_id) 224 | # url += "?" + params.map{|k,v| "#{k}=#{v}"}.join("&") unless params.empty? 225 | json = fetch_json(url, {}, params) 226 | 227 | issue = json['issue'] || json 228 | raise "no such issue #{ticket} : #{base}" unless issue 229 | 230 | issue 231 | end 232 | 233 | def fetch_comments(ticket_id) 234 | url = to_url("repositories", @repo, 'issues', ticket_id, 'comments') 235 | json = fetch_json(url) || [] 236 | end 237 | 238 | def build_issue_json(options, property_names) 239 | json = property_names.inject({}){|h,k| h[k] = options[k] if options[k]; h} 240 | json[:labels] = json[:labels].split(",") if json[:labels] 241 | json 242 | end 243 | 244 | def post_json(url, json, options, params = {}) 245 | response = send_request(url, json, options, params, :post) 246 | json = JSON.parse(response.body) 247 | 248 | raise error_message(json) unless response_success?(response) 249 | json 250 | end 251 | 252 | def put_json(url, json, options, params = {}) 253 | response = send_request(url, json, options, params, :put) 254 | json = JSON.parse(response.body) 255 | 256 | raise error_message(json) unless response_success?(response) 257 | json 258 | end 259 | 260 | def error_message(json) 261 | msg = [json['message']] 262 | msg += json['errors'].map(&:pretty_inspect) if json['errors'] 263 | msg.join("\n ") 264 | end 265 | 266 | def send_request(url, json = {}, options = {}, params = {}, method = :post) 267 | url = "#{url}" 268 | uri = URI.parse(url) 269 | 270 | if @debug 271 | puts '-' * 80 272 | puts url 273 | pp json 274 | puts '-' * 80 275 | end 276 | 277 | https = connection(uri.host, uri.port) 278 | https.use_ssl = true 279 | https.verify_mode = @ssl_options[:ssl_verify_mode] || OpenSSL::SSL::VERIFY_NONE 280 | 281 | store = OpenSSL::X509::Store.new 282 | if @ssl_options[:ssl_ca_cert].present? 283 | if File.directory? @ssl_options[:ssl_ca_cert] 284 | store.add_path @ssl_options[:ssl_ca_cert] 285 | else 286 | store.add_file @ssl_options[:ssl_ca_cert] 287 | end 288 | http.cert_store = store 289 | else 290 | store.set_default_paths 291 | end 292 | https.cert_store = store 293 | 294 | https.set_debug_output $stderr if @debug && https.respond_to?(:set_debug_output) 295 | 296 | https.start{|http| 297 | 298 | path = "#{uri.path}" 299 | if method == :post or method == :put then 300 | post_options = options.map{|k,v| "#{k}=#{v}"}.join("&") 301 | else 302 | path += "?" + params.map{|k,v| "#{k}=#{v}"}.join("&") unless params.empty? 303 | end 304 | 305 | request = case method 306 | when :post then Net::HTTP::Post.new(path) 307 | when :put then Net::HTTP::Put.new(path) 308 | when :get then Net::HTTP::Get.new(path) 309 | else raise "unknown method #{method}" 310 | end 311 | 312 | password = options[:password] || get_password(@user) 313 | 314 | request.basic_auth @user, password 315 | 316 | if json != nil then 317 | request.set_content_type("application/json") 318 | request.body = json.to_json if json.present? 319 | elsif method == :post or method == :put then 320 | request.set_content_type("application/x-www-form-urlencoded") 321 | request.body = post_options 322 | end 323 | 324 | response = http.request(request) 325 | if @debug 326 | puts "#{response.code}: #{response.msg}" 327 | puts response.body 328 | end 329 | 330 | response 331 | } 332 | end 333 | 334 | def get_password(user) 335 | Pit.get("bitbucket", :require => { 336 | "password" => "Your password in Bitbucket", 337 | })["password"] 338 | end 339 | 340 | def oneline_issue(issue, options = {}) 341 | issue_title(issue) 342 | end 343 | 344 | def format_issue(issue, comments, options) 345 | msg = [""] 346 | 347 | msg << issue_title(issue) 348 | msg << "-" * 80 349 | msg << issue_author(issue) 350 | msg << "" 351 | 352 | props = [] 353 | props << ['comments', issue['comments']] 354 | props << ['votes', issue['votes']] 355 | props << ['position', issue['position']] 356 | props << ['milestone', issue['milestone']['title']] unless issue['milestone'].blank? 357 | 358 | props.each_with_index do |p,n| 359 | row = sprintf("%s : %s", mljust(p.first, 18), mljust(p.last.to_s, 24)) 360 | if n % 2 == 0 361 | msg << row 362 | else 363 | msg[-1] = "#{msg.last} #{row}" 364 | end 365 | end 366 | 367 | msg << sprintf("%s : %s", mljust('kind', 18), apply_fmt_colors(:labels, issue['metadata']['kind'])) 368 | msg << sprintf("%s : %s", mljust('updated_at', 18), Time.parse(issue['utc_last_updated'])) 369 | 370 | # display description 371 | msg << "-" * 80 372 | msg << "#{issue['content']}" 373 | msg << "" 374 | 375 | # display comments 376 | if comments && !comments.empty? 377 | msg << "-" * 80 378 | msg << "" 379 | cmts = format_comments(comments) 380 | msg += cmts.map{|s| " #{s}"} 381 | end 382 | 383 | msg.join("\n") 384 | end 385 | 386 | def issue_title(issue) 387 | 388 | "[#{apply_fmt_colors(:state, issue['status'])}] #{apply_fmt_colors(:id, "##{issue['local_id']}")} #{issue['title']}" 389 | end 390 | 391 | def issue_author(issue) 392 | author = issue['reported_by']['username'] 393 | created_on = issue['created_on'] 394 | 395 | msg = "#{apply_fmt_colors(:login, author)} opened this issue #{Time.parse(created_on)}" 396 | msg 397 | end 398 | 399 | def format_comments(comments) 400 | cmts = [] 401 | comments.sort_by{|c| c['utc_created_on']}.each_with_index do |c,n| 402 | cmts += format_comment(c,n) 403 | end 404 | cmts 405 | end 406 | 407 | def format_comment(c, n) 408 | cmts = [] 409 | 410 | cmts << "##{n + 1} - #{c['author_info']['username']}が#{time_ago_in_words(c['utc_created_on'])}に更新" 411 | cmts << "-" * 78 412 | cmts += c['content'].split("\n").to_a if c['content'] 413 | cmts << "" 414 | end 415 | 416 | def opt_parser 417 | opts = super 418 | opts.on("--supperss_comments", "-sc", "show issue journals"){|v| @options[:supperss_comments] = true} 419 | opts.on("--title=VALUE", "Title of issue.Use the given value to create/update issue."){|v| @options[:title] = v} 420 | opts.on("--body=VALUE", "Body content of issue.Use the given value to create/update issue."){|v| @options[:content] = v} 421 | opts.on("--state=VALUE", "Use the given value to create/update issue. or query of listing issues.Where 'state' is either 'open' or 'closed'"){|v| @options[:status] = v} 422 | opts.on("--milestone=VALUE", "Use the given value to create/update issue. or query of listing issues, (Integer Milestone number)"){|v| @options[:milestone] = v } 423 | opts.on("--assignee=VALUE", "Use the given value to create/update issue. or query of listing issues, (String User login)"){|v| @options[:assignee] = v } 424 | opts.on("--mentioned=VALUE", "Query of listing issues, (String User login)"){|v| @options[:mentioned] = v } 425 | opts.on("--labels=VALUE", "Use the given value to create/update issue. or query of listing issues, (String list of comma separated Label names)"){|v| @options[:labels] = v } 426 | opts.on("--sort=VALUE", "Query of listing issues, (created, updated, comments, default: created)"){|v| @options[:sort] = v } 427 | opts.on("--direction=VALUE", "Query of listing issues, (asc or desc, default: desc.)"){|v| @options[:direction] = v } 428 | opts.on("--since=VALUE", "Query of listing issue, (Optional string of a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ)"){|v| @options[:since] = v } 429 | 430 | opts.on("--password=VALUE", "For Authorizaion of create/update issue. Github API v3 doesn't supports API token base authorization for now. then, use Basic Authorizaion instead token." ){|v| @options[:password]} 431 | opts.on("--sslnoverify", "don't verify SSL"){|v| @options[:sslNoVerify] = true} 432 | opts 433 | end 434 | 435 | def apply_fmt_colors(key, str) 436 | fmt_colors[key.to_sym] ? apply_colors(str, *Array(fmt_colors[key.to_sym])) : str 437 | end 438 | 439 | def fmt_colors 440 | @fmt_colors ||= { :id => [:bold, :cyan], :state => :blue, 441 | :login => :magenta, :labels => :yellow} 442 | end 443 | 444 | end 445 | end 446 | -------------------------------------------------------------------------------- /lib/git_issue/github.rb: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | require 'base64' 3 | require 'pit' 4 | 5 | module GitIssue 6 | class GitIssue::Github < GitIssue::Base 7 | def initialize(args, options = {}) 8 | super(args, options) 9 | 10 | @repo = configured_value('issue.repo') 11 | if @repo.blank? 12 | url = `git config remote.origin.url`.strip 13 | @repo = url.match(/github.com[:\/](.+)\.git/)[1] 14 | end 15 | 16 | @user = options[:user] || configured_value('issue.user') 17 | @user = global_configured_value('github.user') if @user.blank? 18 | @user = Pit.get("github", :require => { 19 | "user" => "Your user name in GitHub", 20 | })["user"] if @user.blank? 21 | 22 | configure_error('user', "git config issue.user yuroyoro") if @user.blank? 23 | @ssl_options = {} 24 | if @options.key?(:sslNoVerify) && RUBY_VERSION < "1.9.0" 25 | @ssl_options[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE 26 | elsif configured_value('http.sslVerify') == "false" 27 | @ssl_options[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE 28 | end 29 | if (ssl_cert = configured_value('http.sslCert')) 30 | @ssl_options[:ssl_ca_cert] = ssl_cert 31 | end 32 | end 33 | 34 | def commands 35 | cl = super 36 | cl << GitIssue::Command.new(:mention, :men, 'create a comment to given issue') 37 | cl << GitIssue::Command.new(:close , :cl, 'close an issue with comment. comment is optional.') 38 | end 39 | 40 | def show(options = {}) 41 | ticket = options[:ticket_id] 42 | raise 'ticket_id is required.' unless ticket 43 | issue = fetch_issue(ticket, options) 44 | 45 | if options[:oneline] 46 | puts oneline_issue(issue, options) 47 | else 48 | comments = [] 49 | 50 | if issue['comments'].to_i > 0 51 | comments = fetch_comments(ticket) unless options[:supperss_comments] 52 | end 53 | puts "" 54 | puts format_issue(issue, comments, options) 55 | end 56 | end 57 | 58 | def view(options = {}) 59 | ticket = options[:ticket_id] 60 | raise 'ticket_id is required.' unless ticket 61 | url = URI.join('https://github.com/', [@user, @repo, 'issues', ticket].join("/")) 62 | system "git web--browse #{url}" 63 | end 64 | 65 | def list(options = {}) 66 | state = options[:state] || "open" 67 | 68 | query_names = [:state, :milestone, :assignee, :mentioned, :labels, :sort, :direction] 69 | params = query_names.inject({}){|h,k| h[k] = options[k] if options[k];h} 70 | params[:state] ||= "open" 71 | params[:per_page] = options[:max_count] || 30 72 | 73 | url = to_url("repos", @repo, 'issues') 74 | 75 | issues = fetch_json(url, options, params) 76 | issues = issues.sort_by{|i| i['number'].to_i} unless params[:sort] || params[:direction] 77 | 78 | t_max = issues.map{|i| mlength(i['title'])}.max 79 | l_max = issues.map{|i| mlength(i['labels'].map{|l| l['name']}.join(","))}.max 80 | u_max = issues.map{|i| mlength(i['user']['login'])}.max 81 | 82 | or_zero = lambda{|v| v.blank? ? "0" : v } 83 | 84 | issues.each do |i| 85 | puts sprintf("%s %s %s %s %s c:%s v:%s p:%s %s %s", 86 | apply_fmt_colors(:id, sprintf('#%-4d', i['number'].to_i)), 87 | apply_fmt_colors(:state, i['state']), 88 | mljust(i['title'], t_max), 89 | apply_fmt_colors(:login, mljust(i['user']['login'], u_max)), 90 | apply_fmt_colors(:labels, mljust(i['labels'].map{|l| l['name']}.join(','), l_max)), 91 | or_zero.call(i['comments']), 92 | or_zero.call(i['votes']), 93 | or_zero.call(i['position']), 94 | to_date(i['created_at']), 95 | to_date(i['updated_at']) 96 | ) 97 | end 98 | 99 | end 100 | 101 | def mine(options = {}) 102 | list(options.merge(:assignee => @user)) 103 | end 104 | 105 | def add(options = {}) 106 | property_names = [:title, :body, :assignee, :milestone, :labels] 107 | 108 | message = <<-MSG 109 | ### Write title here ### 110 | 111 | ### descriptions here ### 112 | MSG 113 | 114 | unless options[:title] 115 | options[:title], options[:body] = get_title_and_body_from_editor(message) 116 | end 117 | 118 | json = build_issue_json(options, property_names) 119 | url = to_url("repos", @repo, 'issues') 120 | 121 | issue = post_json(url, json, options) 122 | puts "created issue #{oneline_issue(issue)}" 123 | end 124 | 125 | def update(options = {}) 126 | ticket = options[:ticket_id] 127 | raise 'ticket_id is required.' unless ticket 128 | 129 | property_names = [:title, :body, :assignee, :milestone, :labels, :state] 130 | 131 | if options.slice(*property_names).empty? 132 | issue = fetch_issue(ticket) 133 | message = "#{issue['title']}\n\n#{issue['body']}" 134 | options[:title], options[:body] = get_title_and_body_from_editor(message) 135 | end 136 | 137 | json = build_issue_json(options, property_names) 138 | url = to_url("repos", @repo, 'issues', ticket) 139 | 140 | issue = post_json(url, json, options) # use POST instead of PATCH. 141 | puts "updated issue #{oneline_issue(issue)}" 142 | end 143 | 144 | 145 | def mention(options = {}) 146 | ticket = options[:ticket_id] 147 | raise 'ticket_id is required.' unless ticket 148 | 149 | body = options[:body] || get_body_from_editor("### comment here ###") 150 | raise 'comment body is required.' if body.empty? 151 | 152 | json = { :body => body } 153 | url = to_url("repos", @repo, 'issues', ticket, 'comments') 154 | 155 | issue = post_json(url, json, options) 156 | 157 | issue = fetch_issue(ticket) 158 | puts "commented issue #{oneline_issue(issue)}" 159 | end 160 | 161 | def branch(options = {}) 162 | ticket = options[:ticket_id] 163 | raise 'ticket_id is required.' unless ticket 164 | 165 | branch_name = ticket_branch(ticket) 166 | 167 | if options[:force] 168 | system "git branch -D #{branch_name}" if options[:force] 169 | system "git checkout -b #{branch_name}" 170 | else 171 | if %x(git branch -l | grep "#{branch_name}").strip.empty? 172 | system "git checkout -b #{branch_name}" 173 | else 174 | system "git checkout #{branch_name}" 175 | end 176 | end 177 | 178 | show(options) 179 | end 180 | 181 | def close(options = {}) 182 | ticket = options[:ticket_id] 183 | raise 'ticket_id is required.' unless ticket 184 | 185 | body = options[:body] || get_body_from_editor("### comment here ###") 186 | 187 | json = {:state => 'closed' } 188 | url = to_url("repos", @repo, 'issues', ticket) 189 | 190 | issue = post_json(url, json, options) 191 | 192 | comment_json = { :body => body } 193 | comment_url = to_url("repos", @repo, 'issues', ticket, 'comments') 194 | post_json(comment_url, comment_json, options) 195 | 196 | puts "closed issue #{oneline_issue(issue)}" 197 | end 198 | 199 | private 200 | 201 | ROOT = 'https://api.github.com/' 202 | def to_url(*path_list) 203 | URI.join(ROOT, path_list.join("/")) 204 | end 205 | 206 | def fetch_json(url, options = {}, params = {}) 207 | response = send_request(url, {},options, params, :get) 208 | json = JSON.parse(response.body) 209 | 210 | raise error_message(json) unless response_success?(response) 211 | 212 | if @debug 213 | puts '-' * 80 214 | puts url 215 | pp json 216 | puts '-' * 80 217 | end 218 | 219 | json 220 | end 221 | 222 | def fetch_issue(ticket_id, params = {}) 223 | url = to_url("repos", @repo, 'issues', ticket_id) 224 | # url += "?" + params.map{|k,v| "#{k}=#{v}"}.join("&") unless params.empty? 225 | json = fetch_json(url, {}, params) 226 | 227 | issue = json['issue'] || json 228 | raise "no such issue #{ticket} : #{base}" unless issue 229 | 230 | issue 231 | end 232 | 233 | def fetch_comments(ticket_id) 234 | url = to_url("repos", @repo, 'issues', ticket_id, 'comments') 235 | json = fetch_json(url) || [] 236 | end 237 | 238 | def build_issue_json(options, property_names) 239 | json = property_names.inject({}){|h,k| h[k] = options[k] if options[k]; h} 240 | json[:labels] = json[:labels].split(",") if json[:labels] 241 | json 242 | end 243 | 244 | def post_json(url, json, options, params = {}) 245 | response = send_request(url, json, options, params, :post) 246 | json = JSON.parse(response.body) 247 | 248 | raise error_message(json) unless response_success?(response) 249 | json 250 | end 251 | 252 | def put_json(url, json, options, params = {}) 253 | response = send_request(url, json, options, params, :put) 254 | json = JSON.parse(response.body) 255 | 256 | raise error_message(json) unless response_success?(response) 257 | json 258 | end 259 | 260 | def error_message(json) 261 | msg = [json['message']] 262 | msg += json['errors'].map(&:pretty_inspect) if json['errors'] 263 | msg.join("\n ") 264 | end 265 | 266 | def send_request(url, json = {}, options = {}, params = {}, method = :post) 267 | url = "#{url}" 268 | uri = URI.parse(url) 269 | 270 | if @debug 271 | puts '-' * 80 272 | puts url 273 | pp json 274 | puts '-' * 80 275 | end 276 | 277 | https = connection(uri.host, uri.port) 278 | https.use_ssl = true 279 | https.verify_mode = @ssl_options[:ssl_verify_mode] || OpenSSL::SSL::VERIFY_NONE 280 | 281 | store = OpenSSL::X509::Store.new 282 | if @ssl_options[:ssl_ca_cert].present? 283 | if File.directory? @ssl_options[:ssl_ca_cert] 284 | store.add_path @ssl_options[:ssl_ca_cert] 285 | else 286 | store.add_file @ssl_options[:ssl_ca_cert] 287 | end 288 | http.cert_store = store 289 | else 290 | store.set_default_paths 291 | end 292 | https.cert_store = store 293 | 294 | https.set_debug_output $stderr if @debug && https.respond_to?(:set_debug_output) 295 | 296 | https.start{|http| 297 | 298 | path = "#{uri.path}" 299 | path += "?" + params.map{|k,v| "#{k}=#{v}"}.join("&") unless params.empty? 300 | 301 | request = case method 302 | when :post then Net::HTTP::Post.new(path) 303 | when :put then Net::HTTP::Put.new(path) 304 | when :get then Net::HTTP::Get.new(path) 305 | else raise "unknown method #{method}" 306 | end 307 | 308 | # request["Authorizaion"] = "#{@user}/token: #{@apikey}" 309 | # 310 | # Github API v3 doesn't supports API token base authorization for now. 311 | # For Authentication, this method use Basic Authorizaion instead token. 312 | password = options[:password] || get_password(@user) 313 | 314 | request.basic_auth @user, password 315 | 316 | request.set_content_type("application/json") 317 | request.body = json.to_json if json.present? 318 | 319 | response = http.request(request) 320 | if @debug 321 | puts "#{response.code}: #{response.msg}" 322 | puts response.body 323 | end 324 | 325 | response 326 | } 327 | end 328 | 329 | def get_password(user) 330 | Pit.get("github", :require => { 331 | "password" => "Your password in GitHub", 332 | })["password"] 333 | end 334 | 335 | def oneline_issue(issue, options = {}) 336 | issue_title(issue) 337 | end 338 | 339 | def format_issue(issue, comments, options) 340 | msg = [""] 341 | 342 | msg << issue_title(issue) 343 | msg << "-" * 80 344 | msg << issue_author(issue) 345 | msg << "" 346 | 347 | props = [] 348 | props << ['comments', issue['comments']] 349 | props << ['votes', issue['votes']] 350 | props << ['position', issue['position']] 351 | props << ['milestone', issue['milestone']['title']] unless issue['milestone'].blank? 352 | 353 | props.each_with_index do |p,n| 354 | row = sprintf("%s : %s", mljust(p.first, 18), mljust(p.last.to_s, 24)) 355 | if n % 2 == 0 356 | msg << row 357 | else 358 | msg[-1] = "#{msg.last} #{row}" 359 | end 360 | end 361 | 362 | msg << sprintf("%s : %s", mljust('labels', 18), apply_fmt_colors(:labels, issue['labels'].map{|l| l['name']}.join(","))) 363 | msg << sprintf("%s : %s", mljust('html_url', 18), issue['html_url']) 364 | msg << sprintf("%s : %s", mljust('updated_at', 18), Time.parse(issue['updated_at'])) 365 | 366 | # display description 367 | msg << "-" * 80 368 | msg << "#{issue['body']}" 369 | msg << "" 370 | 371 | # display comments 372 | if comments && !comments.empty? 373 | msg << "-" * 80 374 | msg << "" 375 | cmts = format_comments(comments) 376 | msg += cmts.map{|s| " #{s}"} 377 | end 378 | 379 | msg.join("\n") 380 | end 381 | 382 | def issue_title(issue) 383 | "[#{apply_fmt_colors(:state, issue['state'])}] #{apply_fmt_colors(:id, "##{issue['number']}")} #{issue['title']}" 384 | end 385 | 386 | def issue_author(issue) 387 | author = issue['user']['login'] 388 | created_at = issue['created_at'] 389 | 390 | msg = "#{apply_fmt_colors(:login, author)} opened this issue #{Time.parse(created_at)}" 391 | msg 392 | end 393 | 394 | def format_comments(comments) 395 | cmts = [] 396 | comments.sort_by{|c| c['created_at']}.each_with_index do |c,n| 397 | cmts += format_comment(c,n) 398 | end 399 | cmts 400 | end 401 | 402 | def format_comment(c, n) 403 | cmts = [] 404 | 405 | cmts << "##{n + 1} - #{c['user']['login']}が#{time_ago_in_words(c['created_at'])}に更新" 406 | cmts << "-" * 78 407 | cmts += c['body'].split("\n").to_a if c['body'] 408 | cmts << "" 409 | end 410 | 411 | def opt_parser 412 | opts = super 413 | opts.on("--supperss_comments", "-sc", "show issue journals"){|v| @options[:supperss_comments] = true} 414 | opts.on("--title=VALUE", "Title of issue.Use the given value to create/update issue."){|v| @options[:title] = v} 415 | opts.on("--body=VALUE", "Body content of issue.Use the given value to create/update issue."){|v| @options[:body] = v} 416 | opts.on("--state=VALUE", "Use the given value to create/update issue. or query of listing issues.Where 'state' is either 'open' or 'closed'"){|v| @options[:state] = v} 417 | opts.on("--milestone=VALUE", "Use the given value to create/update issue. or query of listing issues, (Integer Milestone number)"){|v| @options[:milestone] = v } 418 | opts.on("--assignee=VALUE", "Use the given value to create/update issue. or query of listing issues, (String User login)"){|v| @options[:assignee] = v } 419 | opts.on("--mentioned=VALUE", "Query of listing issues, (String User login)"){|v| @options[:mentioned] = v } 420 | opts.on("--labels=VALUE", "Use the given value to create/update issue. or query of listing issues, (String list of comma separated Label names)"){|v| @options[:labels] = v } 421 | opts.on("--sort=VALUE", "Query of listing issues, (created, updated, comments, default: created)"){|v| @options[:sort] = v } 422 | opts.on("--direction=VALUE", "Query of listing issues, (asc or desc, default: desc.)"){|v| @options[:direction] = v } 423 | opts.on("--since=VALUE", "Query of listing issue, (Optional string of a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ)"){|v| @options[:since] = v } 424 | 425 | opts.on("--password=VALUE", "For Authorizaion of create/update issue. Github API v3 doesn't supports API token base authorization for now. then, use Basic Authorizaion instead token." ){|v| @options[:password]} 426 | opts.on("--sslnoverify", "don't verify SSL"){|v| @options[:sslNoVerify] = true} 427 | opts 428 | end 429 | 430 | def apply_fmt_colors(key, str) 431 | fmt_colors[key.to_sym] ? apply_colors(str, *Array(fmt_colors[key.to_sym])) : str 432 | end 433 | 434 | def fmt_colors 435 | @fmt_colors ||= { :id => [:bold, :cyan], :state => :blue, 436 | :login => :magenta, :labels => :yellow} 437 | end 438 | 439 | end 440 | end 441 | -------------------------------------------------------------------------------- /lib/git_issue/redmine.rb: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | module GitIssue 4 | class Redmine < GitIssue::Base 5 | 6 | def initialize(args, options = {}) 7 | super(args, options) 8 | 9 | @apikey = options[:apikey] || configured_value('issue.apikey') 10 | configure_error('apikey', "git config issue.apikey some_api_key") if @apikey.blank? 11 | 12 | @url = options[:url] || configured_value('issue.url') 13 | configure_error('url', "git config issue.url http://example.com/redmine") if @url.blank? 14 | end 15 | 16 | def default_cmd 17 | Helper.configured_value('issue.project').blank? ? :list : :project 18 | end 19 | 20 | def commands 21 | cl = super 22 | cl << GitIssue::Command.new(:local, :loc, 'listing local branches tickets') 23 | cl << GitIssue::Command.new(:project, :pj, 'listing ticket belongs to sspecified project ') 24 | end 25 | 26 | def show(options = {}) 27 | ticket = options[:ticket_id] 28 | raise 'ticket_id is required.' unless ticket 29 | 30 | issue = fetch_issue(ticket, options) 31 | 32 | if options[:oneline] 33 | puts oneline_issue(issue, options) 34 | else 35 | puts "" 36 | puts format_issue(issue, options) 37 | end 38 | end 39 | 40 | def view(options = {}) 41 | ticket = options[:ticket_id] 42 | raise 'ticket_id is required.' unless ticket 43 | url = to_url('issues', ticket) 44 | system "git web--browse #{url}" 45 | end 46 | 47 | def list(options = {}) 48 | url = to_url('issues') 49 | max_count = options[:max_count].to_s if options[:max_count] 50 | params = {"limit" => max_count || "100" } 51 | params.merge!("assigned_to_id" => "me") if options[:mine] 52 | params.merge!(Hash[*(options[:query].split("&").map{|s| s.split("=") }.flatten)]) if options[:query] 53 | 54 | param_list = Hash[*params.map{|k,v| [k,v.split(/,/)] }.flatten(1)] 55 | keys = param_list.keys 56 | pl,*pls = param_list.values 57 | 58 | jsons = pl.product(*pls).map{|vs| Hash[*keys.zip(vs).flatten]}.map{|p| 59 | fetch_json(url, p)['issues'] 60 | }.flatten 61 | 62 | known_ids = [] 63 | issues = jsons.reject{|i| 64 | known = known_ids.include?(i["id"]) 65 | known_ids << i['id'] unless known 66 | known 67 | } 68 | 69 | # json = fetch_json(url, params) 70 | 71 | # output_issues(json['issues']) 72 | output_issues(issues) 73 | end 74 | 75 | def mine(options = {}) 76 | list(options.merge(:mine => true)) 77 | end 78 | 79 | def commit(options = {}) 80 | ticket = options[:ticket_id] 81 | raise 'ticket_id is required.' unless ticket 82 | 83 | issue = fetch_issue(ticket) 84 | 85 | f = File.open("./commit_msg_#{ticket}", 'w') 86 | f.write("refs ##{ticket} #{issue['subject']}") 87 | f.close 88 | 89 | cmd = "git commit --edit #{options[:all] ? '-a' : ''} --file #{f.path}" 90 | system(cmd) 91 | 92 | File.unlink f.path if f.path 93 | end 94 | 95 | def add(options = {}) 96 | property_names = [:project_id, :subject, :description, :done_ratio, :status_id, :priority_id, :tracker_id, :assigned_to_id, :category_id, :fixed_version_id, :notes] 97 | 98 | project_id = options[:project_id] || Helper.configured_value('issue.project') 99 | if options.slice(*property_names).empty? 100 | issue = read_issue_from_editor({"project" => {"id" => project_id}}, options) 101 | description = issue.delete(:notes) 102 | issue[:description] = description 103 | options.merge!(issue) 104 | end 105 | 106 | required_properties = [:subject, :description] 107 | required_properties.each do |name| 108 | options[name] = prompt(name) unless options[name] 109 | end 110 | 111 | json = build_issue_json(options, property_names) 112 | json["issue"][:project_id] ||= Helper.configured_value('issue.project') 113 | 114 | url = to_url('issues') 115 | 116 | json = post_json(url, json, options) 117 | puts "created issue #{oneline_issue(json["issue"])}" 118 | end 119 | 120 | def update(options = {}) 121 | ticket = options[:ticket_id] 122 | raise 'ticket_id is required.' unless ticket 123 | 124 | property_names = [:subject, :done_ratio, :status_id, :priority_id, :tracker_id, :assigned_to_id, :category_id, :fixed_version_id, :notes] 125 | 126 | if options.slice(*property_names).empty? 127 | org_issue = fetch_issue(ticket, options) 128 | update_attrs = read_issue_from_editor(org_issue, options) 129 | update_attrs = update_attrs.reject{|k,v| v.present? && org_issue[k] == v} 130 | options.merge!(update_attrs) 131 | end 132 | 133 | json = build_issue_json(options, property_names) 134 | 135 | url = to_url('issues', ticket) 136 | put_json(url, json, options) 137 | issue = fetch_issue(ticket) 138 | puts "updated issue #{oneline_issue(issue)}" 139 | end 140 | 141 | def branch(options = {}) 142 | ticket = options[:ticket_id] 143 | raise 'ticket_id is required.' unless ticket 144 | 145 | branch_name = ticket_branch(ticket) 146 | 147 | if options[:force] 148 | system "git branch -D #{branch_name}" if options[:force] 149 | system "git checkout -b #{branch_name}" 150 | else 151 | if %x(git branch -l | grep "#{branch_name}").strip.empty? 152 | system "git checkout -b #{branch_name}" 153 | else 154 | system "git checkout #{branch_name}" 155 | end 156 | end 157 | 158 | show(options) 159 | end 160 | 161 | def local(option = {}) 162 | branches = %x(git branch).split(/\n/).select{|b| b.scan(/(\d+)_/).present?}.map{|b| b.gsub(/^(\s+|\*\s+)/, "")} 163 | branches.each do |b| 164 | puts b 165 | issues = b.scan(/(\d+)_/).map{|ticket_id| fetch_issue(ticket_id) rescue nil}.compact 166 | issues.each do |i| 167 | puts " #{oneline_issue(i, options)}" 168 | end 169 | puts "" 170 | end 171 | end 172 | 173 | def project(options = {}) 174 | project_id = Helper.configured_value('issue.project') 175 | project_id = options[:ticket_id] if project_id.blank? 176 | raise 'project_id is required.' unless project_id 177 | list(options.merge(:query => "project_id=#{project_id}")) 178 | end 179 | 180 | private 181 | 182 | def to_url(*path_list) 183 | URI.join(@url, path_list.join("/")) 184 | end 185 | 186 | def fetch_json(url, params = {}) 187 | url = "#{url}.json?key=#{@apikey}" 188 | url += "&" + params.map{|k,v| "#{k}=#{v}"}.join("&") unless params.empty? 189 | json = open(url) {|io| JSON.parse(io.read) } 190 | 191 | if @debug 192 | puts '-' * 80 193 | puts url 194 | pp json 195 | puts '-' * 80 196 | end 197 | 198 | json 199 | end 200 | 201 | def fetch_issue(ticket_id, options = {}) 202 | url = to_url("issues", ticket_id) 203 | includes = issue_includes(options) 204 | params = includes.empty? ? {} : {"include" => includes } 205 | json = fetch_json(url, params) 206 | 207 | issue = json['issue'] || json 208 | raise "no such issue #{ticket} : #{base}" unless issue 209 | 210 | issue 211 | end 212 | 213 | def post_json(url, json, options, params = {}) 214 | response = send_json(url, json, options, params, :post) 215 | JSON.parse(response.body) if response_success?(response) 216 | end 217 | 218 | def put_json(url, json, options, params = {}) 219 | send_json(url, json, options, params, :put) 220 | end 221 | 222 | def send_json(url, json, options, params = {}, method = :post) 223 | url = "#{url}.json" 224 | uri = URI.parse(url) 225 | 226 | if @debug 227 | puts '-' * 80 228 | puts url 229 | pp json 230 | puts '-' * 80 231 | end 232 | 233 | http = connection(uri.host, uri.port) 234 | if uri.scheme == 'https' 235 | http.use_ssl = true 236 | http.verify_mode = OpenSSL::SSL::VERIFY_NONE 237 | end 238 | http.set_debug_output $stderr if @debug && http.respond_to?(:set_debug_output) 239 | http.start{|http| 240 | 241 | path = "#{uri.path}?key=#{@apikey}" 242 | path += "&" + params.map{|k,v| "#{k}=#{v}"}.join("&") unless params.empty? 243 | 244 | request = case method 245 | when :post then Net::HTTP::Post.new(path) 246 | when :put then Net::HTTP::Put.new(path) 247 | else raise "unknown method #{method}" 248 | end 249 | 250 | request.set_content_type("application/json") 251 | request.body = json.to_json 252 | 253 | response = http.request(request) 254 | if @debug 255 | puts "#{response.code}: #{response.msg}" 256 | puts response.body 257 | end 258 | response 259 | } 260 | end 261 | 262 | def issue_includes(options) 263 | includes = [] 264 | includes << "journals" if ! options[:supperss_journals] || options[:verbose] 265 | includes << "changesets" if ! options[:supperss_changesets] || options[:verbose] 266 | includes << "relations" if ! options[:supperss_relations] || options[:verbose] 267 | includes.join(",") 268 | end 269 | 270 | 271 | def issue_title(issue) 272 | "[#{apply_colors(issue['project']['name'], :green)}] #{apply_colors(issue['tracker']['name'], :yellow)} #{apply_fmt_colors(:id, "##{issue['id']}")} #{issue['subject']}" 273 | end 274 | 275 | def issue_author(issue) 276 | author = issue['author']['name'] 277 | created_on = issue['created_on'] 278 | updated_on = issue['updated_on'] 279 | 280 | msg = "#{apply_fmt_colors(:assigned_to, author)}が#{time_ago_in_words(created_on)}に追加" 281 | msg += ", #{time_ago_in_words(updated_on)}に更新" unless created_on == updated_on 282 | msg 283 | end 284 | 285 | PROPERTY_TITLES= {"status"=>"ステータス", "start_date"=>"開始日", "category"=>"カテゴリ", "assigned_to"=>"担当者", "estimated_hours"=>"予定工数", "priority"=>"優先度", "fixed_version"=>"対象バージョン", "due_date"=>"期日", "done_ratio"=>"進捗"} 286 | 287 | def property_title(name) 288 | PROPERTY_TITLES[name] || name 289 | end 290 | 291 | def oneline_issue(issue, options = {}) 292 | "#{apply_fmt_colors(:id, "##{issue['id']}")} #{issue['subject']}" 293 | end 294 | 295 | def format_issue(issue, options) 296 | msg = [""] 297 | 298 | msg << issue_title(issue) 299 | msg << "-" * 80 300 | msg << issue_author(issue) 301 | msg << "" 302 | 303 | props = [] 304 | prop_name = Proc.new{|name| 305 | "#{issue[name]['name']}(#{issue[name]['id']})" if issue[name] && issue[name]['name'] 306 | } 307 | add_prop = Proc.new{|name| 308 | title = property_title(name) 309 | value = issue[name] || "" 310 | props << [title, value, name] 311 | } 312 | add_prop_name = Proc.new{|name| 313 | title = property_title(name) 314 | value = '' 315 | value = prop_name.call(name) 316 | props << [title, value, name] 317 | } 318 | 319 | add_prop_name.call('status') 320 | add_prop.call("start_date") 321 | add_prop_name.call('priority') 322 | add_prop.call('due_date') 323 | add_prop_name.call('assigned_to') 324 | add_prop.call('done_ratio') 325 | add_prop_name.call('category') 326 | add_prop.call('estimated_hours') 327 | 328 | # acd custom_fields if it have value. 329 | if custom_fields = issue[:custom_fields] && custom_fields.reject{|cf| cf['value'].nil? || cf['value'].empty? } 330 | custom_fields.each do |cf| 331 | props << [cf['name'], cf['value'], cf['name']] 332 | end 333 | end 334 | 335 | props.each_with_index do |p,n| 336 | title, value, name = p 337 | row = sprintf("%s : %s", mljust(title, 18), apply_fmt_colors(name, mljust(value.to_s, 24))) 338 | if n % 2 == 0 339 | msg << row 340 | else 341 | msg[-1] = "#{msg.last} #{row}" 342 | end 343 | end 344 | 345 | msg << sprintf("%s : %s", mljust(property_title('fixed_version'),18), mljust(prop_name.call('fixed_version'), 66)) 346 | 347 | # display relations tickets 348 | if ! options[:supperss_relations] || options[:verbose] 349 | relations = issue['relations'] 350 | if relations && !relations.empty? 351 | msg << "関連するチケット" 352 | msg << "-" * 80 353 | rels = format_relations(relations) 354 | msg += rels 355 | end 356 | end 357 | 358 | # display description 359 | msg << "-" * 80 360 | msg << "#{issue['description']}" 361 | msg << "" 362 | 363 | # display journals 364 | if ! options[:supperss_journals] || options[:verbose] 365 | journals = issue['journals'] 366 | if journals && !journals.empty? 367 | msg << "履歴" 368 | msg << "-" * 80 369 | msg << "" 370 | jnl = format_jounals(journals) 371 | msg += jnl.map{|s| " #{s}"} 372 | end 373 | end 374 | 375 | # display changesets 376 | if ! options[:supperss_changesets] || options[:verbose] 377 | changesets = issue['changesets'] 378 | if changesets && !changesets.empty? 379 | msg << "関係しているリビジョン" 380 | msg << "-" * 80 381 | msg << "" 382 | cs = format_changesets(changesets) 383 | msg += cs.map{|s| " #{s}"} 384 | end 385 | end 386 | 387 | msg.join("\n") 388 | 389 | end 390 | 391 | def format_jounals(journals) 392 | jnl = [] 393 | journals.sort_by{|j| j['created_on']}.each_with_index do |j,n| 394 | jnl += format_jounal(j,n) 395 | end 396 | jnl 397 | end 398 | 399 | def format_jounal(j, n) 400 | jnl = [] 401 | 402 | jnl << "##{n + 1} - #{apply_fmt_colors(:assigned_to, j['user']['name'])}が#{time_ago_in_words(j['created_on'])}に更新" 403 | jnl << "-" * 78 404 | j['details'].each do |d| 405 | log = "#{property_title(d['name'])}を" 406 | if d['old_value'] 407 | log += "\"#{d['old_value']}\"から\"#{d['new_value']}\"へ変更" 408 | else 409 | log += "\"#{d['new_value']}\"にセット" 410 | end 411 | jnl << log 412 | end 413 | jnl += j['notes'].split("\n").to_a if j['notes'] 414 | jnl << "" 415 | end 416 | 417 | def format_changesets(changesets) 418 | cs = [] 419 | changesets.sort_by{|c| c['committed_on'] }.each do |c| 420 | cs << "リビジョン: #{apply_colors((c['revision'] || "")[0..10], :cyan)} #{apply_fmt_colors(:assigned_to, (c['user'] || {})['name'])}が#{time_ago_in_words(c['committed_on'])}に追加" 421 | cs += c['comments'].split("\n").to_a 422 | cs << "" 423 | end 424 | cs 425 | end 426 | 427 | def format_relations(relations) 428 | relations.map{|r| 429 | issue = fetch_issue(r['issue_id']) 430 | "#{relations_label(r['relation_type'])} #{issue_title(issue)} #{apply_fmt_colors(:status, issue['status']['name'])} #{issue['start_date']} " 431 | } 432 | end 433 | 434 | DEFAULT_FORMAT = "%I %S | %A | %s %T %P | %V %C |" 435 | 436 | def format_issue_tables(issues_json) 437 | name_of = lambda{|issue, name| issue[name]['name'] rescue ""} 438 | 439 | issues = issues_json.map{ |issue|{ 440 | :id => sprintf("#%-4d", issue['id']), :subject => issue['subject'], 441 | :project => name_of.call(issue, 'project'), 442 | :tracker => name_of.call(issue, 'tracker'), 443 | :status => name_of.call(issue, 'status'), 444 | :assigned_to => name_of.call(issue, 'assigned_to'), 445 | :version => name_of.call(issue, 'fixed_version'), 446 | :priority => name_of.call(issue, 'priority'), 447 | :category => name_of.call(issue, 'category'), 448 | :updated_on => issue['updated_on'].to_date 449 | }} 450 | 451 | max_of = lambda{|name, limit| 452 | max = issues.map{|i| mlength(i[name])}.max 453 | [max, limit].compact.min 454 | } 455 | max_length = { 456 | :project => max_of.call(:project, 20), 457 | :tracker => max_of.call(:tracker, 20), 458 | :status => max_of.call(:status, 20), 459 | :assigned_to => max_of.call(:assigned_to, 20), 460 | :version => max_of.call(:version, 20), 461 | :priority => max_of.call(:priority, 20), 462 | :category => max_of.call(:category, 20), 463 | :subject => 80 464 | } 465 | 466 | fmt = configured_value('issue.defaultformat', false) 467 | fmt = DEFAULT_FORMAT unless fmt.present? 468 | 469 | fmt_chars = { :I => :id, :S => :subject, 470 | :A => :assigned_to, :s => :status, :T => :tracker, 471 | :P => :priority, :p => :project, :V => :version, 472 | :C => :category, :U => :updated_on } 473 | 474 | format_to = lambda{|i| 475 | res = fmt.dup 476 | fmt_chars.each do |k, v| 477 | res.gsub!(/\%(\d*)#{k}/) do |s| 478 | max = $1.blank? ? max_length[v] : $1.to_i 479 | str = max ? mljust(i[v], max) : i[v] 480 | colored = fmt_colors[v] ? apply_fmt_colors(v, str) : str 481 | colored 482 | end 483 | end 484 | res 485 | } 486 | 487 | issues.map{|i| format_to.call(i) } 488 | end 489 | 490 | def apply_fmt_colors(key, str) 491 | fmt_colors[key.to_sym] ? apply_colors(str, *Array(fmt_colors[key.to_sym])) : str 492 | end 493 | 494 | def fmt_colors 495 | @fmt_colors ||= { :id => [:bold, :cyan], :status => :blue, 496 | :priority => :green, :assigned_to => :magenta, 497 | :tracker => :yellow} 498 | end 499 | 500 | def output_issues(issues) 501 | 502 | if options[:oneline] 503 | issues.each do |i| 504 | puts oneline_issue(i, options) 505 | end 506 | elsif options[:raw_id] 507 | issues.each do |i| 508 | puts i['id'] 509 | end 510 | else 511 | format_issue_tables(issues).each do |i| 512 | puts i 513 | end 514 | end 515 | end 516 | 517 | RELATIONS_LABEL = { "relates" => "関係している", "duplicates" => "重複している", 518 | "duplicated" => "重複されている", "blocks" => "ブロックしている", 519 | "blocked" => "ブロックされている", "precedes" => "先行する", "follows" => "後続する", 520 | } 521 | 522 | def relations_label(rel) 523 | RELATIONS_LABEL[rel] || rel 524 | end 525 | 526 | def build_issue_json(options, property_names) 527 | json = {"issue" => property_names.inject({}){|h,k| h[k] = options[k] if options[k].present?; h} } 528 | 529 | if custom_fields = options[:custom_fields] 530 | json['custom_fields'] = custom_fields.split(",").map{|s| k,*v = s.split(":");{'id' => k.to_i, 'value' => v.join }} 531 | end 532 | json 533 | end 534 | 535 | def read_issue_from_editor(issue, options = {}) 536 | id_of = lambda{|name| issue[name] ? sprintf('%2s : %s', issue[name]["id"] , issue[name]['name'] ): ""} 537 | 538 | memofile = configured_value('issue.memofile') 539 | memo = File.open(memofile).read.lines.map{|l| "# #{l}"}.join("") unless memofile.blank? 540 | 541 | message = <<-MSG 542 | #{issue["subject"].present? ? issue["subject"].chomp : "### subject here ###"} 543 | 544 | Project : #{id_of.call("project")} 545 | Tracker : #{id_of.call("tracker")} 546 | Status : #{id_of.call("status")} 547 | Priority : #{id_of.call("priority")} 548 | Category : #{id_of.call("category")} 549 | Assigned : #{id_of.call("assigned_to")} 550 | Version : #{id_of.call("fixed_version")} 551 | 552 | # Please enter the notes for your changes. Lines starting 553 | # with '#' will be ignored, and an empty message aborts. 554 | #{memo} 555 | MSG 556 | body = get_body_from_editor(message) 557 | 558 | subject, dummy, project_id, tracker_id, status_id, priority_id, category_id, assigned_to_id, fixed_version_id, dummy, *notes = body.lines.to_a 559 | 560 | notes = if notes.present? 561 | notes.reject{|l| l =~ /^#/}.join("") 562 | else 563 | nil 564 | end 565 | 566 | if @debug 567 | puts "------" 568 | puts "sub: #{subject}" 569 | puts "pid: #{project_id}" 570 | puts "tid: #{tracker_id}" 571 | puts "sid: #{status_id}" 572 | puts "prd: #{priority_id}" 573 | puts "cat: #{category_id}" 574 | puts "ass: #{assigned_to_id}" 575 | puts "vss: #{fixed_version_id}" 576 | puts "nos: #{notes}" 577 | puts "------" 578 | end 579 | 580 | take_id = lambda{|s| 581 | x, i, name = s.chomp.split(":") 582 | i.present? ? i.strip.to_i : nil 583 | } 584 | 585 | { :subject => subject.chomp, :project_id => take_id.call(project_id), 586 | :tracker_id => take_id.call(tracker_id), 587 | :status_id => take_id.call(status_id), 588 | :priority_id => take_id.call(priority_id), 589 | :category_id => take_id.call(category_id), 590 | :assigned_to_id => take_id.call(assigned_to_id), 591 | :fixed_version_id => take_id.call(fixed_version_id), 592 | :notes => notes 593 | } 594 | end 595 | 596 | def opt_parser 597 | opts = super 598 | opts.on("--supperss_journals", "-j", "do not show issue journals"){|v| @options[:supperss_journals] = true} 599 | opts.on("--supperss_relations", "-r", "do not show issue relations tickets"){|v| @options[:supperss_relations] = true} 600 | opts.on("--supperss_changesets", "-c", "do not show issue changesets"){|v| @options[:supperss_changesets] = true} 601 | opts.on("--query=VALUE",'-q=VALUE', "filter query of listing tickets") {|v| @options[:query] = v} 602 | 603 | opts.on("--mine", "lists issues assigned_to me"){|v| @options[:mine] = true} 604 | opts.on("--project_id=VALUE", "use the given value to create subject"){|v| @options[:project_id] = v} 605 | opts.on("--description=VALUE", "use the given value to create subject"){|v| @options[:description] = v} 606 | opts.on("--subject=VALUE", "use the given value to create/update subject"){|v| @options[:subject] = v} 607 | opts.on("--ratio=VALUE", "use the given value to create/update done-ratio(%)"){|v| @options[:done_ratio] = v.to_i} 608 | opts.on("--status=VALUE", "use the given value to create/update issue statues id"){|v| @options[:status_id] = v } 609 | opts.on("--priority=VALUE", "use the given value to create/update issue priority id"){|v| @options[:priority_id] = v } 610 | opts.on("--tracker=VALUE", "use the given value to create/update tracker id"){|v| @options[:tracker_id] = v } 611 | opts.on("--assigned_to_id=VALUE", "use the given value to create/update assigned_to id"){|v| @options[:assigned_to_id] = v } 612 | opts.on("--category=VALUE", "use the given value to create/update category id"){|v| @options[:category_id] = v } 613 | opts.on("--fixed_version=VALUE", "use the given value to create/update fixed_version id"){|v| @options[:fixed_version_id] = v } 614 | opts.on("--custom_fields=VALUE", "value should be specifies ':,:, ...' "){|v| @options[:custom_fields] = v } 615 | 616 | opts.on("--notes=VALUE", "add notes to issue"){|v| @options[:notes] = v} 617 | 618 | opts 619 | end 620 | end 621 | end 622 | -------------------------------------------------------------------------------- /lib/git_issue/version.rb: -------------------------------------------------------------------------------- 1 | module GitIssue 2 | VERSION = "0.9.2" 3 | end 4 | -------------------------------------------------------------------------------- /spec/git_issue/base_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') 2 | 3 | describe GitIssue::Base do 4 | 5 | class SampleIts < GitIssue::Base 6 | def show(options = {});end 7 | def guess_ticket; 6789 end 8 | end 9 | 10 | describe '#initialize' do 11 | context 'specified unknown g ' do 12 | let(:args) { ["homuhomu", "1234"] } 13 | it { lambda{ SampleIts.new(args) }.should raise_error } 14 | end 15 | 16 | context 'specified known g ' do 17 | let(:args) { ["show", "1234"] } 18 | 19 | subject{ SampleIts.new(args) } 20 | 21 | it { subject.command.name.should == :show } 22 | its(:tickets) { should == [1234] } 23 | end 24 | 25 | context 'args is blank' do 26 | let (:args) { [] } 27 | 28 | subject { SampleIts.new(args) } 29 | 30 | it { subject.command.name.should == :show } 31 | its(:tickets) { should == [6789]} 32 | end 33 | 34 | context 'specified number only' do 35 | let (:args) {["9876"] } 36 | 37 | subject { SampleIts.new(args) } 38 | 39 | it { subject.command.name.should == :show } 40 | its(:tickets) { should == [9876]} 41 | end 42 | 43 | context 'specified multipul numbers ' do 44 | let(:args) { ["1234", "5678", "9999"] } 45 | 46 | subject { SampleIts.new(args) } 47 | 48 | it { subject.command.name.should == :show } 49 | its(:tickets) { should == [1234, 5678, 9999]} 50 | end 51 | end 52 | 53 | describe '#execute' do 54 | 55 | context 'one ticket_id specified' do 56 | let(:args) { ["show", "1234"] } 57 | let(:its) { SampleIts.new(args) } 58 | 59 | it { its.should_receive(:show).with(its.options.merge(:ticket_id => 1234)).once } 60 | after { its.execute } 61 | end 62 | 63 | context 'three ticket_ids specified' do 64 | let(:args) { ["show", "1234", "5678", "9999"] } 65 | let(:its) { SampleIts.new(args) } 66 | 67 | it { 68 | its.should_receive(:show).with(its.options.merge(:ticket_id => 1234)).once 69 | its.should_receive(:show).with(its.options.merge(:ticket_id => 5678)).once 70 | its.should_receive(:show).with(its.options.merge(:ticket_id => 9999)).once 71 | } 72 | after { its.execute } 73 | end 74 | 75 | end 76 | 77 | 78 | end 79 | -------------------------------------------------------------------------------- /spec/git_issue/github_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') 2 | 3 | describe GitIssue::Github do 4 | 5 | let(:apikey) { "ABCDEFG1234567890" } 6 | let(:user) { "yuroyoro" } 7 | let(:repo) { "gitterb" } 8 | 9 | describe '#initialize' do 10 | context 'ginve no apikey ' do 11 | let(:args) { ["show", "1234"] } 12 | it { lambda{ GitIssue::Github.new(args) }.should raise_error } 13 | end 14 | 15 | context 'given no user' do 16 | let(:args) { ["show", "1234"] } 17 | it { lambda{ GitIssue::Github.new(args, :apikey => apikey) }.should raise_error } 18 | end 19 | 20 | context 'given no repo' do 21 | let(:args) { ["show", "1234"] } 22 | it { lambda{ GitIssue::Github.new(args, :apikey => apikey, :user => user) }.should raise_error } 23 | end 24 | 25 | context 'given apikey, user and repo ' do 26 | let(:args) { ["show", "1234"] } 27 | it { lambda{ GitIssue::Github.new(args, :apikey => apikey, :user => user, :repo => repo) }.should_not raise_error } 28 | end 29 | end 30 | 31 | describe '#show' do 32 | let(:args) { ["show", "1234"] } 33 | let(:sysout) { StringIO.new } 34 | let(:syserr) { StringIO.new } 35 | let(:github) { GitIssue::Github.new(args, :apikey => apikey, :user => user, :repo => repo, :sysout => sysout, :syserr => syserr) } 36 | 37 | let(:json) {{"issue"=> 38 | {"body" =>"change diff views like github.", 39 | "closed_at" =>"2011/07/20 01:48:05 -0700", 40 | "comments" =>1, 41 | "created_at" =>"2011/07/14 04:14:12 -0700", 42 | "gravatar_id"=>"bd3590aaffe8948079d27795cb6f7388", 43 | "html_url" =>"https://github.com/yuroyoro/gitterb/issues/1", 44 | "labels" =>[], 45 | "number" =>1, 46 | "position" =>1.0, 47 | "state" =>"closed", 48 | "title" =>"improve diff views", 49 | "updated_at" =>"2011/07/20 01:48:05 -0700", 50 | "user" =>"yuroyoro", 51 | "votes" =>0}} 52 | } 53 | 54 | let(:comments) { [ 55 | { "user"=>"yuroyoro", "gravatar_id"=>"bd3590aaffe8948079d27795cb6f7388", 56 | "updated_at"=>"2011/07/20 01:48:05 -0700", "body"=>"completed.", "id"=>1613903, 57 | "created_at"=>"2011/07/20 01:48:05 -0700"}, 58 | { "user"=>"foolesa", "gravatar_id"=>"bd3590aaffe8948079d27795cb6f7388", 59 | "updated_at"=>"2011/07/22 03:50:05 -0700", 60 | "body"=>"らめぇぁ…あ!!!あひゃぴー?ひぃぱぎぃっうふふ?", "id"=>1613904, 61 | "created_at"=>"2011/07/23 04:48:05 -0700"} 62 | ] 63 | } 64 | 65 | context 'given no ticket_id' do 66 | it { lambda {github.show() }.should raise_error( 'ticket_id is required.') } 67 | end 68 | 69 | context 'given ticket_id' do 70 | 71 | before { 72 | github.should_receive(:fetch_json).and_return(json) 73 | github.show(:ticket_id => 1234) 74 | } 75 | subject { github.sysout.rewind; github.sysout.read } 76 | 77 | it { sysout.length.should_not be_zero } 78 | it { syserr.length.should be_zero } 79 | 80 | it { should include '[closed] #1 improve diff views' } 81 | it { should include 'yuroyoro opened this issue Thu Jul 14 20:14:12 +0900 2011' } 82 | it { should include 'change diff views like github.' } 83 | 84 | end 85 | 86 | context 'given ticket_id with --comments' do 87 | 88 | before { 89 | github.should_receive(:fetch_json).and_return(json) 90 | github.should_receive(:fetch_comments).and_return(comments) 91 | github.show(:ticket_id => 1234, :comments=> true) 92 | } 93 | subject { github.sysout.rewind; github.sysout.read } 94 | 95 | it { sysout.length.should_not be_zero } 96 | it { syserr.length.should be_zero } 97 | 98 | it { should include '[closed] #1 improve diff views' } 99 | 100 | end 101 | end 102 | 103 | describe '#list' do 104 | let(:args) { ["list","--status=closed"] } 105 | let(:sysout) { StringIO.new } 106 | let(:syserr) { StringIO.new } 107 | let(:github) { GitIssue::Github.new(args, :apikey => apikey, :user => user, :repo => repo, :sysout => sysout, :syserr => syserr) } 108 | 109 | let(:issues) { 110 | {"issues" => 111 | [{"body" => "It appeared two commit has same SHA-1.\r\nThat's maybe branch's commit.", 112 | "closed_at" =>"2011/07/20 05:28:42 -0700", "comments" =>1, 113 | "created_at" =>"2011/07/20 04:18:23 -0700", "gravatar_id"=>"bd3590aaffe8948079d27795cb6f7388", 114 | "html_url" =>"https://github.com/yuroyoro/gitterb/issues/5", 115 | "labels" =>["foo","bar"], "number" =>5, "position" =>1.0, 116 | "state" =>"closed", "title" =>"Rendered duplicate commit node.", 117 | "updated_at" =>"2011/07/20 05:28:51 -0700", "user" =>"yuroyoro", "votes" =>0}, 118 | {"body" => "if the 'all' checked ,it will be rendered all other branches that reach from selected branch's commit.\r\nif selected branch is near from first commit, it will be rendered all most commits and branches.\r\nit's too slow and rendered diagram to be large.", 119 | "closed_at" =>"2011/07/20 00:06:27 -0700", "comments" =>1, 120 | "created_at" =>"2011/07/19 23:21:20 -0700", "gravatar_id"=>"bd3590aaffe8948079d27795cb6f7388", 121 | "html_url" =>"https://github.com/yuroyoro/gitterb/issues/4", 122 | "labels" =>["bar"], "number" =>4, "position" =>1.0, "state" =>"closed", 123 | "title" => "related branche's commit are too many and rendering are too slow.", 124 | "updated_at" =>"2011/07/20 01:46:10 -0700", "user" =>"yuroyoro", "votes" =>0}, 125 | {"body" => "cytoscapeweb's swf generate javascripts for calling event listener \r\nwhen click event fired. but generated javascripts doesn't escaped\r\ndouble quote, then it's to be a invalid syntax and syntax error occurred.\r\n", 126 | "closed_at" =>"2011/07/20 01:47:42 -0700", "comments" =>1, 127 | "created_at" =>"2011/07/19 23:02:33 -0700", "gravatar_id"=>"bd3590aaffe8948079d27795cb6f7388", 128 | "html_url" =>"https://github.com/yuroyoro/gitterb/issues/3", 129 | "labels" =>[], "number" =>3, "position" =>1.0, "state" =>"closed", 130 | "title" => "script error occurred when commit message includes double quote.", 131 | "updated_at" =>"2011/07/20 01:47:42 -0700", "user" =>"yuroyoro", "votes" =>0}, 132 | {"body" =>"upgrade to rails3.1.0.rc4. use coffeescript and scss.", 133 | "closed_at" =>"2011/07/20 01:47:53 -0700", "comments" =>1, 134 | "created_at" =>"2011/07/14 04:16:23 -0700", "gravatar_id"=>"bd3590aaffe8948079d27795cb6f7388", 135 | "html_url" =>"https://github.com/yuroyoro/gitterb/issues/2", 136 | "labels" =>[], "number" =>2, "position" =>1.0, "state" =>"closed", 137 | "title" =>"upgrade to rails3.1.0", "updated_at" =>"2011/07/20 01:47:53 -0700", 138 | "user" =>"yuroyoro", "votes" =>0}, 139 | {"body" =>"change diff views like github.", 140 | "closed_at" =>"2011/07/20 01:48:05 -0700", "comments" =>1, 141 | "created_at" =>"2011/07/14 04:14:12 -0700", 142 | "gravatar_id"=>"bd3590aaffe8948079d27795cb6f7388", 143 | "html_url" =>"https://github.com/yuroyoro/gitterb/issues/1", 144 | "labels" =>[], "number" =>1, "position" =>1.0, "state" =>"closed", 145 | "title" =>"improve diff views", 146 | "updated_at" =>"2011/09/12 04:30:41 -0700", "user" =>"yuroyoro", "votes" =>0}]} 147 | } 148 | 149 | context 'given no status' do 150 | 151 | before { 152 | github.should_receive(:fetch_json).with( URI.join(GitIssue::Github::ROOT, 'issues/list/yuroyoro/gitterb/open')).and_return(issues) 153 | 154 | github.list() 155 | } 156 | subject { github.sysout.rewind; github.sysout.read } 157 | 158 | it { sysout.length.should_not be_zero } 159 | it { syserr.length.should be_zero } 160 | 161 | it { should include "#5 closed Rendered duplicate commit node. yuroyoro foo,bar comments:1 votes:0 position:1.0 2011/07/20"} 162 | it { should include "#4 closed related branche's commit are too many and rendering are too slow. yuroyoro bar comments:1 votes:0 position:1.0 2011/07/19"} 163 | it { should include "#3 closed script error occurred when commit message includes double quote. yuroyoro comments:1 votes:0 position:1.0 2011/07/19"} 164 | it { should include "#2 closed upgrade to rails3.1.0 yuroyoro comments:1 votes:0 position:1.0 2011/07/14"} 165 | it { should include "#1 closed improve diff views yuroyoro comments:1 votes:0 position:1.0 2011/07/14"} 166 | 167 | 168 | end 169 | 170 | context 'given status' do 171 | 172 | before { 173 | github.should_receive(:fetch_json).with( URI.join(GitIssue::Github::ROOT, 'issues/list/yuroyoro/gitterb/closed')).and_return(issues) 174 | 175 | github.list(:status => 'closed') 176 | } 177 | subject { github.sysout.rewind; github.sysout.read } 178 | 179 | it { sysout.length.should_not be_zero } 180 | it { syserr.length.should be_zero } 181 | end 182 | end 183 | 184 | 185 | 186 | end 187 | -------------------------------------------------------------------------------- /spec/git_issue/redmine_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') 2 | 3 | describe GitIssue::Redmine do 4 | 5 | let(:apikey) { "ABCDEFG1234567890" } 6 | let(:url) { "http://example.com/redmine" } 7 | 8 | describe '#initialize' do 9 | context 'ginve no apikey ' do 10 | let(:args) { ["show", "1234"] } 11 | it { lambda{ GitIssue::Redmine.new(args) }.should raise_error } 12 | end 13 | 14 | context 'given no url' do 15 | let(:args) { ["show", "1234"] } 16 | it { lambda{ GitIssue::Redmine.new(args, :apikey => apikey) }.should raise_error } 17 | end 18 | 19 | context 'given apikey and url' do 20 | let(:args) { ["show", "1234"] } 21 | it { lambda{ GitIssue::Redmine.new(args, :apikey => apikey, :url => url) }.should_not raise_error } 22 | end 23 | end 24 | 25 | describe '#show' do 26 | let(:args) { ["show", "1234"] } 27 | let(:sysout) { StringIO.new } 28 | let(:syserr) { StringIO.new } 29 | let(:redmine) { GitIssue::Redmine.new(args, :apikey => apikey, :url => url, :sysout => sysout, :syserr => syserr) } 30 | 31 | context 'given no ticket_id' do 32 | it { lambda {redmine.show() }.should raise_error } 33 | end 34 | 35 | context 'given ticket_id' do 36 | let(:json) {{ 37 | 'issue' => { 38 | 'id' => 1234, 39 | 'status'=>{'name'=>'新規', 'id'=>1}, 40 | 'category'=>{'name'=>'カテゴリ', 'id'=>3}, 41 | 'assigned_to'=>{'name'=>'Tomohito Ozaki', 'id'=>13}, 42 | 'project' => {'name' => 'Testプロジェクト', 'id' => 9}, 43 | 'priority'=>{'name'=>'通常', 'id'=>4}, 44 | 'author' => {'name' => 'author hoge', 'id' => 3}, 45 | 'committer' => {'name' => 'committer fuga', 'id' => 4}, 46 | 'tracker' => {'name' => 'Bug', 'id' => 5}, 47 | 'subject' => 'new演算子が乳演算子だったらプログラマもっと増えてた', 48 | 'description'=>'( ゚∀゚)o彡°おっぱい!おっぱい!', 49 | 'created_on'=>'2008/08/03 04:08:39 +0900', 50 | 'updated_on'=>'2011/03/02 23:22:49 +0900', 51 | 'done_ratio'=>0, 52 | 'custom_fields'=> 53 | [{'name'=>'Complete', 'id'=>1, 'value'=>'0'}, 54 | {'name'=>'Due assign', 'id'=>2, 'value'=>'yyyy/mm/dd'}, 55 | {'name'=>'Due close', 'id'=>3, 'value'=>'yyyy/mm/dd'}, 56 | {'name'=>'Resolution', 'id'=>4, 'value'=>''} 57 | ] 58 | } 59 | }} 60 | 61 | it { 62 | redmine.should_receive(:fetch_json).and_return(json) 63 | redmine.show(:ticket_id => 1234) 64 | sysout.length.should_not be_zero 65 | syserr.length.should be_zero 66 | } 67 | 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/git_issue_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/spec_helper') 2 | 3 | describe GitIssue do 4 | describe '#main' do 5 | context 'config issue.type does not configured' do 6 | it{ 7 | GitIssue::Helper.should_receive(:configured_value).with("type").and_return("") 8 | GitIssue::Helper.should_receive(:configured_value).with("apikey").and_return("some value") 9 | GitIssue::Helper.should_receive(:configure_error).with( "type (redmine | github)", "git config issue.type redmine") 10 | lambda { GitIssue.main([]) }.should raise_error(SystemExit) 11 | } 12 | end 13 | 14 | context 'invalid issue.type' do 15 | it{ 16 | GitIssue::Helper.should_receive(:configured_value).with("type").and_return("unknown-type") 17 | GitIssue::Helper.should_receive(:configured_value).with("apikey").and_return("some value") 18 | lambda { GitIssue.main([]) }.should raise_error(SystemExit) 19 | } 20 | end 21 | end 22 | 23 | describe '#its_klass_of' do 24 | context 'unknown type' do 25 | specify { lambda { GitIssue::Helper.its_klass_of("unknown_type") }.should raise_error } 26 | end 27 | 28 | context 'type is redmine' do 29 | subject { GitIssue::Helper.its_klass_of("redmine") } 30 | it { should == GitIssue::Redmine } 31 | end 32 | 33 | context 'type is github' do 34 | subject { GitIssue::Helper.its_klass_of("github") } 35 | it { should == GitIssue::Github} 36 | end 37 | 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/spec.opts: -------------------------------------------------------------------------------- 1 | --colour 2 | --format specdoc 3 | --loadby mtime 4 | --backtrace 5 | --reverse 6 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 2 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 3 | require 'git_issue' 4 | require 'rspec' 5 | require 'rspec/autorun' 6 | 7 | RSpec.configure do |config| 8 | end 9 | --------------------------------------------------------------------------------