ursm-ditz 0.4 → 0.5

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 (50) hide show
  1. data/Changelog +15 -0
  2. data/INSTALL +20 -0
  3. data/LICENSE +674 -0
  4. data/Manifest.txt +40 -0
  5. data/PLUGINS.txt +140 -0
  6. data/README.txt +43 -27
  7. data/Rakefile +36 -3
  8. data/ReleaseNotes +6 -0
  9. data/bin/ditz +49 -63
  10. data/contrib/completion/ditz.bash +25 -9
  11. data/lib/ditz.rb +52 -7
  12. data/lib/ditz/file-storage.rb +54 -0
  13. data/lib/{hook.rb → ditz/hook.rb} +1 -1
  14. data/lib/{html.rb → ditz/html.rb} +42 -4
  15. data/lib/{lowline.rb → ditz/lowline.rb} +31 -11
  16. data/lib/{model-objects.rb → ditz/model-objects.rb} +53 -21
  17. data/lib/ditz/model.rb +321 -0
  18. data/lib/{operator.rb → ditz/operator.rb} +122 -67
  19. data/lib/ditz/plugins/git-sync.rb +83 -0
  20. data/lib/{plugins → ditz/plugins}/git.rb +57 -18
  21. data/lib/ditz/plugins/issue-claiming.rb +174 -0
  22. data/lib/ditz/plugins/issue-labeling.rb +161 -0
  23. data/lib/{util.rb → ditz/util.rb} +4 -0
  24. data/lib/{view.rb → ditz/view.rb} +0 -0
  25. data/lib/{views.rb → ditz/views.rb} +7 -4
  26. data/man/{ditz.1 → man1/ditz.1} +1 -1
  27. data/setup.rb +1585 -0
  28. data/share/ditz/blue-check.png +0 -0
  29. data/{lib → share/ditz}/component.rhtml +7 -5
  30. data/share/ditz/green-bar.png +0 -0
  31. data/share/ditz/green-check.png +0 -0
  32. data/share/ditz/index.rhtml +130 -0
  33. data/share/ditz/issue.rhtml +119 -0
  34. data/share/ditz/issue_table.rhtml +28 -0
  35. data/share/ditz/red-check.png +0 -0
  36. data/share/ditz/release.rhtml +98 -0
  37. data/share/ditz/style.css +226 -0
  38. data/share/ditz/unassigned.rhtml +23 -0
  39. data/share/ditz/yellow-bar.png +0 -0
  40. metadata +50 -28
  41. data/lib/index.rhtml +0 -113
  42. data/lib/issue.rhtml +0 -111
  43. data/lib/issue_table.rhtml +0 -33
  44. data/lib/model.rb +0 -208
  45. data/lib/plugins/issue-claiming.rb +0 -92
  46. data/lib/release.rhtml +0 -69
  47. data/lib/style.css +0 -127
  48. data/lib/trollop.rb +0 -518
  49. data/lib/unassigned.rhtml +0 -31
  50. data/lib/vendor/yaml_waml.rb +0 -28
