redmine-issue 0.0.1
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.
- checksums.yaml +7 -0
- data/bin/redmine-issue +22 -0
- data/lib/redmine_issue.rb +459 -0
- metadata +46 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 59220386a7fb9db3431ad74f2af2813ddf8a82ee
|
4
|
+
data.tar.gz: 25ffe1680e517b3c0e79b5dba97d3c3bd2b9379f
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: ed2be50348029ab03e6feeccfe449f2cc67801a8bc572c958e00f3abd8441fa9914f3b1421b2de7900cab48343ab506c8f7269c83ff85d6c3d20a23b1eb23974
|
7
|
+
data.tar.gz: debbb423be9b4edb82aa2e0bbb3cf3c221de252d1106e821db762fd15184423210b17a2ee64adde8e63e0fa558777336bc4b0e6f71f727db9c215c0b976cfc3b
|
data/bin/redmine-issue
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'redmine_issue'
|
4
|
+
|
5
|
+
verbose = false
|
6
|
+
begin
|
7
|
+
args = ARGV.clone()
|
8
|
+
verbose = !args.delete('--verbose').nil?()
|
9
|
+
result =
|
10
|
+
RedmineIssue.
|
11
|
+
method(RedmineIssue._get_arguments_key(args[0])).
|
12
|
+
call(*args[1..-1])
|
13
|
+
|
14
|
+
puts(result)
|
15
|
+
rescue => error
|
16
|
+
if verbose
|
17
|
+
raise error
|
18
|
+
end
|
19
|
+
|
20
|
+
puts "ERROR: #{error.message} (#{error.class})"
|
21
|
+
exit(1)
|
22
|
+
end
|
@@ -0,0 +1,459 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'shellwords'
|
3
|
+
require 'terminal-table'
|
4
|
+
require 'cgi'
|
5
|
+
require 'date'
|
6
|
+
require 'net/http'
|
7
|
+
require 'fileutils'
|
8
|
+
|
9
|
+
module RedmineIssue
|
10
|
+
|
11
|
+
STATUS_INPROGRESS = 2
|
12
|
+
STATUS_COMPLETED = 3
|
13
|
+
STATUS_FEEDBACK = 4
|
14
|
+
STATUS_CLOSED = 5
|
15
|
+
|
16
|
+
CONFIG_PATH = '~/.config/redmine-issue/config'
|
17
|
+
CONFIG = JSON.parse(File.read(File.expand_path(CONFIG_PATH)))
|
18
|
+
|
19
|
+
class Undefined; end
|
20
|
+
|
21
|
+
def self.config(key, value = Undefined.new())
|
22
|
+
if value.instance_of?(Undefined) && !CONFIG.has_key?(key)
|
23
|
+
raise "#{key} should be set in config"
|
24
|
+
end
|
25
|
+
|
26
|
+
return CONFIG.fetch(key, value)
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.set_config(key, value)
|
30
|
+
if value.nil?()
|
31
|
+
CONFIG.delete(key)
|
32
|
+
else
|
33
|
+
CONFIG[key] = value
|
34
|
+
end
|
35
|
+
|
36
|
+
FileUtils.mkdir_p(File.dirname(CONFIG_PATH))
|
37
|
+
new_config = JSON.pretty_generate(CONFIG)
|
38
|
+
File.write(File.expand_path(CONFIG_PATH), new_config)
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.request(method, get = {}, data = nil, options = {method: 'POST'})
|
42
|
+
query =
|
43
|
+
get.
|
44
|
+
collect() { |key, value |
|
45
|
+
CGI.escape(key.to_s()) + '=' + CGI.escape(value.to_s())
|
46
|
+
}.
|
47
|
+
join('&')
|
48
|
+
|
49
|
+
uri = URI(config('address') + '/' + method + '.json?' + query)
|
50
|
+
|
51
|
+
if data.nil?()
|
52
|
+
request = Net::HTTP::Get.new(uri)
|
53
|
+
else
|
54
|
+
if options[:method] == 'POST'
|
55
|
+
request = Net::HTTP::Post.new(uri)
|
56
|
+
elsif options[:method] == 'PUT'
|
57
|
+
request = Net::HTTP::Put.new(uri)
|
58
|
+
else
|
59
|
+
raise 'Wrong request method'
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
request['X-Redmine-API-Key'] = config('secret')
|
64
|
+
request['Content-Type'] = 'application/json'
|
65
|
+
|
66
|
+
if !data.nil?()
|
67
|
+
request.body = JSON.generate(data)
|
68
|
+
end
|
69
|
+
|
70
|
+
result = Net::HTTP.start(uri.hostname, uri.port) { |http|
|
71
|
+
http.request(request)
|
72
|
+
}
|
73
|
+
|
74
|
+
if result.is_a?(Net::HTTPOK) && result.body == ''
|
75
|
+
return true
|
76
|
+
end
|
77
|
+
|
78
|
+
begin
|
79
|
+
return JSON.parse(result.body)
|
80
|
+
rescue => error
|
81
|
+
raise "Failed to parse json: #{result} (#{error})"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def self._split_text(text, length = 60)
|
86
|
+
result =
|
87
|
+
0.
|
88
|
+
upto((text.length / length.to_f()).floor()).
|
89
|
+
collect() { |index|
|
90
|
+
text[index * length, length]
|
91
|
+
}.
|
92
|
+
join("\n")
|
93
|
+
|
94
|
+
return result
|
95
|
+
end
|
96
|
+
|
97
|
+
def self._expand_issue_id(id)
|
98
|
+
if id.nil?()
|
99
|
+
return config('current')['id']
|
100
|
+
end
|
101
|
+
|
102
|
+
if id.start_with?('#')
|
103
|
+
return id[1..-1].to_i()
|
104
|
+
end
|
105
|
+
|
106
|
+
found = config('last_listed_issues', []).select() { |found_id|
|
107
|
+
found_id.to_s().end_with?(id.to_s())
|
108
|
+
}
|
109
|
+
|
110
|
+
if found.length > 1
|
111
|
+
raise "Issue #id is ambiguos; specify more digits"
|
112
|
+
end
|
113
|
+
|
114
|
+
if found.length == 0
|
115
|
+
return id
|
116
|
+
end
|
117
|
+
|
118
|
+
return found.first().to_i()
|
119
|
+
end
|
120
|
+
|
121
|
+
def self._get_current_elapsed_hours()
|
122
|
+
current = config('current', nil)
|
123
|
+
if current.nil?() || current['id'].nil?()
|
124
|
+
raise 'No active issue in progress'
|
125
|
+
end
|
126
|
+
|
127
|
+
seconds =
|
128
|
+
DateTime.now().to_time() -
|
129
|
+
DateTime.parse(current['started']).to_time()
|
130
|
+
|
131
|
+
hours = (seconds / 3600.0).round(2).to_s()
|
132
|
+
|
133
|
+
return hours
|
134
|
+
end
|
135
|
+
|
136
|
+
def self._get_arguments_key(key)
|
137
|
+
key = key.gsub('_', '{{__DASH__}}')
|
138
|
+
key = key.gsub('-', '_')
|
139
|
+
key = key.gsub('{{__DASH__}}', '-')
|
140
|
+
return key
|
141
|
+
end
|
142
|
+
|
143
|
+
def self._get_console_option(args, option)
|
144
|
+
if option.instance_of?(::Array)
|
145
|
+
option.each() { |option_item|
|
146
|
+
result = _get_console_option(args, option_item)
|
147
|
+
if !result.nil?()
|
148
|
+
return result
|
149
|
+
end
|
150
|
+
}
|
151
|
+
|
152
|
+
return nil
|
153
|
+
end
|
154
|
+
|
155
|
+
if option.length == 1
|
156
|
+
index = args.index('-' + _get_arguments_key(option))
|
157
|
+
if !index.nil?()
|
158
|
+
return args[index + 1]
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
if args.index('--no-' + _get_arguments_key(option))
|
163
|
+
return false
|
164
|
+
end
|
165
|
+
|
166
|
+
index = args.index('--' + _get_arguments_key(option))
|
167
|
+
if index.nil?()
|
168
|
+
return nil
|
169
|
+
end
|
170
|
+
|
171
|
+
if args[index + 1].nil?()
|
172
|
+
return true
|
173
|
+
end
|
174
|
+
|
175
|
+
return args[index + 1]
|
176
|
+
end
|
177
|
+
|
178
|
+
def self._get_arguments_hash(args)
|
179
|
+
result = {}
|
180
|
+
args.each_with_index() { |value, index|
|
181
|
+
if value.start_with?('--no-')
|
182
|
+
result[_get_arguments_key(value[5..-1])] = true
|
183
|
+
next
|
184
|
+
end
|
185
|
+
|
186
|
+
if value.start_with?('--')
|
187
|
+
if args[index + 1].nil?() || args[index + 1].start_with?('--')
|
188
|
+
result[_get_arguments_key(value[2..-1])] = true
|
189
|
+
next
|
190
|
+
end
|
191
|
+
|
192
|
+
result[_get_arguments_key(value[2..-1])] = args[index + 1]
|
193
|
+
end
|
194
|
+
}
|
195
|
+
|
196
|
+
return result
|
197
|
+
end
|
198
|
+
|
199
|
+
def self._get_user_id()
|
200
|
+
user_id = config('user_id', nil)
|
201
|
+
if !user_id.nil?()
|
202
|
+
return user_id
|
203
|
+
end
|
204
|
+
|
205
|
+
user_id = request('users/current')['user']['id']
|
206
|
+
set_config('user_id', user_id)
|
207
|
+
return user_id
|
208
|
+
end
|
209
|
+
|
210
|
+
def self._get_responsible_user_id(issue)
|
211
|
+
if issue.instance_of?(::Fixnum) || issue.instance_of?(::String)
|
212
|
+
issue = request("issues/#{issue}", {include: 'journals'})['issue']
|
213
|
+
end
|
214
|
+
|
215
|
+
if !issue.has_key?('journals')
|
216
|
+
raise "Issue should have journals to find out adminitrator id"
|
217
|
+
end
|
218
|
+
|
219
|
+
user_id = _get_user_id()
|
220
|
+
issue['journals'].reverse().each() { |note|
|
221
|
+
if note['user']['id'] != user_id
|
222
|
+
return note['user']['id']
|
223
|
+
end
|
224
|
+
}
|
225
|
+
|
226
|
+
return issue['author']['id']
|
227
|
+
end
|
228
|
+
|
229
|
+
def self._pause_current(id)
|
230
|
+
current = config('current', nil)
|
231
|
+
if !current.nil?() && current['id'].to_i() == id.to_i()
|
232
|
+
pause()
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
def self.list(*args)
|
237
|
+
params = _get_arguments_hash(args)
|
238
|
+
|
239
|
+
params['assigned_to_id'] ||= 'me'
|
240
|
+
params['status_id'] ||= 'open'
|
241
|
+
params['sort'] ||= 'priority:desc,project'
|
242
|
+
issues = request('issues', params).fetch('issues')
|
243
|
+
|
244
|
+
ids = issues.collect() { |issue| issue['id'] }
|
245
|
+
|
246
|
+
set_config('last_listed_issues', ids)
|
247
|
+
|
248
|
+
info = [['id', 'priority', 'subject', 'info']]
|
249
|
+
info.push(:separator)
|
250
|
+
info += issues.collect() { |issue|
|
251
|
+
next [
|
252
|
+
'#' + issue['id'].to_s(),
|
253
|
+
[
|
254
|
+
issue.fetch('priority', {})['name'],
|
255
|
+
issue.fetch('status', {})['name'],
|
256
|
+
" ",
|
257
|
+
].join("\n"),
|
258
|
+
_split_text(issue['subject']),
|
259
|
+
[
|
260
|
+
issue.fetch('project', {})['name'],
|
261
|
+
issue.fetch('author', {})['name'],
|
262
|
+
].join("\n")
|
263
|
+
]
|
264
|
+
}
|
265
|
+
|
266
|
+
info.pop()
|
267
|
+
|
268
|
+
return Terminal::Table.new(rows: info)
|
269
|
+
end
|
270
|
+
|
271
|
+
def self.start(id)
|
272
|
+
current = config('current', nil)
|
273
|
+
if !current.nil?()
|
274
|
+
raise "Can not start; issue #{current['id']} is already " +
|
275
|
+
'in progress'
|
276
|
+
end
|
277
|
+
|
278
|
+
real_id = _expand_issue_id(id)
|
279
|
+
|
280
|
+
issue = {
|
281
|
+
'issue' => {'status_id' => STATUS_INPROGRESS}
|
282
|
+
}
|
283
|
+
|
284
|
+
result = request("issues/#{real_id}", {}, issue, {method: 'PUT'})
|
285
|
+
set_config('current', {'id' => real_id, 'started' => DateTime.now().to_s()})
|
286
|
+
return result
|
287
|
+
end
|
288
|
+
|
289
|
+
def self.pause()
|
290
|
+
current = config('current', nil)
|
291
|
+
if current.nil?()
|
292
|
+
raise "No active issue in progress"
|
293
|
+
end
|
294
|
+
|
295
|
+
time_entry = {
|
296
|
+
'issue_id' => current['id'],
|
297
|
+
'hours' => _get_current_elapsed_hours()
|
298
|
+
}
|
299
|
+
|
300
|
+
result = request('time_entries', {}, {'time_entry' => time_entry})
|
301
|
+
set_config('current', nil)
|
302
|
+
return result
|
303
|
+
end
|
304
|
+
|
305
|
+
def self.cancel()
|
306
|
+
current = config('current', nil)
|
307
|
+
if current.nil?()
|
308
|
+
raise "No active issue in progress"
|
309
|
+
end
|
310
|
+
|
311
|
+
set_config('current', nil)
|
312
|
+
return current
|
313
|
+
end
|
314
|
+
|
315
|
+
def self.status()
|
316
|
+
current = config('current', nil)
|
317
|
+
if current.nil?()
|
318
|
+
raise "No active issue in progress"
|
319
|
+
end
|
320
|
+
|
321
|
+
return "Issue: ##{current['id']}\nTime: #{_get_current_elapsed_hours()}"
|
322
|
+
end
|
323
|
+
|
324
|
+
def self.complete(id = nil)
|
325
|
+
real_id = _expand_issue_id(id)
|
326
|
+
_pause_current(real_id)
|
327
|
+
|
328
|
+
issue = {
|
329
|
+
'assigned_to_id' => _get_responsible_user_id(real_id),
|
330
|
+
'status_id' => STATUS_COMPLETED,
|
331
|
+
}
|
332
|
+
|
333
|
+
return request("issues/#{real_id}", {}, {'issue' => issue}, {method: 'PUT'})
|
334
|
+
end
|
335
|
+
|
336
|
+
def self.close(id = nil)
|
337
|
+
real_id = _expand_issue_id(id)
|
338
|
+
_pause_current(real_id)
|
339
|
+
|
340
|
+
issue = {
|
341
|
+
'status_id' => STATUS_CLOSED
|
342
|
+
}
|
343
|
+
|
344
|
+
return request("issues/#{real_id}", {}, {'issue' => issue}, {method: 'PUT'})
|
345
|
+
end
|
346
|
+
|
347
|
+
def self.description(id = nil)
|
348
|
+
real_id = _expand_issue_id(id)
|
349
|
+
issue = request("issues/#{real_id}", {include: 'journals'})['issue']
|
350
|
+
journal = issue.delete('journals')
|
351
|
+
|
352
|
+
info =
|
353
|
+
issue.
|
354
|
+
collect() { |key, value|
|
355
|
+
if value.instance_of?(::Hash)
|
356
|
+
if value.has_key?('name')
|
357
|
+
value = value['name']
|
358
|
+
else
|
359
|
+
value = value.to_s()
|
360
|
+
end
|
361
|
+
end
|
362
|
+
|
363
|
+
[key.capitalize(), _split_text(value.to_s(), 100)]
|
364
|
+
}
|
365
|
+
|
366
|
+
comments =
|
367
|
+
journal.
|
368
|
+
select() { |element|
|
369
|
+
!element['notes'].empty?()
|
370
|
+
}.
|
371
|
+
collect() { |element|
|
372
|
+
[element['user']['name'], _split_text(element['notes'], 80)]
|
373
|
+
}
|
374
|
+
|
375
|
+
if comments.length > 0
|
376
|
+
info.push(['Comments', Terminal::Table.new(rows: comments)])
|
377
|
+
end
|
378
|
+
|
379
|
+
return Terminal::Table.new(rows: info)
|
380
|
+
end
|
381
|
+
|
382
|
+
def self.reply(*args)
|
383
|
+
if args.length > 0 && args[0].match(/^\d+/)
|
384
|
+
real_id = _expand_issue_id(args[0])
|
385
|
+
else
|
386
|
+
id = _get_console_option(args, ['i', 'issue'])
|
387
|
+
real_id = _expand_issue_id(id)
|
388
|
+
end
|
389
|
+
|
390
|
+
message = _get_console_option(args, ['m', 'message'])
|
391
|
+
|
392
|
+
issue = {
|
393
|
+
'assigned_to_id' => _get_responsible_user_id(real_id),
|
394
|
+
'status' => STATUS_FEEDBACK,
|
395
|
+
'notes' => message,
|
396
|
+
}
|
397
|
+
|
398
|
+
return request("issues/#{real_id}", {}, {'issue' => issue}, {method: 'PUT'})
|
399
|
+
end
|
400
|
+
|
401
|
+
def self.help()
|
402
|
+
return <<-TEXT
|
403
|
+
USAGE: redmine-issue [command] [args]
|
404
|
+
|
405
|
+
Manage redmine issues.
|
406
|
+
|
407
|
+
Commands:
|
408
|
+
|
409
|
+
list
|
410
|
+
|
411
|
+
List issues; arguments is API get params: http://www.redmine.org/projects/redmine/wiki/Rest_Issues;
|
412
|
+
Example: --project-id 10 --status-id closed; default arguments: --assigned-to-id me --status-id open
|
413
|
+
--sort "priority:desc,project"
|
414
|
+
|
415
|
+
description [id]
|
416
|
+
|
417
|
+
Get issue description and comments
|
418
|
+
|
419
|
+
reply [id] -m message
|
420
|
+
|
421
|
+
Reply to issue; adds comment, sets status "Feedback" and returns issue to responsible user
|
422
|
+
|
423
|
+
start id
|
424
|
+
|
425
|
+
Starts issue specified by id; starts tracking current issue and spent time and set issue status "In progress"
|
426
|
+
|
427
|
+
pause
|
428
|
+
|
429
|
+
Pause current issue; save spent time to issue and untrack current issue.
|
430
|
+
|
431
|
+
cancel
|
432
|
+
|
433
|
+
Cancel current issue; untrack current issue without time saving.
|
434
|
+
|
435
|
+
status
|
436
|
+
|
437
|
+
Get current issue id and spent time.
|
438
|
+
|
439
|
+
complete [id]
|
440
|
+
|
441
|
+
Complete issue; set status "Completed" to issue and returns issue to responsible user; if completes current -
|
442
|
+
save spent time.
|
443
|
+
|
444
|
+
close [id]
|
445
|
+
|
446
|
+
Same as complete but set status "Closed"; you have to have permission to close issues to tun this command.
|
447
|
+
|
448
|
+
config key
|
449
|
+
|
450
|
+
Displays config value.
|
451
|
+
|
452
|
+
set-config key value
|
453
|
+
|
454
|
+
Sets config value.
|
455
|
+
|
456
|
+
TEXT
|
457
|
+
end
|
458
|
+
|
459
|
+
end
|
metadata
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: redmine-issue
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Leonid Shagabutdinov
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-06-11 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: Redmine issue helper - let you do simple redmine workflow from console
|
14
|
+
email: leonid@shagabutdinov.com
|
15
|
+
executables:
|
16
|
+
- redmine-issue
|
17
|
+
extensions: []
|
18
|
+
extra_rdoc_files: []
|
19
|
+
files:
|
20
|
+
- bin/redmine-issue
|
21
|
+
- lib/redmine_issue.rb
|
22
|
+
homepage: http://github.com/shagabutdinov/redmine-issue
|
23
|
+
licenses:
|
24
|
+
- MIT
|
25
|
+
metadata: {}
|
26
|
+
post_install_message:
|
27
|
+
rdoc_options: []
|
28
|
+
require_paths:
|
29
|
+
- lib
|
30
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
31
|
+
requirements:
|
32
|
+
- - ">="
|
33
|
+
- !ruby/object:Gem::Version
|
34
|
+
version: '0'
|
35
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ">="
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '0'
|
40
|
+
requirements: []
|
41
|
+
rubyforge_project:
|
42
|
+
rubygems_version: 2.4.5
|
43
|
+
signing_key:
|
44
|
+
specification_version: 4
|
45
|
+
summary: Redmine issue
|
46
|
+
test_files: []
|