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.
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: []