@@ -0,0 +1,321 @@
1
+ require 'yaml'
2
+ require "yaml_waml"
3
+ require 'sha1'
4
+ require "ditz/lowline"; include Lowline
5
+ require "ditz/util"
6
+
7
+ class Time
8
+ alias :old_to_yaml :to_yaml
9
+ def to_yaml(opts = {})
10
+ self.utc.old_to_yaml(opts)
11
+ end
12
+ end
13
+
14
+ module Ditz
15
+
16
+ class ModelObject
17
+ class ModelError < StandardError; end
18
+
19
+ def initialize
20
+ @values = {}
21
+ @serialized_values = {}
22
+ self.class.fields.map { |f, opts| @values[f] = [] if opts[:multi] }
23
+ end
24
+
25
+ ## yamlability
26
+ def self.yaml_domain; "ditz.rubyforge.org,2008-03-06" end
27
+ def self.yaml_other_thing; name.split('::').last.dcfirst end
28
+ def to_yaml_type; "!#{self.class.yaml_domain}/#{self.class.yaml_other_thing}" end
29
+ def self.inherited subclass
30
+ YAML.add_domain_type(yaml_domain, subclass.yaml_other_thing) do |type, val|
31
+ o = subclass.new
32
+ val.each do |k, v|
33
+ m = "__serialized_#{k}="
34
+ if o.respond_to? m
35
+ o.send m, v
36
+ else
37
+ $stderr.puts "warning: unknown field #{k.inspect} in YAML for #{type}; ignoring"
38
+ end
39
+ end
40
+ o.class.fields.each do |f, opts|
41
+ m = "__serialized_#{f}"
42
+ if opts[:multi] && o.send(m).nil?
43
+ o.send(m + '=', [])
44
+ end
45
+ end
46
+ o.unchanged!
47
+ o
48
+ end
49
+ end
50
+
51
+ ## override these two to model per-field transformations between disk and
52
+ ## memory.
53
+ ##
54
+ ## convert disk form => memory form
55
+ def deserialized_form_of field, value
56
+ value
57
+ end
58
+
59
+ ## convert memory form => disk form
60
+ def serialized_form_of field, value
61
+ value
62
+ end
63
+
64
+ ## Add a field to a model object
65
+ ##
66
+ ## The options you specify here determine how the field is populated when an
67
+ ## instance of this object is created. Objects can be created interactively,
68
+ ## with #create_interactively, or non-interactively, with #create, and the
69
+ ## creation mode, combined with these options, determine how the field is
70
+ ## populated on a new model object.
71
+ ##
72
+ ## The default behavior is to simply prompt the user with the field name when
73
+ ## in interactive mode, and to raise an exception if the value is not passed
74
+ ## to #create in non-interactive mode.
75
+ ##
76
+ ## Options:
77
+ ## :interactive_generator => a method name or Proc that will be called to
78
+ ## return the value of this field, if the model object is created
79
+ ## interactively.
80
+ ## :generator => a method name or Proc that will be called to return the
81
+ ## value of this field. If the model object is created interactively, and
82
+ ## a :interactive_generator option is specified, that will be used instead.
83
+ ## :multi => a boolean determining whether the field has multiple values,
84
+ ## i.e., is an array. If created with :ask => false, will be initialized
85
+ ## to [] instead of to nil. Additionally, the model object will have
86
+ ## #add_<field> and #drop_<field> methods.
87
+ ## :ask => a boolean determining whether, if the model object is created
88
+ ## interactively, the user will be prompted for the value of this field.
89
+ ## TRUE BY DEFAULT. If :interactive_generator or :generator are specified,
90
+ ## those will be called instead.
91
+ ##
92
+ ## If this is true, non-interactive creation
93
+ ## will raise an exception unless the field value is passed as an argument.
94
+ ## If this is false, non-interactive creation will initialize this to nil
95
+ ## (or [] if this field is additionally marked :multi) unless the value is
96
+ ## passed as an argument.
97
+ ## :prompt => a string to display to the user when prompting for the field
98
+ ## value during interactive creation. Not used if :generator or
99
+ ## :interactive_generator is specified.
100
+ ## :multiline => a boolean determining whether to prompt the user for a
101
+ ## multiline answer during interactive creation. Default false. Not used
102
+ ## if :generator or :interactive_generator is specified.
103
+ ## :default => a default value when prompting for the field value during
104
+ ## interactive creation. Not used if :generator, :interactive_generator,
105
+ ## :multiline, or :default_generator is specified.
106
+ ## :default_generator => a method name or Proc which will be called to
107
+ ## generate the default value when prompting for the field value during
108
+ ## interactive creation. Not used if :generator, :interactive_generator,
109
+ ## or :multiline is specified.
110
+ ## :nil_ok => a boolean determining whether, if created in non-interactive
111
+ ## mode and the value for this field is not passed in, (or is passed in
112
+ ## as nil), that's ok. Default is false. This is not necessary if :ask =>
113
+ ## false is specified; it's only necessary for fields that you want an
114
+ ## interactive prompt for, but a nil value is fine.
115
+ def self.field name, opts={}
116
+ @fields ||= [] # can't use a hash because we need to preserve field order
117
+ raise ModelError, "field with name #{name} already defined" if @fields.any? { |k, v| k == name }
118
+ @fields << [name, opts]
119
+
120
+ if opts[:multi]
121
+ single_name = name.to_s.sub(/s$/, "") # oh yeah
122
+ define_method "add_#{single_name}" do |obj|
123
+ array = send(name)
124
+ raise ModelError, "already has a #{single_name} with name #{obj.name.inspect}" if obj.respond_to?(:name) && array.any? { |o| o.name == obj.name }
125
+ changed!
126
+ @serialized_values.delete name
127
+ array << obj
128
+ end
129
+
130
+ define_method "drop_#{single_name}" do |obj|
131
+ return unless send(name).delete obj
132
+ @serialized_values.delete name
133
+ changed!
134
+ obj
135
+ end
136
+ end
137
+
138
+ define_method "#{name}=" do |o|
139
+ changed!
140
+ @serialized_values.delete name
141
+ @values[name] = o
142
+ end
143
+
144
+ define_method "__serialized_#{name}=" do |o|
145
+ changed!
146
+ @values.delete name
147
+ @serialized_values[name] = o
148
+ end
149
+
150
+ define_method "__serialized_#{name}" do
151
+ @serialized_values[name]
152
+ end
153
+
154
+ define_method name do
155
+ return @values[name] if @values.member?(name)
156
+ @values[name] = deserialized_form_of name, @serialized_values[name]
157
+ end
158
+ end
159
+
160
+ def self.field_names; @fields.map { |name, opts| name } end
161
+ class << self
162
+ attr_reader :fields, :values, :serialized_values
163
+ end
164
+
165
+ def self.changes_are_logged
166
+ define_method(:changes_are_logged?) { true }
167
+ field :log_events, :multi => true, :ask => false
168
+ end
169
+
170
+ def self.from fn
171
+ returning YAML::load_file(fn) do |o|
172
+ raise ModelError, "error loading from yaml file #{fn.inspect}: expected a #{self}, got a #{o.class}" unless o.class == self
173
+ o.pathname = fn if o.respond_to? :pathname=
174
+ end
175
+ end
176
+
177
+ def to_s
178
+ "<#{self.class.name}: " + self.class.field_names.map { |f| "#{f}: " + send(f).inspect }.join(", ") + ">"
179
+ end
180
+
181
+ def inspect; to_s end
182
+
183
+ ## depth-first search on all reachable ModelObjects. fuck yeah.
184
+ def each_modelobject
185
+ seen = {}
186
+ to_see = [self]
187
+ until to_see.empty?
188
+ cur = to_see.pop
189
+ seen[cur] = true
190
+ yield cur
191
+ cur.class.field_names.each do |f|
192
+ val = cur.send(f)
193
+ next if seen[val]
194
+ if val.is_a?(ModelObject)
195
+ to_see.push val
196
+ elsif val.is_a?(Array)
197
+ to_see += val.select { |v| v.is_a?(ModelObject) }
198
+ end
199
+ end
200
+ end
201
+ end
202
+
203
+ def save! fn
204
+ #FileUtils.mv fn, "#{fn}~", :force => true rescue nil
205
+ File.open(fn, "w") { |f| f.puts to_yaml }
206
+ self
207
+ end
208
+
209
+ def to_yaml opts={}
210
+ ret = YAML::quick_emit(object_id, opts) do |out|
211
+ out.map(taguri, nil) do |map|
212
+ self.class.fields.each do |f, fops|
213
+ v = if @serialized_values.member?(f)
214
+ @serialized_values[f]
215
+ else
216
+ @serialized_values[f] = serialized_form_of f, @values[f]
217
+ end
218
+
219
+ map.add f.to_s, v
220
+ end
221
+ end
222
+ end
223
+ YamlWaml.decode(ret)
224
+ end
225
+
226
+ def log what, who, comment
227
+ add_log_event([Time.now, who, what, comment || ""])
228
+ self
229
+ end
230
+
231
+ def changed?; @changed ||= false end
232
+ def changed!; @changed = true end
233
+ def unchanged!; @changed = false end
234
+
235
+ class << self
236
+ ## creates the object, prompting the user when necessary. can take
237
+ ## a :with => { hash } parameter for pre-filling model fields.
238
+ ##
239
+ ## can also take a :defaults_from => obj parameter for pre-filling model
240
+ ## fields from another object with (some of) those fields. kinda like a
241
+ ## bizarre interactive copy constructor.
242
+ def create_interactively opts={}
243
+ o = self.new
244
+ generator_args = opts[:args] || []
245
+ @fields.each do |name, field_opts|
246
+ val = if opts[:with] && opts[:with][name]
247
+ opts[:with][name]
248
+ elsif(found, v = generate_field_value(o, field_opts, generator_args, :interactive => true)) && found
249
+ v
250
+ else
251
+ q = field_opts[:prompt] || name.to_s.capitalize
252
+ if field_opts[:multiline]
253
+ ## multiline options currently aren't allowed to have a default
254
+ ## value, so just ask.
255
+ ask_multiline_smartly q
256
+ else
257
+ default = if opts[:defaults_from] && opts[:defaults_from].respond_to?(name) && (x = opts[:defaults_from].send(name))
258
+ x
259
+ else
260
+ default = generate_field_default o, field_opts, generator_args
261
+ end
262
+ ask q, :default => default
263
+ end
264
+ end
265
+ o.send "#{name}=", val
266
+ end
267
+ o
268
+ end
269
+
270
+ ## creates the object, filling in fields from 'vals', and throwing a
271
+ ## ModelError when it can't find all the requisite fields
272
+ def create vals={}, generator_args=[]
273
+ o = self.new
274
+ @fields.each do |fname, fopts|
275
+ val = if(x = vals[fname] || vals[fname.to_s])
276
+ x
277
+ elsif(found, x = generate_field_value(o, fopts, generator_args, :interactive => false)) && found
278
+ x
279
+ elsif !fopts[:nil_ok]
280
+ raise ModelError, "missing required field #{fname.inspect} on #{self.name} object (got #{vals.keys.inspect})"
281
+ end
282
+ o.send "#{fname}=", val if val
283
+ end
284
+ o
285
+ end
286
+
287
+ private
288
+
289
+ ## get the value for a field if it can be automatically determined
290
+ ## returns [success, value] (because a successful value can be nil)
291
+ def generate_field_value o, field_opts, args, opts={}
292
+ gen = if opts[:interactive]
293
+ field_opts[:interactive_generator] || field_opts[:generator]
294
+ else
295
+ field_opts[:generator]
296
+ end
297
+
298
+ if gen.is_a? Proc
299
+ [true, gen.call(*args)]
300
+ elsif gen
301
+ [true, o.send(gen, *args)]
302
+ elsif field_opts[:ask] == false # nil counts as true here
303
+ [true, field_opts[:default] || (field_opts[:multi] ? [] : nil)]
304
+ else
305
+ [false, nil]
306
+ end
307
+ end
308
+
309
+ def generate_field_default o, field_opts, args
310
+ if field_opts[:default_generator].is_a? Proc
311
+ field_opts[:default_generator].call(*args)
312
+ elsif field_opts[:default_generator]
313
+ o.send field_opts[:default_generator], *args
314
+ elsif field_opts[:default]
315
+ field_opts[:default]
316
+ end
317
+ end
318
+ end
319
+ end
320
+
321
+ end
@@ -134,8 +134,9 @@ class Operator
134
134
  if opts[:cow]
