git-issue 0.8.7 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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