├── .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 |
--------------------------------------------------------------------------------