135
135
  puts "MOO!"
136
136
  puts "All is well with the world now. A bit more methane though."
137
- exit 0
137
+ return
138
138
  end
139
+ run_pager
139
140
  return help_single(command) if command
140
141
  puts <<EOS
141
142
  Ditz commands:
@@ -170,14 +171,35 @@ Usage: ditz #{name} #{args}
170
171
  EOS
171
172
  end
172
173
 
173
- operation :add, "Add an issue"
174
- def add project, config
174
+ ## gets a comment from the user, assuming the standard argument setup
175
+ def get_comment opts
176
+ comment = if opts[:no_comment]
177
+ nil
178
+ elsif opts[:comment]
179
+ opts[:comment]
180
+ else
181
+ ask_multiline_smartly "Comments"
182
+ end
183
+ end
184
+ private :get_comment
185
+
186
+ operation :reconfigure, "Rerun configuration script"
187
+ def reconfigure project, config
188
+ new_config = Config.create_interactively :defaults_from => config
189
+ new_config.save! $opts[:config_file]
190
+ puts "Configuration written."
191
+ end
192
+
193
+ operation :add, "Add an issue" do
194
+ opt :comment, "Specify a comment", :short => 'm', :type => String
195
+ opt :no_comment, "Skip asking for a comment", :default => false
196
+ end
197
+ def add project, config, opts
175
198
  issue = Issue.create_interactively(:args => [config, project]) or return
