git-issue 0.7.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.
- data/.document +5 -0
- data/LICENSE +20 -0
- data/README.markdown +115 -0
- data/Rakefile +50 -0
- data/VERSION +1 -0
- data/bin/git-issue +8 -0
- data/git-issue.gemspec +62 -0
- data/images/git-issue_screenshot-1.png +0 -0
- data/images/git-issue_screenshot-2.png +0 -0
- data/lib/git_issue.rb +96 -0
- data/lib/git_issue/base.rb +263 -0
- data/lib/git_issue/github.rb +362 -0
- data/lib/git_issue/redmine.rb +450 -0
- data/spec/git_issue/base_spec.rb +78 -0
- data/spec/git_issue/github_spec.rb +186 -0
- data/spec/git_issue/redmine_spec.rb +70 -0
- data/spec/git_issue_spec.rb +39 -0
- data/spec/spec.opts +5 -0
- data/spec/spec_helper.rb +9 -0
- metadata +113 -0
@@ -0,0 +1,362 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
|
3
|
+
module GitIssue
|
4
|
+
class GitIssue::Github < GitIssue::Base
|
5
|
+
def initialize(args, options = {})
|
6
|
+
super(args, options)
|
7
|
+
|
8
|
+
@apikey = options[:apikey] || configured_value('apikey')
|
9
|
+
@apikey = global_configured_value('github.token') if @apikey.blank?
|
10
|
+
configure_error('apikey', "git config issue.apikey some_api_key") if @apikey.blank?
|
11
|
+
|
12
|
+
@repo = options[:repo] || configured_value('repo')
|
13
|
+
configure_error('repo', "git config issue.repo git-issue") if @repo.blank?
|
14
|
+
|
15
|
+
@user = options[:user] || configured_value('user')
|
16
|
+
@user = global_configured_value('github.user') if @user.blank?
|
17
|
+
configure_error('user', "git config issue.user yuroyoro") if @user.blank?
|
18
|
+
end
|
19
|
+
|
20
|
+
def commands
|
21
|
+
cl = super
|
22
|
+
cl << GitIssue::Command.new(:mention, :men, 'create a comment to given issue')
|
23
|
+
end
|
24
|
+
|
25
|
+
def show(options = {})
|
26
|
+
ticket = options[:ticket_id]
|
27
|
+
raise 'ticket_id is required.' unless ticket
|
28
|
+
issue = fetch_issue(ticket, options)
|
29
|
+
|
30
|
+
if options[:oneline]
|
31
|
+
puts oneline_issue(issue, options)
|
32
|
+
else
|
33
|
+
comments = []
|
34
|
+
|
35
|
+
if issue['comments'].to_i > 0
|
36
|
+
comments = fetch_comments(ticket) unless options[:supperss_comments]
|
37
|
+
end
|
38
|
+
puts ""
|
39
|
+
puts format_issue(issue, comments, options)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def list(options = {})
|
44
|
+
state = options[:state] || "open"
|
45
|
+
|
46
|
+
query_names = [:state, :milestone, :assignee, :mentioned, :labels, :sort, :direction]
|
47
|
+
params = query_names.inject({}){|h,k| h[k] = options[k] if options[k];h}
|
48
|
+
params[:state] ||= "open"
|
49
|
+
|
50
|
+
url = to_url("repos",@user, @repo, 'issues')
|
51
|
+
|
52
|
+
issues = fetch_json(url, params)
|
53
|
+
issues = issues.sort_by{|i| i['number'].to_i} unless params[:sort] || params[:direction]
|
54
|
+
|
55
|
+
t_max = issues.map{|i| mlength(i['title'])}.max
|
56
|
+
l_max = issues.map{|i| mlength(i['labels'].map{|l| l['name']}.join(","))}.max
|
57
|
+
u_max = issues.map{|i| mlength(i['user']['login'])}.max
|
58
|
+
|
59
|
+
or_zero = lambda{|v| v.blank? ? "0" : v }
|
60
|
+
|
61
|
+
issues.each do |i|
|
62
|
+
puts sprintf("#%-4d %s %s %s %s c:%s v:%s p:%s %s %s",
|
63
|
+
i['number'].to_i,
|
64
|
+
i['state'],
|
65
|
+
mljust(i['title'], t_max),
|
66
|
+
mljust(i['user']['login'], u_max),
|
67
|
+
mljust(i['labels'].map{|l| l['name']}.join(','), l_max),
|
68
|
+
or_zero.call(i['comments']),
|
69
|
+
or_zero.call(i['votes']),
|
70
|
+
or_zero.call(i['position']),
|
71
|
+
to_date(i['created_at']),
|
72
|
+
to_date(i['updated_at'])
|
73
|
+
)
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
77
|
+
|
78
|
+
def mine(options = {})
|
79
|
+
list(options.merge(:assignee => @user))
|
80
|
+
end
|
81
|
+
|
82
|
+
def add(options = {})
|
83
|
+
property_names = [:title, :body, :assignee, :milestone, :labels]
|
84
|
+
|
85
|
+
json = build_issue_json(options, property_names)
|
86
|
+
url = to_url("repos", @user, @repo, 'issues')
|
87
|
+
|
88
|
+
issue = post_json(url, json, options)
|
89
|
+
puts "created issue #{oneline_issue(issue)}"
|
90
|
+
end
|
91
|
+
|
92
|
+
def update(options = {})
|
93
|
+
ticket = options[:ticket_id]
|
94
|
+
raise 'ticket_id is required.' unless ticket
|
95
|
+
|
96
|
+
property_names = [:title, :body, :assignee, :milestone, :labels, :state]
|
97
|
+
|
98
|
+
json = build_issue_json(options, property_names)
|
99
|
+
url = to_url("repos", @user, @repo, 'issues', ticket)
|
100
|
+
|
101
|
+
issue = post_json(url, json, options) # use POST instead of PATCH.
|
102
|
+
puts "updated issue #{oneline_issue(issue)}"
|
103
|
+
end
|
104
|
+
|
105
|
+
|
106
|
+
def mention(options = {})
|
107
|
+
ticket = options[:ticket_id]
|
108
|
+
raise 'ticket_id is required.' unless ticket
|
109
|
+
|
110
|
+
body = options[:body]
|
111
|
+
raise 'commnet body is required.' unless body
|
112
|
+
|
113
|
+
json = { :body => body }
|
114
|
+
url = to_url("repos", @user, @repo, 'issues', ticket, 'comments')
|
115
|
+
|
116
|
+
issue = post_json(url, json, options)
|
117
|
+
|
118
|
+
issue = fetch_issue(ticket)
|
119
|
+
puts "commented issue #{oneline_issue(issue)}"
|
120
|
+
end
|
121
|
+
|
122
|
+
def branch(options = {})
|
123
|
+
ticket = options[:ticket_id]
|
124
|
+
raise 'ticket_id is required.' unless ticket
|
125
|
+
|
126
|
+
branch_name = ticket_branch(ticket)
|
127
|
+
|
128
|
+
if options[:force]
|
129
|
+
system "git branch -D #{branch_name}" if options[:force]
|
130
|
+
system "git checkout -b #{branch_name}"
|
131
|
+
else
|
132
|
+
if %x(git branch -l | grep "#{branch_name}").strip.empty?
|
133
|
+
system "git checkout -b #{branch_name}"
|
134
|
+
else
|
135
|
+
system "git checkout #{branch_name}"
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
show(options)
|
140
|
+
end
|
141
|
+
|
142
|
+
private
|
143
|
+
|
144
|
+
ROOT = 'https://api.github.com/'
|
145
|
+
def to_url(*path_list)
|
146
|
+
URI.join(ROOT, path_list.join("/"))
|
147
|
+
end
|
148
|
+
|
149
|
+
def fetch_json(url, params = {})
|
150
|
+
url += "?" + params.map{|k,v| "#{k}=#{v}"}.join("&") unless params.empty?
|
151
|
+
|
152
|
+
if @debug
|
153
|
+
puts url
|
154
|
+
end
|
155
|
+
json = open(url, {"Authorizaion" => "#{@user}/token:#{@apikey}"}) {|io|
|
156
|
+
JSON.parse(io.read)
|
157
|
+
}
|
158
|
+
|
159
|
+
if @debug
|
160
|
+
puts '-' * 80
|
161
|
+
puts url
|
162
|
+
pp json
|
163
|
+
puts '-' * 80
|
164
|
+
end
|
165
|
+
|
166
|
+
json
|
167
|
+
end
|
168
|
+
|
169
|
+
def fetch_issue(ticket_id, params = {})
|
170
|
+
url = to_url("repos",@user, @repo, 'issues', ticket_id)
|
171
|
+
url += "?" + params.map{|k,v| "#{k}=#{v}"}.join("&") unless params.empty?
|
172
|
+
json = fetch_json(url)
|
173
|
+
|
174
|
+
issue = json['issue'] || json
|
175
|
+
raise "no such issue #{ticket} : #{base}" unless issue
|
176
|
+
|
177
|
+
issue
|
178
|
+
end
|
179
|
+
|
180
|
+
def fetch_comments(ticket_id)
|
181
|
+
url = to_url("repos",@user, @repo, 'issues', ticket_id, 'comments')
|
182
|
+
json = fetch_json(url) || []
|
183
|
+
end
|
184
|
+
|
185
|
+
def build_issue_json(options, property_names)
|
186
|
+
json = property_names.inject({}){|h,k| h[k] = options[k] if options[k]; h}
|
187
|
+
json[:labels] = json[:labels].split(",") if json[:labels]
|
188
|
+
json
|
189
|
+
end
|
190
|
+
|
191
|
+
def post_json(url, json, options, params = {})
|
192
|
+
response = send_json(url, json, options, params, :post)
|
193
|
+
json = JSON.parse(response.body)
|
194
|
+
|
195
|
+
raise error_message(json) unless response_success?(response)
|
196
|
+
json
|
197
|
+
end
|
198
|
+
|
199
|
+
def put_json(url, json, options, params = {})
|
200
|
+
response = send_json(url, json, options, params, :put)
|
201
|
+
json = JSON.parse(response.body)
|
202
|
+
|
203
|
+
raise error_message(json) unless response_success?(response)
|
204
|
+
json
|
205
|
+
end
|
206
|
+
|
207
|
+
def error_message(json)
|
208
|
+
msg = [json['message']]
|
209
|
+
msg += json['errors'].map(&:pretty_inspect) if json['errors']
|
210
|
+
msg.join("\n ")
|
211
|
+
end
|
212
|
+
|
213
|
+
def send_json(url, json, options, params = {}, method = :post)
|
214
|
+
url = "#{url}"
|
215
|
+
uri = URI.parse(url)
|
216
|
+
|
217
|
+
if @debug
|
218
|
+
puts '-' * 80
|
219
|
+
puts url
|
220
|
+
pp json
|
221
|
+
puts '-' * 80
|
222
|
+
end
|
223
|
+
|
224
|
+
https = Net::HTTP.new(uri.host, uri.port)
|
225
|
+
https.use_ssl = true
|
226
|
+
https.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
227
|
+
https.set_debug_output $stderr if @debug && https.respond_to?(:set_debug_output)
|
228
|
+
https.start{|http|
|
229
|
+
|
230
|
+
path = "#{uri.path}"
|
231
|
+
path += "?" + params.map{|k,v| "#{k}=#{v}"}.join("&") unless params.empty?
|
232
|
+
|
233
|
+
request = case method
|
234
|
+
when :post then Net::HTTP::Post.new(path)
|
235
|
+
when :put then Net::HTTP::Put.new(path)
|
236
|
+
else raise "unknown method #{method}"
|
237
|
+
end
|
238
|
+
|
239
|
+
# request["Authorizaion"] = "#{@user}/token: #{@apikey}"
|
240
|
+
#
|
241
|
+
# Github API v3 does'nt supports API token base authorization for now.
|
242
|
+
# For Authentication, this method use Basic Authorizaion instead token.
|
243
|
+
password = options[:password] || get_password(@user)
|
244
|
+
|
245
|
+
request.basic_auth @user, password
|
246
|
+
|
247
|
+
request.set_content_type("application/json")
|
248
|
+
request.body = json.to_json
|
249
|
+
|
250
|
+
response = http.request(request)
|
251
|
+
if @debug
|
252
|
+
puts "#{response.code}: #{response.msg}"
|
253
|
+
puts response.body
|
254
|
+
end
|
255
|
+
response
|
256
|
+
}
|
257
|
+
end
|
258
|
+
|
259
|
+
def get_password(user)
|
260
|
+
print "password(#{user}): "
|
261
|
+
system "stty -echo"
|
262
|
+
password = $stdin.gets.chop
|
263
|
+
system "stty echo"
|
264
|
+
password
|
265
|
+
end
|
266
|
+
|
267
|
+
def oneline_issue(issue, options = {})
|
268
|
+
issue_title(issue)
|
269
|
+
end
|
270
|
+
|
271
|
+
def format_issue(issue, comments, options)
|
272
|
+
msg = [""]
|
273
|
+
|
274
|
+
msg << issue_title(issue)
|
275
|
+
msg << "-" * 80
|
276
|
+
msg << issue_author(issue)
|
277
|
+
msg << ""
|
278
|
+
|
279
|
+
props = []
|
280
|
+
props << ['comments', issue['comments']]
|
281
|
+
props << ['votes', issue['votes']]
|
282
|
+
props << ['position', issue['position']]
|
283
|
+
props << ['milestone', issue['milestone']['title']] unless issue['milestone'].blank?
|
284
|
+
|
285
|
+
props.each_with_index do |p,n|
|
286
|
+
row = sprintf("%s : %s", mljust(p.first, 18), mljust(p.last.to_s, 24))
|
287
|
+
if n % 2 == 0
|
288
|
+
msg << row
|
289
|
+
else
|
290
|
+
msg[-1] = "#{msg.last} #{row}"
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
msg << sprintf("%s : %s", mljust('labels', 18), issue['labels'].map{|l| l['name']}.join(","))
|
295
|
+
msg << sprintf("%s : %s", mljust('html_url', 18), issue['html_url'])
|
296
|
+
msg << sprintf("%s : %s", mljust('updated_at', 18), Time.parse(issue['updated_at']))
|
297
|
+
|
298
|
+
# display description
|
299
|
+
msg << "-" * 80
|
300
|
+
msg << "#{issue['body']}"
|
301
|
+
msg << ""
|
302
|
+
|
303
|
+
# display comments
|
304
|
+
if comments && !comments.empty?
|
305
|
+
msg << "-" * 80
|
306
|
+
msg << ""
|
307
|
+
cmts = format_comments(comments)
|
308
|
+
msg += cmts.map{|s| " #{s}"}
|
309
|
+
end
|
310
|
+
|
311
|
+
msg.join("\n")
|
312
|
+
end
|
313
|
+
|
314
|
+
def issue_title(issue)
|
315
|
+
"[#{issue['state']}] ##{issue['number']} #{issue['title']}"
|
316
|
+
end
|
317
|
+
|
318
|
+
def issue_author(issue)
|
319
|
+
author = issue['user']['login']
|
320
|
+
created_at = issue['created_at']
|
321
|
+
|
322
|
+
msg = "#{author} opened this issue #{Time.parse(created_at)}"
|
323
|
+
msg
|
324
|
+
end
|
325
|
+
|
326
|
+
def format_comments(comments)
|
327
|
+
cmts = []
|
328
|
+
comments.sort_by{|c| c['created_at']}.each_with_index do |c,n|
|
329
|
+
cmts += format_comment(c,n)
|
330
|
+
end
|
331
|
+
cmts
|
332
|
+
end
|
333
|
+
|
334
|
+
def format_comment(c, n)
|
335
|
+
cmts = []
|
336
|
+
|
337
|
+
cmts << "##{n + 1} - #{c['user']['login']}が#{time_ago_in_words(c['created_at'])}に更新"
|
338
|
+
cmts << "-" * 78
|
339
|
+
cmts += c['body'].split("\n").to_a if c['body']
|
340
|
+
cmts << ""
|
341
|
+
end
|
342
|
+
|
343
|
+
def opt_parser
|
344
|
+
opts = super
|
345
|
+
opts.on("--supperss_comments", "-sc", "show issue journals"){|v| @options[:supperss_comments] = true}
|
346
|
+
opts.on("--title=VALUE", "Title of issue.Use the given value to create/update issue."){|v| @options[:title] = v}
|
347
|
+
opts.on("--body=VALUE", "Body content of issue.Use the given value to create/update issue."){|v| @options[:body] = v}
|
348
|
+
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}
|
349
|
+
opts.on("--milestone=VALUE", "Use the given value to create/update issue. or query of listing issues, (Integer Milestone number)"){|v| @options[:milestone] = v }
|
350
|
+
opts.on("--assignee=VALUE", "Use the given value to create/update issue. or query of listing issues, (String User login)"){|v| @options[:assignee] = v }
|
351
|
+
opts.on("--mentioned=VALUE", "Query of listing issues, (String User login)"){|v| @options[:mentioned] = v }
|
352
|
+
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 }
|
353
|
+
opts.on("--sort=VALUE", "Query of listing issues, (created, updated, comments, default: created)"){|v| @options[:sort] = v }
|
354
|
+
opts.on("--direction=VALUE", "Query of listing issues, (asc or desc, default: desc.)"){|v| @options[:direction] = v }
|
355
|
+
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 }
|
356
|
+
|
357
|
+
opts.on("--password=VALUE", "For Authorizaion of create/update issue. Github API v3 does'nt supports API token base authorization for now. then, use Basic Authorizaion instead token." ){|v| @options[:password]}
|
358
|
+
opts
|
359
|
+
end
|
360
|
+
|
361
|
+
end
|
362
|
+
end
|
@@ -0,0 +1,450 @@
|
|
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('apikey')
|
10
|
+
configure_error('apikey', "git config issue.apikey some_api_key") if @apikey.blank?
|
11
|
+
|
12
|
+
@url = options[:url] || configured_value('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('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, :prj, '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 list(options = {})
|
41
|
+
url = to_url('issues')
|
42
|
+
params = {"limit" => options[:max_count] || 100 }
|
43
|
+
params.merge!("assigned_to_id" => "me") if options[:mine]
|
44
|
+
params.merge!(Hash[*(options[:query].split("&").map{|s| s.split("=") }.flatten)]) if options[:query]
|
45
|
+
|
46
|
+
json = fetch_json(url, params)
|
47
|
+
|
48
|
+
output_issues(json['issues'])
|
49
|
+
end
|
50
|
+
|
51
|
+
def mine(options = {})
|
52
|
+
list(options.merge(:mine => true))
|
53
|
+
end
|
54
|
+
|
55
|
+
def commit(options = {})
|
56
|
+
ticket = options[:ticket_id]
|
57
|
+
raise 'ticket_id is required.' unless ticket
|
58
|
+
|
59
|
+
issue = fetch_issue(ticket)
|
60
|
+
|
61
|
+
f = File.open("./commit_msg_#{ticket}", 'w')
|
62
|
+
f.write("refs ##{ticket} #{issue['subject']}")
|
63
|
+
f.close
|
64
|
+
|
65
|
+
cmd = "git commit --edit #{options[:all] ? '-a' : ''} --file #{f.path}"
|
66
|
+
system(cmd)
|
67
|
+
|
68
|
+
File.unlink f.path if f.path
|
69
|
+
end
|
70
|
+
|
71
|
+
def add(options = {})
|
72
|
+
property_names = [:project_id, :subject, :description, :done_ratio, :status_id, :priority_id, :tracker_id, :assigned_to_id, :category_id, :fixed_version_id, :notes]
|
73
|
+
|
74
|
+
json = build_issue_json(options, property_names)
|
75
|
+
json["issue"][:project_id] ||= Helper.configured_value('project')
|
76
|
+
|
77
|
+
url = to_url('issues')
|
78
|
+
|
79
|
+
json = post_json(url, json, options)
|
80
|
+
puts "created issue #{oneline_issue(json["issue"])}"
|
81
|
+
end
|
82
|
+
|
83
|
+
def update(options = {})
|
84
|
+
ticket = options[:ticket_id]
|
85
|
+
raise 'ticket_id is required.' unless ticket
|
86
|
+
|
87
|
+
property_names = [:subject, :done_ratio, :status_id, :priority_id, :tracker_id, :assigned_to_id, :category_id, :fixed_version_id, :notes]
|
88
|
+
json = build_issue_json(options, property_names)
|
89
|
+
|
90
|
+
url = to_url('issues', ticket)
|
91
|
+
put_json(url, json, options)
|
92
|
+
issue = fetch_issue(ticket)
|
93
|
+
puts "updated issue #{oneline_issue(issue)}"
|
94
|
+
end
|
95
|
+
|
96
|
+
def branch(options = {})
|
97
|
+
ticket = options[:ticket_id]
|
98
|
+
raise 'ticket_id is required.' unless ticket
|
99
|
+
|
100
|
+
branch_name = ticket_branch(ticket)
|
101
|
+
|
102
|
+
if options[:force]
|
103
|
+
system "git branch -D #{branch_name}" if options[:force]
|
104
|
+
system "git checkout -b #{branch_name}"
|
105
|
+
else
|
106
|
+
if %x(git branch -l | grep "#{branch_name}").strip.empty?
|
107
|
+
system "git checkout -b #{branch_name}"
|
108
|
+
else
|
109
|
+
system "git checkout #{branch_name}"
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
show(options)
|
114
|
+
end
|
115
|
+
|
116
|
+
def local(option = {})
|
117
|
+
|
118
|
+
brances = %x(git branch).split(/\n/).map{|b| b.scan(/.*ticket\D*(\d+).*/).first }.reject{|r| r.nil?}.map{|r| r.first }
|
119
|
+
|
120
|
+
issues = brances.map{|ticket_id| fetch_issue(ticket_id) }
|
121
|
+
|
122
|
+
output_issues(issues)
|
123
|
+
end
|
124
|
+
|
125
|
+
def project(options = {})
|
126
|
+
project_id = Helper.configured_value('project')
|
127
|
+
project_id = options[:ticket_id] if project_id.blank?
|
128
|
+
raise 'project_id is required.' unless project_id
|
129
|
+
list(options.merge(:query => "project_id=#{project_id}"))
|
130
|
+
end
|
131
|
+
|
132
|
+
private
|
133
|
+
|
134
|
+
def to_url(*path_list)
|
135
|
+
URI.join(@url, path_list.join("/"))
|
136
|
+
end
|
137
|
+
|
138
|
+
def fetch_json(url, params = {})
|
139
|
+
url = "#{url}.json?key=#{@apikey}"
|
140
|
+
url += "&" + params.map{|k,v| "#{k}=#{v}"}.join("&") unless params.empty?
|
141
|
+
json = open(url) {|io| JSON.parse(io.read) }
|
142
|
+
|
143
|
+
if @debug
|
144
|
+
puts '-' * 80
|
145
|
+
puts url
|
146
|
+
pp json
|
147
|
+
puts '-' * 80
|
148
|
+
end
|
149
|
+
|
150
|
+
json
|
151
|
+
end
|
152
|
+
|
153
|
+
def fetch_issue(ticket_id, options = {})
|
154
|
+
url = to_url("issues", ticket_id)
|
155
|
+
includes = issue_includes(options)
|
156
|
+
params = includes.empty? ? {} : {"include" => includes }
|
157
|
+
json = fetch_json(url, params)
|
158
|
+
|
159
|
+
issue = json['issue'] || json
|
160
|
+
raise "no such issue #{ticket} : #{base}" unless issue
|
161
|
+
|
162
|
+
issue
|
163
|
+
end
|
164
|
+
|
165
|
+
def post_json(url, json, options, params = {})
|
166
|
+
response = send_json(url, json, options, params, :post)
|
167
|
+
JSON.parse(response.body) if response_success?(response)
|
168
|
+
end
|
169
|
+
|
170
|
+
def put_json(url, json, options, params = {})
|
171
|
+
send_json(url, json, options, params, :put)
|
172
|
+
end
|
173
|
+
|
174
|
+
def send_json(url, json, options, params = {}, method = :post)
|
175
|
+
url = "#{url}.json"
|
176
|
+
uri = URI.parse(url)
|
177
|
+
|
178
|
+
if @debug
|
179
|
+
puts '-' * 80
|
180
|
+
puts url
|
181
|
+
pp json
|
182
|
+
puts '-' * 80
|
183
|
+
end
|
184
|
+
|
185
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
186
|
+
http.set_debug_output $stderr if @debug && http.respond_to?(:set_debug_output)
|
187
|
+
http.start(uri.host, uri.port){|http|
|
188
|
+
|
189
|
+
path = "#{uri.path}?key=#{@apikey}"
|
190
|
+
path += "&" + params.map{|k,v| "#{k}=#{v}"}.join("&") unless params.empty?
|
191
|
+
|
192
|
+
request = case method
|
193
|
+
when :post then Net::HTTP::Post.new(path)
|
194
|
+
when :put then Net::HTTP::Put.new(path)
|
195
|
+
else raise "unknown method #{method}"
|
196
|
+
end
|
197
|
+
|
198
|
+
request.set_content_type("application/json")
|
199
|
+
request.body = json.to_json
|
200
|
+
|
201
|
+
response = http.request(request)
|
202
|
+
if @debug
|
203
|
+
puts "#{response.code}: #{response.msg}"
|
204
|
+
puts response.body
|
205
|
+
end
|
206
|
+
response
|
207
|
+
}
|
208
|
+
end
|
209
|
+
|
210
|
+
def issue_includes(options)
|
211
|
+
includes = []
|
212
|
+
includes << "journals" if ! options[:supperss_journals] || options[:verbose]
|
213
|
+
includes << "changesets" if ! options[:supperss_changesets] || options[:verbose]
|
214
|
+
includes << "relations" if ! options[:supperss_relations] || options[:verbose]
|
215
|
+
includes.join(",")
|
216
|
+
end
|
217
|
+
|
218
|
+
|
219
|
+
def issue_title(issue)
|
220
|
+
"[#{issue['project']['name']}] #{issue['tracker']['name']} ##{issue['id']} #{issue['subject']}"
|
221
|
+
end
|
222
|
+
|
223
|
+
def issue_author(issue)
|
224
|
+
author = issue['author']['name']
|
225
|
+
created_on = issue['created_on']
|
226
|
+
updated_on = issue['updated_on']
|
227
|
+
|
228
|
+
msg = "#{author}が#{time_ago_in_words(created_on)}に追加"
|
229
|
+
msg += ", #{time_ago_in_words(updated_on)}に更新" unless created_on == updated_on
|
230
|
+
msg
|
231
|
+
end
|
232
|
+
|
233
|
+
PROPERTY_TITLES= {"status"=>"ステータス", "start_date"=>"開始日", "category"=>"カテゴリ", "assigned_to"=>"担当者", "estimated_hours"=>"予定工数", "priority"=>"優先度", "fixed_version"=>"対象バージョン", "due_date"=>"期日", "done_ratio"=>"進捗"}
|
234
|
+
|
235
|
+
def property_title(name)
|
236
|
+
PROPERTY_TITLES[name] || name
|
237
|
+
end
|
238
|
+
|
239
|
+
def oneline_issue(issue, options = {})
|
240
|
+
"##{issue['id']} #{issue['subject']}"
|
241
|
+
end
|
242
|
+
|
243
|
+
def format_issue(issue, options)
|
244
|
+
msg = [""]
|
245
|
+
|
246
|
+
msg << issue_title(issue)
|
247
|
+
msg << "-" * 80
|
248
|
+
msg << issue_author(issue)
|
249
|
+
msg << ""
|
250
|
+
|
251
|
+
props = []
|
252
|
+
add_prop = Proc.new{|name|
|
253
|
+
title = property_title(name)
|
254
|
+
value = issue[name] || ""
|
255
|
+
props << [title, value]
|
256
|
+
}
|
257
|
+
add_prop_name = Proc.new{|name|
|
258
|
+
title = property_title(name)
|
259
|
+
value = ''
|
260
|
+
value = issue[name]['name'] if issue[name] && issue[name]['name']
|
261
|
+
props << [title, value]
|
262
|
+
}
|
263
|
+
|
264
|
+
add_prop_name.call('status')
|
265
|
+
add_prop.call("start_date")
|
266
|
+
add_prop_name.call('priority')
|
267
|
+
add_prop.call('due_date')
|
268
|
+
add_prop_name.call('assigned_to')
|
269
|
+
add_prop.call('done_ratio')
|
270
|
+
add_prop_name.call('category')
|
271
|
+
add_prop.call('estimated_hours')
|
272
|
+
add_prop_name.call('fixed_version')
|
273
|
+
|
274
|
+
# acd custom_fields if it have value.
|
275
|
+
if custom_fields = issue[:custom_fields] && custom_fields.reject{|cf| cf['value'].nil? || cf['value'].empty? }
|
276
|
+
custom_fields.each do |cf|
|
277
|
+
props << [cf['name'], cf['value']]
|
278
|
+
end
|
279
|
+
end
|
280
|
+
|
281
|
+
props.each_with_index do |p,n|
|
282
|
+
row = sprintf("%s : %s", mljust(p.first, 18), mljust(p.last.to_s, 24))
|
283
|
+
if n % 2 == 0
|
284
|
+
msg << row
|
285
|
+
else
|
286
|
+
msg[-1] = "#{msg.last} #{row}"
|
287
|
+
end
|
288
|
+
end
|
289
|
+
|
290
|
+
# display relations tickets
|
291
|
+
if ! options[:supperss_relations] || options[:verbose]
|
292
|
+
relations = issue['relations']
|
293
|
+
if relations && !relations.empty?
|
294
|
+
msg << "関連するチケット"
|
295
|
+
msg << "-" * 80
|
296
|
+
rels = format_relations(relations)
|
297
|
+
msg += rels
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
# display description
|
302
|
+
msg << "-" * 80
|
303
|
+
msg << "#{issue['description']}"
|
304
|
+
msg << ""
|
305
|
+
|
306
|
+
# display journals
|
307
|
+
if ! options[:supperss_journals] || options[:verbose]
|
308
|
+
journals = issue['journals']
|
309
|
+
if journals && !journals.empty?
|
310
|
+
msg << "履歴"
|
311
|
+
msg << "-" * 80
|
312
|
+
msg << ""
|
313
|
+
jnl = format_jounals(journals)
|
314
|
+
msg += jnl.map{|s| " #{s}"}
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
# display changesets
|
319
|
+
if ! options[:supperss_changesets] || options[:verbose]
|
320
|
+
changesets = issue['changesets']
|
321
|
+
if changesets && !changesets.empty?
|
322
|
+
msg << "関係しているリビジョン"
|
323
|
+
msg << "-" * 80
|
324
|
+
msg << ""
|
325
|
+
cs = format_changesets(changesets)
|
326
|
+
msg += cs.map{|s| " #{s}"}
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
330
|
+
msg.join("\n")
|
331
|
+
|
332
|
+
end
|
333
|
+
|
334
|
+
def format_jounals(journals)
|
335
|
+
jnl = []
|
336
|
+
journals.sort_by{|j| j['created_on']}.each_with_index do |j,n|
|
337
|
+
jnl += format_jounal(j,n)
|
338
|
+
end
|
339
|
+
jnl
|
340
|
+
end
|
341
|
+
|
342
|
+
def format_jounal(j, n)
|
343
|
+
jnl = []
|
344
|
+
|
345
|
+
jnl << "##{n + 1} - #{j['user']['name']}が#{time_ago_in_words(j['created_on'])}に更新"
|
346
|
+
jnl << "-" * 78
|
347
|
+
j['details'].each do |d|
|
348
|
+
log = "#{property_title(d['name'])}を"
|
349
|
+
if d['old_value']
|
350
|
+
log += "\"#{d['old_value']}\"から\"#{d['new_value']}\"へ変更"
|
351
|
+
else
|
352
|
+
log += "\"#{d['new_value']}\"にセット"
|
353
|
+
end
|
354
|
+
jnl << log
|
355
|
+
end
|
356
|
+
jnl += j['notes'].split("\n").to_a if j['notes']
|
357
|
+
jnl << ""
|
358
|
+
end
|
359
|
+
|
360
|
+
def format_changesets(changesets)
|
361
|
+
cs = []
|
362
|
+
changesets.sort_by{|c| c['committed_on'] }.each do |c|
|
363
|
+
cs << "リビジョン: #{c['revision'][0..10]} #{c['user']['name']}が#{time_ago_in_words(c['committed_on'])}に追加"
|
364
|
+
cs += c['comments'].split("\n").to_a
|
365
|
+
cs << ""
|
366
|
+
end
|
367
|
+
cs
|
368
|
+
end
|
369
|
+
|
370
|
+
def format_relations(relations)
|
371
|
+
relations.map{|r|
|
372
|
+
issue = fetch_issue(r['issue_id'])
|
373
|
+
"#{relations_label(r['relation_type'])} #{issue_title(issue)} #{issue['status']['name']} #{issue['start_date']} "
|
374
|
+
}
|
375
|
+
end
|
376
|
+
|
377
|
+
def format_issue_tables(issues_json)
|
378
|
+
issues = issues_json.map{ |issue|
|
379
|
+
project = issue['project']['name'] rescue ""
|
380
|
+
tracker = issue['tracker']['name'] rescue ""
|
381
|
+
status = issue['status']['name'] rescue ""
|
382
|
+
assigned_to = issue['assigned_to']['name'] rescue ""
|
383
|
+
[issue['id'], project, tracker, status, issue['subject'], assigned_to, issue['updated_on']]
|
384
|
+
}
|
385
|
+
|
386
|
+
p_max = issues.map{|i| mlength(i[1])}.max
|
387
|
+
t_max = issues.map{|i| mlength(i[2])}.max
|
388
|
+
s_max = issues.map{|i| mlength(i[3])}.max
|
389
|
+
a_max = issues.map{|i| mlength(i[5])}.max
|
390
|
+
|
391
|
+
issues.map {|i|
|
392
|
+
sprintf("#%-4d %s %s %s %s %s %s", i[0].to_i, mljust(i[1], p_max), mljust(i[2], t_max), mljust(i[3], s_max), mljust(i[4], 80), mljust(i[5], a_max), to_date(i[6]))
|
393
|
+
}
|
394
|
+
end
|
395
|
+
|
396
|
+
def output_issues(issues)
|
397
|
+
if options[:raw_id]
|
398
|
+
issues.each do |i|
|
399
|
+
puts i['id']
|
400
|
+
end
|
401
|
+
else
|
402
|
+
format_issue_tables(issues).each do |i|
|
403
|
+
puts i
|
404
|
+
end
|
405
|
+
end
|
406
|
+
end
|
407
|
+
|
408
|
+
RELATIONS_LABEL = { "relates" => "関係している", "duplicates" => "重複している",
|
409
|
+
"duplicated" => "重複されている", "blocks" => "ブロックしている",
|
410
|
+
"blocked" => "ブロックされている", "precedes" => "先行する", "follows" => "後続する",
|
411
|
+
}
|
412
|
+
|
413
|
+
def relations_label(rel)
|
414
|
+
RELATIONS_LABEL[rel] || rel
|
415
|
+
end
|
416
|
+
|
417
|
+
def build_issue_json(options, property_names)
|
418
|
+
json = {"issue" => property_names.inject({}){|h,k| h[k] = options[k] if options[k]; h} }
|
419
|
+
|
420
|
+
if custom_fields = options[:custom_fields]
|
421
|
+
json['custom_fields'] = custom_fields.split(",").map{|s| k,*v = s.split(":");{'id' => k.to_i, 'value' => v.join }}
|
422
|
+
end
|
423
|
+
json
|
424
|
+
end
|
425
|
+
|
426
|
+
def opt_parser
|
427
|
+
opts = super
|
428
|
+
opts.on("--supperss_journals", "-j", "do not show issue journals"){|v| @options[:supperss_journals] = true}
|
429
|
+
opts.on("--supperss_relations", "-r", "do not show issue relations tickets"){|v| @options[:supperss_relations] = true}
|
430
|
+
opts.on("--supperss_changesets", "-c", "do not show issue changesets"){|v| @options[:supperss_changesets] = true}
|
431
|
+
opts.on("--query=VALUE",'-q=VALUE', "filter query of listing tickets") {|v| @options[:query] = v}
|
432
|
+
|
433
|
+
opts.on("--project_id=VALUE", "use the given value to create subject"){|v| @options[:project_id] = v}
|
434
|
+
opts.on("--description=VALUE", "use the given value to create subject"){|v| @options[:description] = v}
|
435
|
+
opts.on("--subject=VALUE", "use the given value to create/update subject"){|v| @options[:subject] = v}
|
436
|
+
opts.on("--ratio=VALUE", "use the given value to create/update done-ratio(%)"){|v| @options[:done_ratio] = v.to_i}
|
437
|
+
opts.on("--status=VALUE", "use the given value to create/update issue statues id"){|v| @options[:status_id] = v }
|
438
|
+
opts.on("--priority=VALUE", "use the given value to create/update issue priority id"){|v| @options[:priority_id] = v }
|
439
|
+
opts.on("--tracker=VALUE", "use the given value to create/update tracker id"){|v| @options[:tracker_id] = v }
|
440
|
+
opts.on("--assigned_to_id=VALUE", "use the given value to create/update assigned_to id"){|v| @options[:assigned_to_id] = v }
|
441
|
+
opts.on("--category=VALUE", "use the given value to create/update category id"){|v| @options[:category_id] = v }
|
442
|
+
opts.on("--fixed_version=VALUE", "use the given value to create/update fixed_version id"){|v| @options[:fixed_version_id] = v }
|
443
|
+
opts.on("--custom_fields=VALUE", "value should be specifies '<custom_fields_id1>:<value2>,<custom_fields_id2>:<value2>, ...' "){|v| @options[:custom_fields] = v }
|
444
|
+
|
445
|
+
opts.on("--notes=VALUE", "add notes to issue"){|v| @options[:notes] = v}
|
446
|
+
|
447
|
+
opts
|
448
|
+
end
|
449
|
+
end
|
450
|
+
end
|