git-issue 0.8.7 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -58,8 +58,9 @@ module GitIssue
58
58
 
59
59
  def its_klass_of(its_type)
60
60
  case its_type
61
- when /redmine/i then GitIssue::Redmine
62
- when /github/i then GitIssue::Github
61
+ when /redmine/i then GitIssue::Redmine
62
+ when /github/i then GitIssue::Github
63
+ when /bitbucket/i then GitIssue::Bitbucket
63
64
  else
64
65
  raise "unknown issue tracker type : #{its_type}"
65
66
  end
@@ -73,8 +74,11 @@ module GitIssue
73
74
  editor.shellsplit
74
75
  end
75
76
 
76
- def git_dir
77
- `git rev-parse -q --git-dir`.strip
77
+ def work_dir
78
+ dir = RUBY_PLATFORM.downcase =~ /mswin(?!ce)|mingw|bccwin|cygwin/ ?
79
+ `git rev-parse -q --git-dir 2> NUL`.strip :
80
+ `git rev-parse -q --git-dir 2> /dev/null`.strip
81
+ dir.empty? ? Dir.tmpdir : dir
78
82
  end
79
83
 
80
84
  def split_head_and_body(text)
@@ -94,7 +98,7 @@ module GitIssue
94
98
  f = open(file)
95
99
  body = f.read
96
100
  f.close
97
- body.strip
101
+ body
98
102
  end
99
103
 
100
104
  def get_title_and_body_from_editor(message=nil)
@@ -113,21 +117,25 @@ module GitIssue
113
117
  end
114
118
 
115
119
  def open_editor(message = nil, abort_if_not_modified = true , &block)
116
- message_file = File.join(git_dir, 'ISSUE_MESSAGE')
120
+ message_file = File.join(work_dir, 'ISSUE_MESSAGE')
117
121
  File.open(message_file, 'w') { |msg|
118
122
  msg.puts message
119
123
  }
120
- edit_cmd = Array(git_editor).dup
121
- edit_cmd << '-c' << 'set ft=gitcommit' if edit_cmd[0] =~ /^[mg]?vim$/
122
- edit_cmd << message_file
123
-
124
- system(*edit_cmd)
125
- abort "can't open text editor for issue message" unless $?.success?
126
-
127
- text = read_body(message_file)
128
- abort "Aborting cause messages didn't modified." if message == text && abort_if_not_modified
124
+ begin
125
+ edit_cmd = Array(git_editor).dup
126
+ edit_cmd << '-c' << 'set ft=gitcommit' if edit_cmd[0] =~ /^[mg]?vim$/
127
+ edit_cmd << message_file
128
+
129
+ system(*edit_cmd)
130
+ abort "can't open text editor for issue message" unless $?.success?
131
+
132
+ text = read_body(message_file)
133
+ abort "Aborting cause messages didn't modified." if message == text && abort_if_not_modified
134
+ ensure
135
+ File.unlink(message_file)
136
+ end
129
137
 
130
- yield text
138
+ yield text.strip
131
139
  end
132
140
 
133
141
  module_function :configured_value, :global_configured_value, :configure_error, :its_klass_of, :get_title_and_body_from_editor, :get_body_from_editor
@@ -144,10 +152,15 @@ module GitIssue
144
152
  github_user = Helper.global_configured_value('github.user')
145
153
  unless github_user.blank?
146
154
  its_type = 'github'
155
+ else
156
+ bitbucket_user = Helper.global_configured_value('bitbucket.user')
157
+ unless bitbucket_user.blank?
158
+ its_type = 'bitbucket'
159
+ end
147
160
  end
148
161
  end
149
162
 
150
- Helper.configure_error('type (redmine | github)', "git config issue.type redmine") if its_type.blank?
163
+ Helper.configure_error('type (redmine | github | bitbucket)', "git config issue.type redmine") if its_type.blank?
151
164
 
152
165
  its_klass = Helper.its_klass_of(its_type)
153
166
  status = its_klass.new(ARGV).execute || true
@@ -163,5 +176,6 @@ module GitIssue
163
176
  end
164
177
 
165
178
  require File.dirname(__FILE__) + '/git_issue/base'
166
- require File.dirname(__FILE__) + '/git_issue/redmine'
179
+ require File.dirname(__FILE__) + '/git_issue/bitbucket'
167
180
  require File.dirname(__FILE__) + '/git_issue/github'
181
+ require File.dirname(__FILE__) + '/git_issue/redmine'
@@ -0,0 +1,445 @@
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
@@ -409,7 +409,7 @@ class Redmine < GitIssue::Base
409
409
  def format_changesets(changesets)
410
410
  cs = []
411
411
  changesets.sort_by{|c| c['committed_on'] }.each do |c|
412
- cs << "リビジョン: #{apply_colors(c['revision'][0..10], :cyan)} #{apply_fmt_colors(:assigned_to, c['user']['name'])}が#{time_ago_in_words(c['committed_on'])}に追加"
412
+ cs << "リビジョン: #{apply_colors((c['revision'] || "")[0..10], :cyan)} #{apply_fmt_colors(:assigned_to, (c['user'] || {})['name'])}が#{time_ago_in_words(c['committed_on'])}に追加"
413
413
  cs += c['comments'].split("\n").to_a
414
414
  cs << ""
415
415
  end
@@ -1,3 +1,3 @@
1
1
  module GitIssue
2
- VERSION = "0.8.7"
2
+ VERSION = "0.9.0"
3
3
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git-issue
3
3
  version: !ruby/object:Gem::Version
4
- hash: 49
4
+ hash: 59
5
5
  prerelease:
6
6
  segments:
7
7
  - 0
8
- - 8
9
- - 7
10
- version: 0.8.7
8
+ - 9
9
+ - 0
10
+ version: 0.9.0
11
11
  platform: ruby
12
12
  authors:
13
13
  - Tomohito Ozaki
@@ -15,7 +15,7 @@ autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
17
 
18
- date: 2012-10-25 00:00:00 +09:00
18
+ date: 2012-11-19 00:00:00 +09:00
19
19
  default_executable:
20
20
  dependencies:
21
21
  - !ruby/object:Gem::Dependency
@@ -83,6 +83,7 @@ files:
83
83
  - images/git-issue_screenshot-2.png
84
84
  - lib/git_issue.rb
85
85
  - lib/git_issue/base.rb
86
+ - lib/git_issue/bitbucket.rb
86
87
  - lib/git_issue/github.rb
87
88
  - lib/git_issue/redmine.rb
88
89
  - lib/git_issue/version.rb