176
- comment = ask_multiline "Comments" unless $opts[:no_comment]
177
- issue.log "created", config.user, comment
199
+ issue.log "created", config.user, get_comment(opts)
178
200
  project.add_issue issue
179
201
  project.assign_issue_names!
180
- puts "Added issue #{issue.name}."
202
+ puts "Added issue #{issue.name} (#{issue.id})."
181
203
  end
182
204
 
183
205
  operation :drop, "Drop an issue", :issue
@@ -186,12 +208,14 @@ EOS
186
208
  puts "Dropped #{issue.name}. Note that other issue names may have changed."
187
209
  end
188
210
 
189
- operation :add_release, "Add a release", :maybe_name
190
- def add_release project, config, maybe_name
211
+ operation :add_release, "Add a release", :maybe_name do
212
+ opt :comment, "Specify a comment", :short => 'm', :type => String
213
+ opt :no_comment, "Skip asking for a comment", :default => false
214
+ end
215
+ def add_release project, config, opts, maybe_name
191
216
  puts "Adding release #{maybe_name}." if maybe_name
192
217
  release = Release.create_interactively(:args => [project, config], :with => { :name => maybe_name }) or return
193
- comment = ask_multiline "Comments" unless $opts[:no_comment]
194
- release.log "created", config.user, comment
218
+ release.log "created", config.user, get_comment(opts)
195
219
  project.add_release release
