redmine-cli 0.1.1 → 0.1.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA512:
3
+ metadata.gz: cc478e4f17439fc1f2a7d167365e96b271c12c846182000c3c7dc5563b751a8c9e4f33add97d6df6195b60a7624db6a61eff7987bc8c6fad051c32688b71b1dc
4
+ data.tar.gz: c40552a5ff0f9b21b73190a151ae48a03fde7625828118ee5cbf64725ec66c7818a7448fc140a026e70f4d5be824520d8c8013ced4502714e1c94255a908080b
5
+ SHA1:
6
+ metadata.gz: 46a0d3ee489a5d807551fb3d1b38220e55ff0333
7
+ data.tar.gz: 8b27f9c02e87d3c635db32fea2a6a707cdae243e
data/Gemfile.lock CHANGED
@@ -1,27 +1,28 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- redmine-cli (0.1.0)
4
+ redmine-cli (0.1.3)
5
5
  activeresource (~> 3.0.0)
6
+ interactive_editor
6
7
  thor
7
8
 
8
9
  GEM
9
10
  remote: http://rubygems.org/
10
11
  specs:
11
- activemodel (3.0.1)
12
- activesupport (= 3.0.1)
12
+ activemodel (3.0.20)
13
+ activesupport (= 3.0.20)
13
14
  builder (~> 2.1.2)
14
- i18n (~> 0.4.1)
15
- activeresource (3.0.1)
16
- activemodel (= 3.0.1)
17
- activesupport (= 3.0.1)
18
- activesupport (3.0.1)
15
+ i18n (~> 0.5.0)
16
+ activeresource (3.0.20)
17
+ activemodel (= 3.0.20)
18
+ activesupport (= 3.0.20)
19
+ activesupport (3.0.20)
19
20
  aruba (0.2.3)
20
21
  background_process
21
22
  cucumber (~> 0.9.0)
22
23
  background_process (1.2)
23
24
  builder (2.1.2)
24
- columnize (0.3.1)
25
+ columnize (0.3.6)
25
26
  cucumber (0.9.3)
26
27
  builder (~> 2.1.2)
27
28
  diff-lcs (~> 1.1.2)
@@ -29,12 +30,17 @@ GEM
29
30
  json (~> 1.4.6)
30
31
  term-ansicolor (~> 1.0.5)
31
32
  diff-lcs (1.1.2)
33
+ ffi (1.9.3)
32
34
  gherkin (2.2.9)
33
35
  json (~> 1.4.6)
34
36
  term-ansicolor (~> 1.0.5)
35
- i18n (0.4.2)
37
+ i18n (0.5.3)
38
+ interactive_editor (0.0.10)
39
+ spoon (>= 0.0.1)
36
40
  json (1.4.6)
37
- linecache (0.43)
41
+ linecache (0.46)
42
+ rbx-require-relative (> 0.0.4)
43
+ rbx-require-relative (0.0.9)
38
44
  rspec (2.0.1)
39
45
  rspec-core (~> 2.0.1)
40
46
  rspec-expectations (~> 2.0.1)
@@ -45,22 +51,22 @@ GEM
45
51
  rspec-mocks (2.0.1)
46
52
  rspec-core (~> 2.0.1)
47
53
  rspec-expectations (~> 2.0.1)
48
- ruby-debug (0.10.3)
54
+ ruby-debug (0.10.4)
49
55
  columnize (>= 0.1)
50
- ruby-debug-base (~> 0.10.3.0)
51
- ruby-debug-base (0.10.3)
56
+ ruby-debug-base (~> 0.10.4.0)
57
+ ruby-debug-base (0.10.4)
52
58
  linecache (>= 0.3)
59
+ spoon (0.0.4)
60
+ ffi
53
61
  term-ansicolor (1.0.5)
54
- thor (0.14.3)
62
+ thor (0.18.1)
55
63
 
56
64
  PLATFORMS
57
65
  ruby
58
66
 
59
67
  DEPENDENCIES
60
- activeresource (~> 3.0.0)
61
68
  aruba
62
69
  cucumber
63
70
  redmine-cli!
64
71
  rspec
65
72
  ruby-debug
