redmine-issue 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (4) hide show
  1. checksums.yaml +7 -0
  2. data/bin/redmine-issue +22 -0
  3. data/lib/redmine_issue.rb +459 -0
  4. 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: []