196
220
  puts "Added release #{release.name}."
197
221
  end
@@ -203,18 +227,21 @@ EOS
203
227
  puts "Added component #{component.name}."
204
228
  end
205
229
 
206
- operation :add_reference, "Add a reference to an issue", :issue
207
- def add_reference project, config, issue
230
+ operation :add_reference, "Add a reference to an issue", :issue do
231
+ opt :comment, "Specify a comment", :short => 'm', :type => String
232
+ opt :no_comment, "Skip asking for a comment", :default => false
233
+ end
234
+ def add_reference project, config, opts, issue
208
235
  puts "Adding a reference to #{issue.name}: #{issue.title}."
209
236
  reference = ask "Reference"
210
- comment = ask_multiline "Comments" unless $opts[:no_comment]
211
237
  issue.add_reference reference
212
- issue.log "added reference #{issue.references.size}", config.user, comment
238
+ issue.log "added reference #{issue.references.size}", config.user, get_comment(opts)
213
239
  puts "Added reference to #{issue.name}."
214
240
  end
215
241
 
216
242
  operation :status, "Show project status", :maybe_release
217
243
  def status project, config, releases
244
+ run_pager
218
245
  releases ||= project.unreleased_releases + [:unassigned]
219
246
 
220
247
  if releases.empty?
@@ -233,7 +260,9 @@ EOS
233
260
  "%2d/%2d %s" % [nc, num, type.to_s.pluralize(num, false)]
234
261
  end
235
262
 
236
- bar = if r != :unassigned && r.released?
263
+ bar = if r == :unassigned
264
+ ""
265
+ elsif r.released?
237
266
  "(released)"
238
267
  elsif issues.empty?
239
268
  "(no issues)"
@@ -273,23 +302,25 @@ EOS
273
302
  join
274
303
  end
275
304
 
276
- def todo_list_for issues
305
+ def todo_list_for issues, opts={}
277
306
  return if issues.empty?
278
307
  name_len = issues.max_of { |i| i.name.length }
279
308
  issues.map do |i|