66
- thor
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2012 Jorge Dias
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # redmine-cli
2
+ ### A command line interface for redmine
3
+ Because using the browser is overrated.
4
+ [![Gem Version](https://badge.fury.io/rb/redmine-cli.png)](http://badge.fury.io/rb/redmine-cli)
5
+
6
+ ## Installation
7
+ You first need to have Ruby with RubyGems.
8
+
9
+ Then run:
10
+
11
+ gem install redmine-cli
12
+ redmine install
13
+
14
+ This will create a .redmine file in your home directory. The file is a yaml file which contains our necessary configuration
15
+
16
+ During install, you can select the fields that you wish to be displayed, or accept the default (url, status, subject). This list can contain custom fields.
17
+
18
+ Note that previous versions of redmine-cli installed a version of .redmine that do not take full advantage of new features. For compatiblity purposes, this version is compatible with older .redmine files. However, for best results, you should re-run "redmine install" every time you upgrade the gem.
19
+
20
+ ## Usage
21
+ You can get help by simpling executing:
22
+
23
+ redmine
24
+
25
+ Listing tickets
26
+
27
+ redmine list
28
+ redmine list -a me -T bug
29
+
30
+ Display ticket
31
+
32
+ redmine show 524
33
+
34
+ Updating a ticket
35
+
36
+ redmine update 524 -description "New description"
37
+ redmine update 256 --assigned_to me
38
+
39
+ Updating multiple tickets
40
+
41
+ redmine update 2 3 4 --assigned_to johndoe
42
+
43
+ Updating all tickets for a list
44
+
45
+ redmine list --status new --std_output | xargs redmine update --asigned_to me --status 3 -l
46
+
47
+ (Note that the last argument of the update command must be -l)
48
+
49
+ Interactively editing a ticket's fields
50
+
51
+ redmine edit --description 2
52
+
53
+ Your editor will pop up, and you can modify the field. The ticket will be updated when you save the file and exit the editor.
54
+
55
+ ## Configuration
56
+ Redmine-cli will install a default configuration file. However you can edit it to fit your redmine installation.
57
+ You can add mappings for users, statuses, custom queries, and trackers like:
58
+
59
+ user_mappings:
60
+ "me": 1
61
+ "johndoe": 24
62
+ status_mappings:
63
+ "new": 1
64
+ "closed": 4
65
+ tracker_mappings:
66
+ "bug" : 1
67
+ "feature" : 2
68
+
69
+ This will allow to use those names with the commands instead of the ids your redmine installation uses.
70
+
71
+ Additionally, you can choose which fields are displayed when you use "redmine list" by editing the list_fields section like:
72
+ list_fields:
73
+ - project
74
+ - id
75
+ - tracker
76
+ - status
77
+ - priority
78
+ - assigned_to
79
+ - subject
80
+ - updated_on
81
+
82
+ You can choose from any of these fields:
83
+
84
+ * url - A "clickable" link to the issue
85
+ * id - The ID number of the issue
86
+ * subject
87
+ * status - Open, Closed, Resolved, etc.
88
+ * start_date
89
+ * estimated_hours
90
+ * tracker - Bug, Feature, Improvement, etc.
91
+ * priority - Low, High, Immediate, etc. (Note: this field is colorized on terminals that support it)
92
+ * description
93
+ * assigned_to
94
+ * project
95
+ * author
96
+ * done_ratio
97
+ * due_date
98
+ * created_on
99
+ * updated_on
100
+
101
+ In addition to these fields, you can also specify any custom fields that you've configured in your Redmine site. If a field is not found on an issue, or the value is blank, then a blank value will be displayed in the list.
102
+
103
+ ## Known Issues
104
+
105
+ If you use a non-administrative account, redmine-cli's mapping cache will not be able to retrieve the list of users (you must manually populate the user mappings in this case). Additionally, you'll receive an error like this whenever you try to update an issue:
106
+
107
+ Updating mapping cache...
108
+ Failed to fetch users: Failed. Response code = 403. Response message = Forbidden.
109
+
110
+ If this happens, you can disable the caching feature by setting "disable_caching": true in ~/.redmine
data/lib/redmine-cli.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  require 'thor'
2
2
  require 'active_resource'
3
- require 'redmine-cli/issue'
3
+ require 'redmine-cli/resources'
4
4
  require 'redmine-cli/generators/install'
5
5
  require 'redmine-cli/cli'
6
6
  require 'redmine-cli/git'
@@ -1,39 +1,165 @@
1
1
  require 'thor'
2
- require 'redmine-cli/issue'
2
+ require 'redmine-cli/field'
3
+ require 'redmine-cli/config'
4
+ require 'redmine-cli/resources'
3
5
  require 'redmine-cli/generators/install'
6
+ require 'rubygems'
7
+ require 'interactive_editor'
8
+ require 'yaml'
9
+ require 'pp'
10
+
11
+ $KCODE='u'
4
12
 
5
13
  module Redmine
6
14
  module Cli
7
15
  class CLI < Thor
16
+
8
17
  desc "list", "List all issues for the user"
9
18
  method_option :assigned_to, :aliases => "-a", :desc => "id or user name of person the ticket is assigned to"
10
19
  method_option :status, :aliases => "-s", :desc => "id or name of status for ticket"
11
- method_option :std_output, :aliases => "-o", :type => :boolean,
12
- :desc => "special output for STDOUT (useful for updates)"
20
+ method_option :project, :aliases => "-p", :desc => "project id"
21
+ method_option :version, :aliases => "-v", :desc => "the target version"
22
+ method_option :query, :aliases => "-q", :desc => "list issues according to a saved custom query"
23
+ method_option :tracker, :aliases => "-T", :desc => "id or name of tracker for ticket"
24
+ method_option :std_output, :aliases => "-o", :desc => "output a space-delimited list of ticket numbers that match the specified criteria (useful when piped to \"update\" command", :type => :boolean
25
+ method_option :sort, :aliases => "-S", :desc => "sort by the specified column"
26
+
13
27
  def list
14
28
  params = {}
15
29
 
16
30
  params[:assigned_to_id] = map_user(options.assigned_to) if options.assigned_to
17
-
18
31
  params[:status_id] = map_status(options.status) if options.status
32
+ params[:tracker_id] = map_tracker(options.tracker) if options.tracker
33
+ params[:project_id] = map_project(options.project) if options.project
34
+ params[:query_id] = map_query(options.query) if options.query
35
+ params[:sort] = options[:sort] if options[:sort]
36
+
37
+ collection = Issue.fetch_all(params)
19
38
 
20
- collection = Issue.all(:params => params)
39
+ selected_fields = Redmine::Cli::config.list_fields || ["url", "subject", "status"]
21
40
 
22
41
  unless options.std_output
23
- issues = collection.collect { |issue| [link_to_issue(issue.id), issue.subject, issue.status.name] }
42
+ # collection.sort! {|i,j| i.status.id <=> j.status.id }
43
+ collection.sort! {|i,j| i.priority.id <=> j.priority.id }
44
+
45
+ # Retrieve the list of issue fields in selected_fields
46
+ issues = collection.collect { |issue| selected_fields.collect {| key |
47
+
48
+ assignee = ""
49
+ assignee = issue.assigned_to.name if issue.respond_to?(:assigned_to)
50
+ version = issue.fixed_version.name if issue.respond_to?(:fixed_version)
51
+
52
+ # Hack, because I don't feel like spending much time on this
53
+ next unless version == options.version
54
+
55
+ begin
56
+ # If this is a built-in field for which we have a title, ref, and display method, use that.
57
+ field = fields.fetch(key)
58
+ if field.display
59
+ value = issue.attributes.fetch(field.ref)
60
+ field.display.call(value)
61
+ else
62
+ f = fields.fetch(key).ref
63
+ issue.attributes.fetch(f)
64
+ end
65
+ rescue IndexError
66
+ # Otherwise, let's look for a custom field by that name.
67
+ if issue.attributes[:custom_fields].present?
68
+ issue.attributes[:custom_fields].collect { | field |
69
+ if field.attributes.fetch("name") == key
70
+ field.attributes.fetch("value")
71
+ end
72
+ }
73
+ end
74
+ ""
75
+ #TODO: If the custom field doesn't exist, then we end up returning a blank value (not an error). I guess that's OK?
76
+ end
77
+
78
+ }}
24
79
 
25
80
  if issues.any?
26
- issues.insert(0, ["URL", "Subject", "Status"])
81
+ issues.insert(0, selected_fields.collect {| key |
82
+ begin
83
+ fields.fetch(key).title
84
+ rescue IndexError
85
+ key
86
+ end
87
+ })
88
+
27
89
  print_table(issues)
90
+ say "#{collection.count} issues - #{link_to_project(params[:project_id])}", :yellow
28
91
  end
92
+
93
+ # Clean up after ourselves
94
+ issues.compact!
29
95
  else
30
96
  say collection.collect(&:id).join(" ")
31
97
  end
32
98
  end
33
99
 
100
+ desc "projects", "Lists all projects"
101
+ def projects
102
+ projects = Project.fetch_all.sort {|i,j| i.name <=> j.name}.collect { |project| [ project.id, project.identifier, project.name ] }
103
+ if projects.any?
104
+ projects.insert(0, ["Id", "Key", "Name"])
105
+ print_table(projects)
106
+ say "#{projects.count-1} projects - #{link_to_project}", :yellow
107
+ end
108
+ end
109
+
110
+ method_option :status, :type => :boolean, :aliases => "-s", :desc => "id or name of status for ticket"
111
+ method_option :priority, :type => :boolean, :aliases => "-p", :desc => "id or name of priority for ticket"
112
+ method_option :tracker, :type => :boolean, :aliases => "-T", :desc => "id or name of tracker for ticket"
113
+ method_option :subject, :type => :boolean, :aliases => "-t", :desc => "subject for ticket (title)"
114
+ method_option :description, :type => :boolean, :aliases => "-d", :desc => "description for ticket"
115
+ method_option :assigned_to, :type => :boolean, :aliases => "-a", :desc => "id or user name of person the ticket is assigned to"
116
+ method_option :notes, :type => :boolean, :aliases => "-n", :desc => "add a note to the Redmine ticket (interactively)"
117
+ desc "edit [OPTIONS] TICKET", "Interactively edit the fields of an issue"
118
+ def edit(ticket)
119
+ issue = Issue.find(ticket)
120
+
121
+ if options.empty?
122
+ say "You must specify at least one field to edit", :red
123
+ exit
124
+ end
125
+
126
+ data = {}
127
+ options.each { | key, val |
128
+ # Set the initial value to one of:
129
+ # The direct string representation of the issue's key
130
+ # The value of the issue's key's "name" member
131
+ # A multi-line string initialized to a message appropriate to the value (most useful for "notes" and "description")
132
+ initialval = issue.attributes.has_key?(key) && ! issue.attributes[key].nil? ? case issue.attributes[key]
133
+ when String then issue.attributes[key].to_s
134
+ else issue.attributes[key].name.to_s
135
+ end : "Enter your #{key} here.\n\n"
136
+
137
+ data[key] = initialval.clone.ed
138
+
139
+ if data[key] == initialval
140
+ # If the user didn't actually edit the field, then don't submit it for update.
141
+ say "You did not change the value, so the edit to #{key} was ignored.", :red
142
+ data.delete(key)
143
+ end
144
+ }
145
+
146
+ unless data.empty?
147
+ update_ticket(ticket, data.with_indifferent_access)
148
+ else
149
+ say "There was no new data to submit, so #{ticket} was not updated.", :red
150
+ end
151
+
152
+
153
+ rescue ActiveResource::ResourceNotFound
154
+ say "No ticket with number: #{ticket}", :red
155
+ end
156
+
34
157
  desc "show TICKET", "Display information of a ticket"
35
158
  def show(ticket)
36
- issue = Issue.find(ticket)
159
+ params = {}
160
+ params[:params] = {:include => "journals,changesets"}
161
+
162
+ issue = Issue.find(ticket, params)
37
163
 
38
164
  display_issue(issue)
39
165
  rescue ActiveResource::ResourceNotFound
@@ -48,7 +174,7 @@ module Redmine
48
174
  params =
49
175
  Thor::CoreExt::HashWithIndifferentAccess.new(:subject => subject,
50
176
  :description => description,
51
- :project => Issue.config.default_project_id)
177
+ :project => Redmine::Cli::config.default_project_id)
52
178
  params.merge!(options)
53
179
 
54
180
  unless params.project
@@ -66,9 +192,12 @@ module Redmine
66
192
 
67
193
  method_option :tickets, :aliases => "-l", :desc => "list of tickets", :type => :array
68
194
  method_option :status, :aliases => "-s", :desc => "id or name of status for ticket"
195
+ method_option :priority, :aliases => "-p", :desc => "id or name of priority for ticket"
196
+ method_option :tracker, :aliases => "-T", :desc => "id or name of tracker for ticket"
69
197
  method_option :subject, :aliases => "-t", :desc => "subject for ticket (title)"
70
198
  method_option :description, :aliases => "-d", :desc => "description for ticket"
71
199
  method_option :assigned_to, :aliases => "-a", :desc => "id or user name of person the ticket is assigned to"
200
+ method_option :notes, :aliases => "-n", :desc => "an optional note about this update"
72
201
  desc "update [TICKETS]", "Update tickets"
73
202
  def update(*tickets)
74
203
  tickets = options.tickets if tickets.blank? && options.tickets.present?
@@ -81,9 +210,9 @@ module Redmine
81
210
  tickets.collect { |ticket| Thread.new { update_ticket(ticket, options) } }.each(&:join)
82
211
  end
83
212
 
84
- desc "install [URL][USERNAME]", "Generates a default configuration file"
213
+ desc "install [URL] [USERNAME] [FIELDS]", "Generates a default configuration file"
85
214
  method_option :test, :type => :boolean
86
- def install(url = "localhost:3000", username = "")
215
+ def install(url = "localhost:3000", username = "", fieldcsv="")
87
216
  url = "http://#{url}" unless url =~ /\Ahttp/
88
217
 
89
218
  if username.blank?
@@ -92,33 +221,154 @@ module Redmine
92
221
 
93
222
  password = ask_password("Password?")
94
223
 
95
- arguments = [url, username, password]
224
+ if fieldcsv.blank?
225
+ fieldcsv = ask("\nWhat fields should be displayed in \"redmine list\"?\n\nPossible values are: [" + fields.keys.join(", ") + "]\n\nEnter a list of comma-separated fields: ")
226
+ end
227
+
228
+ list_fields = fieldcsv.split(",")
229
+
230
+ arguments = [url, username, password, list_fields]
96
231
  arguments.concat(["--test"]) if options.test
97
232
 
98
233
  Redmine::Cli::Generators::Install.start(arguments)
99
234
  end
100
235
 
101
236
  no_tasks do
237
+
238
+ # Prints a table.
239
+ #
240
+ # ==== Parameters
241
+ # Array[Array[String, String, ...]]
242
+ #
243
+ # ==== Options
244
+ # indent<Integer>:: Indent the first column by indent value.
245
+ # colwidth<Integer>:: Force the first column to colwidth spaces wide.
246
+ #
247
+ def print_table(array, options = {}) # rubocop:disable MethodLength
248
+ return if array.empty?
249
+
250
+ formats, indent, colwidth = [], options[:indent].to_i, options[:colwidth]
251
+ options[:truncate] = terminal_width if options[:truncate] == true
252
+
253
+ formats << "%-#{colwidth + 2}s" if colwidth
254
+ start = colwidth ? 1 : 0
255
+
256
+ colcount = array.max { |a, b| a.size <=> b.size }.size
257
+
258
+ maximas = []
259
+
260
+ start.upto(colcount - 1) do |index|
261
+ maxima = array.map { |row|
262
+ skip = false
263
+ rowlen = row[index].to_s.chars.collect { | char |
264
+ # Deal with ASCII escape sequences (skip them), also acknowledge that
265
+ # a char might consist of more than one byte in the case of unicode characters.
266
+ bytes = char.bytes.to_a
267
+ if bytes[0] < 32
268
+ skip = true
269
+ end
270
+ if bytes[0] == 109 && skip == true
271
+ skip = false
272
+ 0
273
+ next
274
+ end
275
+ #puts "#{bytes.to_s} #{char} skip=#{skip}"
276
+ # FIXME: Unicode characters in most terminal fonts take up somewhere
277
+ # between 1 and 2 columns. So this isn't exact. (But it's better than nothing)
278
+ skip == true ? 0 : char.bytes.to_a.length > 1 ? 2 : 1
279
+ }.compact.reduce(:+)
280
+ row[index].present? ? rowlen : 0
281
+ }.max
282
+ maximas << maxima
283
+ if index == colcount - 1
284
+ # Don't output 2 trailing spaces when printing the last column
285
+ formats << '%-s'
286
+ else
287
+ formats << "%-#{maxima + 2}s"
288
+ end
289
+ end
290
+
291
+ formats[0] = formats[0].insert(0, ' ' * indent)
292
+ formats << '%s'
293
+
294
+ array.each do |row|
295
+ sentence = ''
296
+
297
+ row.each_with_index do |column, index|
298
+ maxima = maximas[index]
299
+
300
+ if column.is_a?(Numeric)
301
+ if index == row.size - 1
302
+ # Don't output 2 trailing spaces when printing the last column
303
+ f = "%#{maxima}s"
304
+ else
305
+ f = "%#{maxima}s "
306
+ end
307
+ else
308
+ f = formats[index]
309
+ end
310
+ sentence << f % column.to_s
311
+ end
312
+
313
+ sentence = truncate(sentence, options[:truncate]) if options[:truncate]
314
+ puts sentence
315
+ end
316
+ end
317
+
102
318
  def link_to_issue(id)
103
- "#{Issue.config.url}/issues/#{id}"
319
+ "#{Redmine::Cli::config.url}/issues/#{id}"
320
+ end
321
+
322
+ def link_to_project(name = nil)
323
+ if name
324
+ "#{Redmine::Cli::config.url}/projects/#{name}/issues"
325
+ else
326
+ "#{Redmine::Cli::config.url}"
327
+ end
328
+ end
329
+
330
+ def status_name(status)
331
+ status.name
104
332
  end
105
333
 
106
334
  def ticket_attributes(options)
107
335
  attributes = {}
108
336
 
109
- attributes[:subject] = options.subject if options.subject.present?
110
- attributes[:description] = options.description if options.description.present?
111
- attributes[:project_id] = options.project if options.project.present?
112
- attributes[:assigned_to_id] = map_user(options.assigned_to) if options.assigned_to.present?
113
- attributes[:status_id] = options.status if options.status.present?
337
+ attributes[:subject] = options["subject"] if options["subject"].present?
338
+ attributes[:description] = options["description"] if options["description"].present?
339
+ attributes[:project_id] = map_project(options["project"]) if options["project"].present?
340
+ attributes[:assigned_to_id] = map_user(options["assigned_to"]) if options["assigned_to"].present?
341
+ attributes[:status_id] = map_status(options["status"]) if options["status"].present?
342
+ attributes[:priority_id] = map_priority(options["priority"])if options["priority"].present?
343
+ attributes[:tracker_id] = map_tracker(options["tracker"]) if options["tracker"].present?
344
+ attributes[:notes] = options["notes"] if options["notes"].present?
114
345
 
115
346
  attributes
116
347
  end
117
348
 
118
349
  def display_issue(issue)
119
- shell.print_wrapped "#{link_to_issue(issue.id)} - #{issue.status.name}"
350
+ shell.print_wrapped "#{issue.project.name} ##{issue.id} - #{link_to_issue(issue.id)} - #{issue.status.name}"
351
+ shell.print_wrapped "Priority: #{issue.priority.name}"
352
+ if issue.attributes[:assigned_to].present?
353
+ shell.print_wrapped "Assigned To: #{issue.attributes[:assigned_to].name}"
354
+ end
120
355
  shell.print_wrapped "Subject: #{issue.subject}"
121
- shell.print_wrapped issue.description || "", :ident => 2
356
+ shell.print_wrapped "Tracker: #{issue.tracker.name}"
357
+ shell.print_wrapped issue.description || "", :indent => 2
358
+ if issue.journals.any?
359
+ issue.journals.each do |journal|
360
+ details = journal.details.collect do |detail|
361
+ unless detail.name == "description"
362
+ "#{detail.name}: #{detail.old_value} => #{detail.new_value}"
363
+ else
364
+ # Multi-line descriptions can make output unreadable.
365
+ "#{detail.name} updated"
366
+ end
367
+ end
368
+ shell.print_wrapped "Updated by #{journal.user.name} on #{journal.created_on} (#{details})"
369
+ shell.print_wrapped "#{journal.notes}\n\n###".bold, :indent => 2 if journal.notes.present?
370
+ end
371
+ end
122
372
  end
123
373
 
124
374
  def map_user(user_name)
@@ -129,22 +379,116 @@ module Redmine
129
379
  get_mapping(:status_mappings, status_name)
130
380
  end
131
381
 
132
- def get_mapping(mapping, value)
133
- return value if value.to_i != 0
382
+ def map_priority(priority_name)
383
+ result = get_mapping(:priority_mappings, priority_name)
384
+ if Redmine::Cli::config["colorize"]
385
+ case result
386
+ # This is sort of a hack... ANSI sequences make it hard to figure out column lengths.
387
+ # So we make all fields the same length and with the same number of unprintable ansi
388
+ # sequence characters.
389
+
390
+ when "Low" then "Low ".blue.blue.blue
391
+ when "Normal" then "Normal ".green.green.green
392
+ when "High" then "High ".red.red.red
393
+ when "Urgent" then "Urgent ".bold.red.red
394
+ when "Immediate" then "Immediate".blink.bold.red
395
+ else result[0...9]
396
+ end
397
+ else
398
+ result
399
+ end
400
+ end
401
+
402
+ def map_project(project_name)
403
+ get_mapping(:project_mappings, project_name)
404
+ end
405
+
406
+ def map_query(query_name)
407
+ get_mapping(:query_mappings, query_name)
408
+ end
134
409
 
135
- if Issue.config[mapping].nil? || (mapped = Issue.config[mapping][value]).nil?
136
- say "No #{mapping} for #{value}", :red
137
- exit 1
410
+ def update_mapping_cache
411
+ unless Redmine::Cli::config["disable_caching"]
412
+ say 'Updating mapping cache...', :yellow
413
+ # TODO: Updating user mapping requries Redmine 1.1+
414
+ # TODO: Retrieving user mapping requires admin privileges in Redmine
415
+ users = []
416
+ begin
417
+ users = User.fetch_all.collect { |user| [ user.login, user.id ] }
418
+ rescue Exception => e
419
+ say "Failed to fetch users: #{e}", :red
420
+ end
421
+ projects = Project.fetch_all.collect { |project| [ project.identifier, project.id ] }
422
+ queries = Query.fetch_all.collect { |query|
423
+ [ query.name, query.id ]
424
+ }
425
+
426
+ priorities = {}
427
+ status = {}
428
+ Issue.fetch_all.each do |issue|
429
+ priorities[issue.priority.name] = issue.priority.id if issue.priority
430
+ status[issue.status.name] = issue.status.id if issue.status
431
+ end
432
+
433
+ # TODO: Need to determine where to place cache file based on
434
+ # config file location.
435
+ File.open(File.expand_path('~/.redmine_cache'), 'w') do |out|
436
+ YAML.dump({
437
+ :user_mappings => Hash[users],
438
+ :project_mappings => Hash[projects],
439
+ :priority_mappings => priorities,
440
+ :status_mappings => status,
441
+ :query_mappings => Hash[queries],
442
+ }, out)
443
+ end
444
+ end
445
+ end
446
+
447
+ def map_tracker(tracker_name)
448
+ get_mapping(:tracker_mappings, tracker_name)
449
+ end
450
+
451
+ def get_mapping(mapping, value)
452
+ begin
453
+ return value if value.to_i != 0
454
+ rescue NoMethodError
455
+ return value.attributes.fetch("name") if value.id.to_i != 0
456
+ end
457
+
458
+ if Redmine::Cli::config[mapping].nil? || (mapped = Redmine::Cli::config[mapping][value]).nil?
459
+ if !(mapped = get_mapping_from_cache(mapping, value))
460
+ update_mapping_cache
461
+
462
+ if !(mapped = get_mapping_from_cache(mapping, value))
463
+ say "No #{mapping} for #{value}", :red
464
+ exit 1
465
+ end
466
+ end
138
467
  end
139
468
 
140
469
  return mapped
141
470
  end
142
471
 
472
+ def get_mapping_from_cache(mapping, value)
473
+ begin
474
+ if Redmine::Cli::cache[mapping].nil? || (mapped = Redmine::Cli::cache[mapping][value]).nil?
475
+ return false
476
+ end
477
+ return mapped
478
+ rescue
479
+ # We need to recover here from any error that could happen
480
+ # in case the cache is corrupted.
481
+ return false
482
+ end
483
+ end
484
+
143
485
  def update_ticket(ticket, options)
144
486
  issue = Issue.find(ticket)
145
487
  params = ticket_attributes(options)
146
488
 
147
489
  if issue.update_attributes(params)
490
+ params[:params] = {:include => "journals,changesets"}
491
+ issue = Issue.find(ticket, params)
148
492
  say "Updated: #{ticket}. Options: #{params.inspect}", :green
149
493
  display_issue(issue)
150
494
  else
@@ -160,7 +504,57 @@ module Redmine
160
504
  system "stty echo"
161
505
  password
162
506
  end
507
+
508
+ def fields()
509
+ # This is a collection of built-in Redmine fields, the key by which they can be accessed, and a wrapper
510
+ # method that is used to display their value. Pseudo-fields can be added that use different wrapper
511
+ # methods to give the user flexibility over their output (see url vs. id)
512
+ return {
513
+ "url" => Field.new("URL", "id", method(:link_to_issue)),
514
+ "id" => Field.new("ID#", "id"),
515
+ "subject" => Field.new("Subject", "subject"),
516
+ "status" => Field.new("Status", "status", method(:status_name)),
517
+ "start_date" => Field.new("Start", "start_date"),
518
+ "estimated_hours" => Field.new("Estd", "estimated_hours"),
519
+ "tracker" => Field.new("Type", "tracker", method(:map_user)),
520
+ "priority" => Field.new("Priority", "priority", method(:map_priority)),
521
+ "description" => Field.new("Description", "description"),
522
+ "assigned_to" => Field.new("Assigned To", "assigned_to", method(:map_user)),
523
+ "project" => Field.new("Project", "project", method(:map_user)),
524
+ "author" => Field.new("Author", "author", method(:map_user)),
525
+ "done_ratio" => Field.new("% Done", "done_ratio"),
526
+ "due_date" => Field.new("Due On", "due_date"),
527
+ "created_on" => Field.new("Created On", "created_on"),
528
+ "updated_on" => Field.new("Updated On", "updated_on"),
529
+ }
530
+ end
531
+
532
+ def default_parameters
533
+ {:limit => 100}
534
+ end
163
535
  end
164
536
  end
165
537
  end
166
538
  end
539
+
540
+ class String
541
+ def black; "\033[30m#{self}\033[0m" end
542
+ def red; "\033[31m#{self}\033[0m" end
543
+ def green; "\033[32m#{self}\033[0m" end
544
+ def brown; "\033[33m#{self}\033[0m" end
545
+ def blue; "\033[34m#{self}\033[0m" end
546
+ def magenta; "\033[35m#{self}\033[0m" end
547
+ def cyan; "\033[36m#{self}\033[0m" end
548
+ def gray; "\033[37m#{self}\033[0m" end
549
+ def bg_black; "\033[40m#{self}\0330m" end
550
+ def bg_red; "\033[41m#{self}\033[0m" end
551
+ def bg_green; "\033[42m#{self}\033[0m" end
552
+ def bg_brown; "\033[43m#{self}\033[0m" end
553
+ def bg_blue; "\033[44m#{self}\033[0m" end
554
+ def bg_magenta; "\033[45m#{self}\033[0m" end
555
+ def bg_cyan; "\033[46m#{self}\033[0m" end
556
+ def bg_gray; "\033[47m#{self}\033[0m" end
557
+ def bold; "\033[1m#{self}\033[22m" end
558
+ def blink; "\033[5m#{self}\033[22m" end
559
+ def reverse_color; "\033[7m#{self}\033[27m" end
560
+ end
@@ -0,0 +1,48 @@
1
+ require 'thor'
2
+ require 'yaml'
3
+
4
+ module Redmine
5
+ module Cli
6
+ class << self
7
+
8
+ def config
9
+ begin
10
+ generic_conf '.redmine'
11
+ rescue Errno::ENOENT
12
+ puts "You need to create the file .redmine in your home with your username, password and url"
13
+ exit 1
14
+ end
15
+ end
16
+
17
+ def cache
18
+ begin
19
+ generic_conf '.redmine_cache'
20
+ rescue Errno::ENOENT
21
+ @cache = Thor::CoreExt::HashWithIndifferentAccess.new
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def generic_conf(config_file)
28
+ # Using Ruby Magic(tm) to get the caller's function name to use for
29
+ # setting up instance variables/accessors for generic config files.
30
+ config_name = caller[0][/`.*'/][1..-2]
31
+
32
+ if !File.file? config_file
33
+ config_file = File.expand_path "~/#{config_file}"
34
+ end
35
+
36
+ contents = YAML.load_file config_file
37
+ if contents
38
+ config ||= Thor::CoreExt::HashWithIndifferentAccess.new(YAML.load_file(config_file))
39
+ else
40
+ config ||= Thor::CoreExt::HashWithIndifferentAccess.new
41
+ end
42
+ self.instance_variable_set("@#{config_name}", config)
43
+ end
44
+
45
+
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,18 @@
1
+ module Redmine
2
+ class Field
3
+ def initialize(title, ref, dm=nil)
4
+ @title=title # The string displayed in the listing table for this field
5
+ @ref=ref # The attribute referenced from the issue record
6
+ @display_method=dm # A method used to process the value before displaying (optional)
7
+ end
8
+ def title
9
+ return @title
10
+ end
11
+ def ref
12
+ return @ref
13
+ end
14
+ def display
15
+ return @display_method
16
+ end
17
+ end
18
+ end
@@ -2,6 +2,8 @@ url: "<%= url %>"
2
2
  username: "<%= username %>"
3
3
  password: "<%= password %>"
4
4
  default_project_id: 1
5
+ disable_caching: false
6
+ colorize: true
5
7
  user_mappings:
6
8
  "admin": 1
7
9
  status_mappings:
@@ -11,3 +13,8 @@ status_mappings:
11
13
  "feedback": 4
12
14
  "closed": 5
13
15
  "rejected": 6
16
+ list_fields:
17
+ - <%= list_fields.join("\n - ") %>
18
+ tracker_mappings:
19
+ "bug" : 1
20
+ "feature": 2
@@ -9,9 +9,10 @@ module Redmine::Cli::Generators
9
9
  File.dirname(__FILE__)
10
10
  end
11
11
 
12
- argument :url, :type => :string
13
- argument :username, :type => :string
14
- argument :password, :type => :string
12
+ argument :url, :type => :string
13
+ argument :username, :type => :string
14
+ argument :password, :type => :string
15
+ argument :list_fields, :type => :array
15
16
  class_option :test, :type => :boolean
16
17
  def copy_configuration_file
17
18
  self.destination_root = File.expand_path("~") unless options.test
@@ -1,5 +1,5 @@
1
1
  require 'thor'
2
- require 'redmine-cli/issue'
2
+ require 'redmine-cli/resources'
3
3
 
4
4
  module Redmine
5
5
  module Cli
@@ -0,0 +1,64 @@
1
+ require 'thor'
2
+ require 'active_resource'
3
+ require 'active_support/core_ext/object/with_options'
4
+ require 'redmine-cli/config'
5
+ require 'pp'
6
+
7
+ module Redmine
8
+ module Cli
9
+ class BaseResource < ActiveResource::Base
10
+ self.site = Redmine::Cli::config.url
11
+ self.user = Redmine::Cli::config.username
12
+ self.password = Redmine::Cli::config.password
13
+
14
+ class << self
15
+ # HACK: Redmine API isn't ActiveResource-friendly out of the box, so
16
+ # we need to pass nometa=1 to all requests since we don't care about
17
+ # the metadata that gets passed back in the top level attributes.
18
+ def find(*arguments)
19
+ arguments[1] = arguments[1] || {}
20
+ arguments[1][:params] = arguments[1][:params] || {}
21
+ arguments[1][:params][:nometa] = 1
22
+
23
+ super
24
+ end
25
+
26
+ def fetch_all(params = {})
27
+ limit = 100
28
+ offset = 0
29
+
30
+ resources = []
31
+
32
+ while((fetched_resources = self.all(:params => params.merge({:limit => limit, :offset => offset}))).any?)
33
+ resources += fetched_resources
34
+ offset += limit
35
+ end
36
+
37
+ resources
38
+ end
39
+ end
40
+ end
41
+
42
+ class Issue < BaseResource; end
43
+ class User < BaseResource; end
44
+ class Project < BaseResource; end
45
+ class Query < BaseResource; end
46
+ end
47
+ end
48
+
49
+
50
+ # HACK: Redmine API isn't ActiveResource-friendly out of the box, and
51
+ # also some versions of Redmine ignore the nometa=1 parameter. So we
52
+ # need to manually strip out metadata that confuses ActiveResource.
53
+ class Hash
54
+ class << self
55
+ alias_method :from_xml_original, :from_xml
56
+ def from_xml(xml)
57
+ scrubbed = scrub_attributes(xml)
58
+ from_xml_original(scrubbed)
59
+ end
60
+ def scrub_attributes(xml)
61
+ xml.gsub(/<issues .*?>/, "<issues type=\"array\">")
62
+ end
63
+ end
64
+ end
@@ -1,5 +1,5 @@
1
1
  module Redmine
2
2
  module Cli
3
- VERSION = "0.1.1"
3
+ VERSION = "0.1.4"
4
4
  end
5
5
  end
data/redmine-cli.gemspec CHANGED
@@ -21,8 +21,13 @@ Gem::Specification.new do |s|
21
21
 
22
22
  s.add_dependency "activeresource", "~>3.0.0"
23
23
  s.add_dependency "thor"
24
- s.add_development_dependency "ruby-debug"
24
+ if RUBY_VERSION =~ /1.9/
25
+ s.add_development_dependency "ruby-debug19"
26
+ else
27
+ s.add_development_dependency "ruby-debug"
28
+ end
25
29
  s.add_development_dependency "rspec"
26
30
  s.add_development_dependency "cucumber"
27
31
  s.add_development_dependency "aruba"
32
+ s.add_dependency "interactive_editor"
28
33
  end
metadata CHANGED
@@ -1,13 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redmine-cli
3
3
  version: !ruby/object:Gem::Version
4
- hash: 25
5
- prerelease: false
6
- segments:
7
- - 0
8
- - 1
9
- - 1
10
- version: 0.1.1
4
+ version: 0.1.4
11
5
  platform: ruby
12
6
  authors:
13
7
  - Jorge Dias
@@ -15,22 +9,15 @@ autorequire:
15
9
  bindir: bin
16
10
  cert_chain: []
17
11
 
18
- date: 2010-11-01 00:00:00 +01:00
19
- default_executable:
12
+ date: 2015-02-25 00:00:00 Z
20
13
  dependencies:
21
14
  - !ruby/object:Gem::Dependency
22
15
  name: activeresource
23
16
  prerelease: false
24
17
  requirement: &id001 !ruby/object:Gem::Requirement
25
- none: false
26
18
  requirements:
27
19
  - - ~>
28
20
  - !ruby/object:Gem::Version
29
- hash: 7
30
- segments:
31
- - 3
32
- - 0
33
- - 0
34
21
  version: 3.0.0
35
22
  type: :runtime
36
23
  version_requirements: *id001
@@ -38,72 +25,53 @@ dependencies:
38
25
  name: thor
39
26
  prerelease: false
40
27
  requirement: &id002 !ruby/object:Gem::Requirement
41
- none: false
42
28
  requirements:
43
- - - ">="
29
+ - &id003
30
+ - ">="
44
31
  - !ruby/object:Gem::Version
45
- hash: 3
46
- segments:
47
- - 0
48
32
  version: "0"
49
33
  type: :runtime
50
34
  version_requirements: *id002
51
35
  - !ruby/object:Gem::Dependency
52
36
  name: ruby-debug
53
37
  prerelease: false
54
- requirement: &id003 !ruby/object:Gem::Requirement
55
- none: false
38
+ requirement: &id004 !ruby/object:Gem::Requirement
56
39
  requirements:
57
- - - ">="
58
- - !ruby/object:Gem::Version
59
- hash: 3
60
- segments:
61
- - 0
62
- version: "0"
40
+ - *id003
63
41
  type: :development
64
- version_requirements: *id003
42
+ version_requirements: *id004
65
43
  - !ruby/object:Gem::Dependency
66
44
  name: rspec
67
45
  prerelease: false
68
- requirement: &id004 !ruby/object:Gem::Requirement
69
- none: false
46
+ requirement: &id005 !ruby/object:Gem::Requirement
70
47
  requirements:
71
- - - ">="
72
- - !ruby/object:Gem::Version
73
- hash: 3
74
- segments:
75
- - 0
76
- version: "0"
48
+ - *id003
77
49
  type: :development
78
- version_requirements: *id004
50
+ version_requirements: *id005
79
51
  - !ruby/object:Gem::Dependency
80
52
  name: cucumber
81
53
  prerelease: false
82
- requirement: &id005 !ruby/object:Gem::Requirement
83
- none: false
54
+ requirement: &id006 !ruby/object:Gem::Requirement
84
55
  requirements:
85
- - - ">="
86
- - !ruby/object:Gem::Version
87
- hash: 3
88
- segments:
89
- - 0
90
- version: "0"
56
+ - *id003
91
57
  type: :development
92
- version_requirements: *id005
58
+ version_requirements: *id006
93
59
  - !ruby/object:Gem::Dependency
94
60
  name: aruba
95
61
  prerelease: false
96
- requirement: &id006 !ruby/object:Gem::Requirement
97
- none: false
62
+ requirement: &id007 !ruby/object:Gem::Requirement
98
63
  requirements:
99
- - - ">="
100
- - !ruby/object:Gem::Version
101
- hash: 3
102
- segments:
103
- - 0
104
- version: "0"
64
+ - *id003
105
65
  type: :development
106
- version_requirements: *id006
66
+ version_requirements: *id007
67
+ - !ruby/object:Gem::Dependency
68
+ name: interactive_editor
69
+ prerelease: false
70
+ requirement: &id008 !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - *id003
73
+ type: :runtime
74
+ version_requirements: *id008
107
75
  description: A simple command line interface for redmine for easy scripting
108
76
  email:
109
77
  - jorge@mrdias.com
@@ -118,7 +86,8 @@ files:
118
86
  - .gitignore
119
87
  - Gemfile
120
88
  - Gemfile.lock
121
- - README.markdown
89
+ - LICENSE
90
+ - README.md
122
91
  - Rakefile
123
92
  - bin/git-redmine
124
93
  - bin/redmine
@@ -127,45 +96,39 @@ files:
127
96
  - features/support/setup.rb
128
97
  - lib/redmine-cli.rb
129
98
  - lib/redmine-cli/cli.rb
99
+ - lib/redmine-cli/config.rb
100
+ - lib/redmine-cli/field.rb
130
101
  - lib/redmine-cli/generators/.redmine
131
102
  - lib/redmine-cli/generators/install.rb
132
103
  - lib/redmine-cli/git.rb
133
- - lib/redmine-cli/issue.rb
104
+ - lib/redmine-cli/resources.rb
134
105
  - lib/redmine-cli/version.rb
135
106
  - redmine-cli.gemspec
136
- has_rdoc: true
137
107
  homepage: http://rubygems.org/gems/redmine-cli
138
108
  licenses: []
139
109
 
110
+ metadata: {}
111
+
140
112
  post_install_message:
141
113
  rdoc_options: []
142
114
 
143
115
  require_paths:
144
116
  - lib
145
117
  required_ruby_version: !ruby/object:Gem::Requirement
146
- none: false
147
118
  requirements:
148
- - - ">="
149
- - !ruby/object:Gem::Version
150
- hash: 3
151
- segments:
152
- - 0
153
- version: "0"
119
+ - *id003
154
120
  required_rubygems_version: !ruby/object:Gem::Requirement
155
- none: false
156
121
  requirements:
157
- - - ">="
158
- - !ruby/object:Gem::Version
159
- hash: 3
160
- segments:
161
- - 0
162
- version: "0"
122
+ - *id003
163
123
  requirements: []
164
124
 
165
125
  rubyforge_project: redmine-cli
166
- rubygems_version: 1.3.7
126
+ rubygems_version: 2.1.11
167
127
  signing_key:
168
- specification_version: 3
128
+ specification_version: 4
169
129
  summary: Command line interface for redmine
170
- test_files: []
171
-
130
+ test_files:
131
+ - features/install.feature
132
+ - features/step_definitions/install_steps.rb
133
+ - features/support/setup.rb
134
+ has_rdoc:
data/README.markdown DELETED
@@ -1,44 +0,0 @@
1
- # A command line interface for redmine
2
-
3
- ## Install
4
-
5
- Execute
6
- redmine install
7
-
8
- This will create a .redmine file in your home directory. The file is a yaml file which contains our necessary configuration
9
-
10
- Here you can add mappings for users and status like:
11
-
12
- user_mappings:
13
- "me": 1
14
- "johndoe": 24
15
- status_mappings:
16
- "new": 1
17
- "closed": 4
18
-
19
- This will allow to use those names with the commands instead of the ids of users or status
20
-
21
- ## Use cases
22
-
23
- - Listing tickets
24
-
25
- redmine list
26
- redmine list -a me
27
-
28
- - Display ticket
29
-
30
- redmine show 524
31
-
32
- - Updating a ticket
33
-
34
- redmine update 524 -description "New description"
35
- redmine update 256 --assigned_to me
36
-
37
- - Updating multiple tickets
38
-
39
- redmine update 2 3 4 --assigned_to johndoe
40
-
41
- - Updating all tickets for a list
42
-
43
- redmine list --status new --std_output | xargs redmine update --asigned_to me --status 3 -l
44
- \# Note that the last argument of the update command must be -l
@@ -1,22 +0,0 @@
1
- require 'thor'
2
- require 'active_resource'
3
-
4
- module Redmine
5
- module Cli
6
- class Issue < ActiveResource::Base
7
- def self.config
8
- @config ||=
9
- begin
10
- Thor::CoreExt::HashWithIndifferentAccess.new(YAML.load_file(File.expand_path("~/.redmine")))
11
- rescue Errno::ENOENT
12
- puts "You need to create the file .redmine in your home with your username, password and url"
13
- Thor::CoreExt::HashWithIndifferentAccess.new
14
- end
15
- end
16
-
17
- self.site = config.url
18
- self.user = config.username
19
- self.password = config.password
20
- end
21
- end
22
- end