280
- sprintf "%s %#{name_len}s: %s\n", i.status_widget, i.name, i.title
309
+ s = sprintf "%s %#{name_len}s: %s", i.status_widget, i.name, i.title
310
+ s += " [#{i.release}]" if opts[:show_release] && i.release
311
+ s + "\n"
281
312
  end.join
282
313
  end
283
314
 
284
315
  def print_todo_list_by_release_for project, issues
285
316
  by_release = issues.inject({}) do |h, i|
286
- r = project.release_for i.release
317
+ r = project.release_for(i.release) || :unassigned
287
318
  h[r] ||= []
288
319
  h[r] << i
289
320
  h
290
321
  end
291
322
 
292
- project.releases.each do |r|
323
+ (project.releases + [:unassigned]).each do |r|
293
324
  next unless by_release.member? r
294
325
  puts r == :unassigned ? "Unassigned:" : "#{r.name} (#{r.status}):"
295
326
  print todo_list_for(by_release[r])
@@ -297,17 +328,15 @@ EOS
297
328
  end
298
329
  end
299
330
 
300
- operation :todo, "Generate todo list", :maybe_release
301
- def todo project, config, releases
302
- actually_do_todo project, config, releases, false
331
+ operation :todo, "Generate todo list", :maybe_release do
332
+ opt :all, "Show all issues, included completed ones", :default => false
303
333
  end
304
-
305
- operation :todo_full, "Generate full todo list, including completed items", :maybe_release
306
- def todo_full project, config, releases
307
- actually_do_todo project, config, releases, true
334
+ def todo project, config, opts, releases
335
+ actually_do_todo project, config, releases, opts[:all]
308
336
  end
309
337
 
310
338
  def actually_do_todo project, config, releases, full
339
+ run_pager
311
340
  releases ||= project.unreleased_releases + [:unassigned]
312
341
  releases = [*releases]
313
342
  releases.each do |r|
@@ -324,33 +353,42 @@ EOS
324
353
  ScreenView.new(project, config).render_issue issue
325
354
  end
326
355
 
327
- operation :start, "Start work on an issue", :unstarted_issue
328
- def start project, config, issue
356
+ operation :start, "Start work on an issue", :unstarted_issue do
357
+ opt :comment, "Specify a comment", :short => 'm', :type => String
358
+ opt :no_comment, "Skip asking for a comment", :default => false
359
+ end
360
+ def start project, config, opts, issue
329
361
  puts "Starting work on issue #{issue.name}: #{issue.title}."
330
- comment = ask_multiline "Comments" unless $opts[:no_comment]
331
- issue.start_work config.user, comment
362
+ issue.start_work config.user, get_comment(opts)
332
363
  puts "Recorded start of work for #{issue.name}."
333
364
  end
334
365
 
335
- operation :stop, "Stop work on an issue", :started_issue
336
- def stop project, config, issue
366
+ operation :stop, "Stop work on an issue", :started_issue do
367
+ opt :comment, "Specify a comment", :short => 'm', :type => String
368
+ opt :no_comment, "Skip asking for a comment", :default => false
369
+ end
370
+ def stop project, config, opts, issue
337
371
  puts "Stopping work on issue #{issue.name}: #{issue.title}."
338
- comment = ask_multiline "Comments" unless $opts[:no_comment]
339
- issue.stop_work config.user, comment
372
+ issue.stop_work config.user, get_comment(opts)
340
373
  puts "Recorded work stop for #{issue.name}."
341
374
  end
342
375
 
343
- operation :close, "Close an issue", :open_issue
344
- def close project, config, issue
376
+ operation :close, "Close an issue", :open_issue do
377
+ opt :comment, "Specify a comment", :short => 'm', :type => String
378
+ opt :no_comment, "Skip asking for a comment", :default => false
379
+ end
380
+ def close project, config, opts, issue
345
381
  puts "Closing issue #{issue.name}: #{issue.title}."
346
382
  disp = ask_for_selection Issue::DISPOSITIONS, "disposition", lambda { |x| Issue::DISPOSITION_STRINGS[x] || x.to_s }
347
- comment = ask_multiline "Comments" unless $opts[:no_comment]
348
- issue.close disp, config.user, comment
383
+ issue.close disp, config.user, get_comment(opts)
349
384
  puts "Closed issue #{issue.name} with disposition #{issue.disposition_string}."
350
385
  end
351
386
 
352
- operation :assign, "Assign an issue to a release", :issue, :maybe_release
353
- def assign project, config, issue, maybe_release
387
+ operation :assign, "Assign an issue to a release", :issue, :maybe_release do
388
+ opt :comment, "Specify a comment", :short => 'm', :type => String
389
+ opt :no_comment, "Skip asking for a comment", :default => false
390
+ end
391
+ def assign project, config, opts, issue, maybe_release
354
392
  if maybe_release && maybe_release.name == issue.release
355
393
  raise Error, "issue #{issue.name} already assigned to release #{issue.release}"
356
394
  end
@@ -361,8 +399,6 @@ EOS
361
399
  "not assigned to any release."
362
400
  end
363
401
 
364
- puts "Assigning to release #{maybe_release.name}." if maybe_release
365
-
366
402
  release = maybe_release || begin
367
403
  releases = project.releases.sort_by { |r| (r.release_time || 0).to_i }
368
404
  releases -= [releases.find { |r| r.name == issue.release }] if issue.release
@@ -374,13 +410,15 @@ EOS
374
410
  end
375
411
  end
376
412
  end
377
- comment = ask_multiline "Comments" unless $opts[:no_comment]
378
- issue.assign_to_release release, config.user, comment
413
+ issue.assign_to_release release, config.user, get_comment(opts)
379
414
  puts "Assigned #{issue.name} to #{release.name}."
380
415
  end
381
416
 
382
- operation :set_component, "Set an issue's component", :issue, :maybe_component
383
- def set_component project, config, issue, maybe_component
417
+ operation :set_component, "Set an issue's component", :issue, :maybe_component do
418
+ opt :comment, "Specify a comment", :short => 'm', :type => String
419
+ opt :no_comment, "Skip asking for a comment", :default => false
420
+ end
421
+ def set_component project, config, opts, issue, maybe_component
384
422
  puts "Changing the component of issue #{issue.name}: #{issue.title}."
385
423
 
386
424
  if project.components.size == 1
@@ -396,8 +434,7 @@ EOS
396
434
  components -= [components.find { |r| r.name == issue.component }] if issue.component
397
435
  ask_for_selection(components, "component") { |r| r.name }
398
436
  end
399
- comment = ask_multiline "Comments" unless $opts[:no_comment]
400
- issue.assign_to_component component, config.user, comment
437
+ issue.assign_to_component component, config.user, get_comment(opts)
401
438
  oldname = issue.name
402
439
  project.assign_issue_names!
403
440
  puts <<EOS
@@ -406,18 +443,23 @@ have changed as well.
406
443
  EOS
407
444
  end
408
445
 
409
- operation :unassign, "Unassign an issue from any releases", :assigned_issue
410
- def unassign project, config, issue
446
+ operation :unassign, "Unassign an issue from any releases", :assigned_issue do
447
+ opt :comment, "Specify a comment", :short => 'm', :type => String
448
+ opt :no_comment, "Skip asking for a comment", :default => false
449
+ end
450
+ def unassign project, config, opts, issue
411
451
  puts "Unassigning issue #{issue.name}: #{issue.title}."
412
- comment = ask_multiline "Comments" unless $opts[:no_comment]
413
- issue.unassign config.user, comment
452
+ issue.unassign config.user, get_comment(opts)
414
453
  puts "Unassigned #{issue.name}."
415
454
  end
416
455
 
417
- operation :comment, "Comment on an issue", :issue
418
- def comment project, config, issue
456
+ operation :comment, "Comment on an issue", :issue do
457
+ opt :comment, "Specify a comment", :short => 'm', :type => String
458
+ opt :no_comment, "Skip asking for a comment", :default => false
459
+ end
460
+ def comment project, config, opts, issue
419
461
  puts "Commenting on issue #{issue.name}: #{issue.title}."
420
- comment = ask_multiline "Comments"
462
+ comment = get_comment opts
421
463
  if comment.blank?
422
464
  puts "Empty comment, aborted."
423
465
  else
@@ -428,6 +470,7 @@ EOS
428
470
 
429
471
  operation :releases, "Show releases"
430
472
  def releases project, config
473
+ run_pager
431
474
  a, b = project.releases.partition { |r| r.released? }
432
475
  (b + a.sort_by { |r| r.release_time }).each do |r|
433
476
  status = r.released? ? "released #{r.release_time.pretty_date}" : r.status
@@ -435,15 +478,18 @@ EOS
435
478
  end
436
479
  end
437
480
 
438
- operation :release, "Release a release", :unreleased_release
439
- def release project, config, release
440
- comment = ask_multiline "Comments" unless $opts[:no_comment]
441
- release.release! project, config.user, comment
481
+ operation :release, "Release a release", :unreleased_release do
482
+ opt :comment, "Specify a comment", :short => 'm', :type => String
483
+ opt :no_comment, "Skip asking for a comment", :default => false
484
+ end
485
+ def release project, config, opts, release
486
+ release.release! project, config.user, get_comment(opts)
442
487
  puts "Release #{release.name} released!"
443
488
  end
444
489
 
445
490
  operation :changelog, "Generate a changelog for a release", :release
446
491
  def changelog project, config, r
492
+ run_pager
447
493
  puts "== #{r.name} / #{r.released? ? r.release_time.pretty_date : 'unreleased'}"
448
494
  project.group_issues(project.issues_for_release(r)).each do |type, issues|
449
495
  issues.select { |i| i.closed? }.each do |i|
@@ -467,9 +513,13 @@ EOS
467
513
  ## a no-op
468
514
  end
469
515
 
470
- operation :grep, "Show issues matching a string or regular expression", :string
471
- def grep project, config, match
472
- re = /#{match}/
516
+ operation :grep, "Show issues matching a string or regular expression", :string do
517
+ opt :ignore_case, "Ignore case distinctions in both the expression and in the issue data", :default => false
518
+ end
519
+
520
+ def grep project, config, opts, match
521
+ run_pager
522
+ re = Regexp.new match, opts[:ignore_case]
473
523
  issues = project.issues.select do |i|
474
524
  i.title =~ re || i.desc =~ re ||
475
525
  i.log_events.map { |time, who, what, comments| comments }.join(" ") =~ re
@@ -479,6 +529,7 @@ EOS
479
529
 
480
530
  operation :log, "Show recent activity"
481
531
  def log project, config
532
+ run_pager
482
533
  project.issues.map { |i| i.log_events.map { |e| [e, i] } }.
483
534
  flatten_one_level.sort_by { |e| e.first.first }.reverse.
484
535
  each do |(date, author, what, comment), i|
@@ -496,6 +547,7 @@ EOS
496
547
 
497
548
  operation :shortlog, "Show recent activity (short form)"
498
549
  def shortlog project, config
550
+ run_pager
499
551
  project.issues.map { |i| i.log_events.map { |e| [e, i] } }.
500
552
  flatten_one_level.sort_by { |e| e.first.first }.reverse.
501
553
  each do |(date, author, what, comment), i|
@@ -521,8 +573,12 @@ EOS
521
573
  puts "Archived to #{dir}."
522
574
  end
523
575
 
524
- operation :edit, "Edit an issue", :issue
525
- def edit project, config, issue
576
+ operation :edit, "Edit an issue", :issue do
577
+ opt :comment, "Specify a comment", :short => 'm', :type => String
578
+ opt :no_comment, "Skip asking for a comment", :default => false
579
+ opt :silent, "Don't add a log message detailing the change", :default => false
580
+ end
581
+ def edit project, config, opts, issue
526
582
  data = { :title => issue.title, :description => issue.desc,
527
583
  :reporter => issue.reporter }
528
584
 
@@ -533,11 +589,10 @@ EOS
533
589
  return
534
590
  end
535
591
 
536
- comment = ask_multiline "Comments" unless $opts[:no_comment]
537
-
538
592
  begin
539
593
  edits = YAML.load_file fn
540
- if issue.change edits, config.user, comment
594
+ comment = opts[:silent] ? nil : get_comment(opts)
595
+ if issue.change edits, config.user, comment, opts[:silent]
541
596
  puts "Change recorded."
542
597
  else
543
598
  puts "No changes."