ohac-ditz 0.5.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.
@@ -0,0 +1,655 @@
1
+ require 'fileutils'
2
+
3
+ module Ditz
4
+
5
+ class Operator
6
+ class Error < StandardError; end
7
+
8
+ class << self
9
+ def method_to_op meth; meth.to_s.gsub("_", "-") end
10
+ def op_to_method op; op.gsub("-", "_").intern end
11
+
12
+ def operation method, desc, *args_spec, &options_blk
13
+ @operations ||= {}
14
+ @operations[method] = { :desc => desc, :args_spec => args_spec,
15
+ :options_blk => options_blk }
16
+ end
17
+
18
+ def operations
19
+ @operations.map { |k, v| [method_to_op(k), v] }.sort_by { |k, v| k }
20
+ end
21
+ def has_operation? op; @operations.member? op_to_method(op) end
22
+
23
+ def build_opts method, args
24
+ options_blk = @operations[method][:options_blk]
25
+ options_blk and Trollop.options args, &options_blk or nil
26
+ end
27
+
28
+ ## parse the specs, and the commandline arguments, and resolve them. does
29
+ ## typechecking but currently doesn't check for open_issues actually being
30
+ ## open, unstarted_issues being unstarted, etc. probably will check for
31
+ ## this in the future.
32
+ def build_args project, method, args
33
+ specs = @operations[method][:args_spec]
34
+ command = "command '#{method_to_op method}'"
35
+
36
+ if specs.empty? && args == ["<options>"]
37
+ die_with_completions project, method, nil
38
+ end
39
+
40
+ built_args = specs.map do |spec|
41
+ optional = spec.to_s =~ /^maybe_/
42
+ spec = spec.to_s.gsub(/^maybe_/, "").intern # :(
43
+ val = args.shift
44
+
45
+ case val
46
+ when nil
47
+ next if optional
48
+ specname = spec.to_s.gsub("_", " ")
49
+ article = specname =~ /^[aeiou]/ ? "an" : "a"
50
+ raise Error, "#{command} requires #{article} #{specname}"
51
+ when "<options>"
52
+ die_with_completions project, method, spec
53
+ end
54
+
55
+ case spec
56
+ when :issue, :open_issue, :unstarted_issue, :started_issue, :assigned_issue
57
+ ## issue completion sticks the title on there, so this will strip it off
58
+ valr = val.sub(/\A(\w+-\d+)_.*$/,'\1')
59
+ issues = project.issues_for valr
60
+ case issues.size
61
+ when 0; raise Error, "no issue with name #{val.inspect}"
62
+ when 1; issues.first
63
+ else
64
+ raise Error, "multiple issues matching name #{val.inspect}"
65
+ end
66
+ when :release, :unreleased_release
67
+ if val == "unassigned"
68
+ :unassigned
69
+ else
70
+ project.release_for(val) or raise Error, "no release with name #{val}"
71
+ end
72
+ when :component
73
+ project.component_for(val) or raise Error, "no component with name #{val}" if val
74
+ else
75
+ val # no translation for other types
76
+ end
77
+ end
78
+
79
+ raise Error, "too many arguments for #{command}" unless args.empty?
80
+ built_args
81
+ end
82
+
83
+ def die_with_completions project, method, spec
84
+ puts(case spec
85
+ when :issue, :open_issue, :unstarted_issue, :started_issue, :assigned_issue
86
+ m = { :issue => nil,
87
+ :open_issue => :open?,
88
+ :unstarted_issue => :unstarted?,
89
+ :started_issue => :in_progress?,
90
+ :assigned_issue => :assigned?,
91
+ }[spec]
92
+ project.issues.select { |i| m.nil? || i.send(m) }.sort_by { |i| i.creation_time }.reverse.map { |i| "#{i.name}_#{i.title.gsub(/\W+/, '-')}" }
93
+ when :release
94
+ project.releases.map { |r| r.name } + ["unassigned"]
95
+ when :unreleased_release
96
+ project.releases.select { |r| r.unreleased? }.map { |r| r.name }
97
+ when :component
98
+ project.components.map { |c| c.name }
99
+ when :command
100
+ operations.map { |name, _| name }
101
+ else
102
+ ""
103
+ end)
104
+ exit 0
105
+ end
106
+ private :die_with_completions
107
+ end
108
+
109
+ def do op, project, config, args
110
+ meth = self.class.op_to_method(op)
111
+
112
+ # Parse options, removing them from args
113
+ opts = self.class.build_opts meth, args
114
+ built_args = self.class.build_args project, meth, args
115
+
116
+ built_args.unshift opts if opts
117
+
118
+ send meth, project, config, *built_args
119
+ end
120
+
121
+ %w(operations has_operation?).each do |m|
122
+ define_method(m) { |*a| self.class.send m, *a }
123
+ end
124
+
125
+ operation :init, "Initialize the issue database for a new project"
126
+ def init
127
+ Project.create_interactively
128
+ end
129
+
130
+ operation :help, "List all registered commands", :maybe_command do
131
+ opt :cow, "Activate super cow powers", :default => false
132
+ end
133
+ def help project, config, opts, command
134
+ if opts[:cow]
135
+ puts "MOO!"
136
+ puts "All is well with the world now. A bit more methane though."
137
+ return
138
+ end
139
+ run_pager config
140
+ return help_single(command) if command
141
+ puts <<EOS
142
+ Ditz commands:
143
+
144
+ EOS
145
+ ops = self.class.operations
146
+ len = ops.map { |name, op| name.to_s.length }.max
147
+ ops.each do |name, opts|
148
+ printf " %#{len}s: %s\n", name, opts[:desc]
149
+ end
150
+ puts <<EOS
151
+
152
+ Use 'ditz help <command>' for details.
153
+ EOS
154
+ end
155
+
156
+ def help_single command
157
+ name, opts = self.class.operations.find { |name, spec| name == command }
158
+ raise Error, "no such ditz command '#{command}'" unless name
159
+ args = opts[:args_spec].map do |spec|
160
+ case spec.to_s
161
+ when /^maybe_(.*)$/
162
+ "[#{$1}]"
163
+ else
164
+ "<#{spec.to_s}>"
165
+ end
166
+ end.join(" ")
167
+
168
+ puts <<EOS
169
+ #{opts[:desc]}.
170
+ Usage: ditz #{name} #{args}
171
+ EOS
172
+ end
173
+
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_or_editor "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 :quick, "Just one line issue", :short => 'q', :type => String
196
+ opt :component, "Specify a component", :short => 'c', :type => String
197
+ opt :release, "Specify a release", :short => 'r', :type => String
198
+ opt :ask_for_comment, "Ask for a comment", :default => false
199
+ end
200
+ def add project, config, opts
201
+ component = opts[:component] || project.components.first.name
202
+ release = opts[:release] || (project.releases.first.name rescue nil)
203
+ with = if opts[:quick]
204
+ {:title => opts[:quick], :desc => '', :type => :task, :component => component,
205
+ :reporter => config.user, :release => release}
206
+ end
207
+ issue = Issue.create_interactively(:args => [config, project], :with => with)
208
+ issue or return
209
+ comment = if opts[:comment]
210
+ opts[:comment]
211
+ elsif opts[:ask_for_comment]
212
+ ask_multiline_or_editor "Comments"
213
+ end
214
+ issue.log "created", config.user, comment
215
+ project.add_issue issue
216
+ project.assign_issue_names!
217
+ puts "Added issue #{issue.name} (#{issue.id})."
218
+ end
219
+
220
+ operation :drop, "Drop an issue", :issue
221
+ def drop project, config, issue
222
+ project.drop_issue issue
223
+ puts "Dropped #{issue.name}. Note that other issue names may have changed."
224
+ end
225
+
226
+ operation :drop_release, "Drop a release", :release
227
+ def drop_release project, config, release
228
+ project.drop_release release
229
+ puts "Dropped release #{release.name}."
230
+ end
231
+
232
+ operation :add_release, "Add a release", :maybe_name do
233
+ opt :comment, "Specify a comment", :short => 'm', :type => String
234
+ opt :no_comment, "Skip asking for a comment", :default => false
235
+ end
236
+ def add_release project, config, opts, maybe_name
237
+ puts "Adding release #{maybe_name}." if maybe_name
238
+ release = Release.create_interactively(:args => [project, config], :with => { :name => maybe_name }) or return
239
+ release.log "created", config.user, get_comment(opts)
240
+ project.add_release release
241
+ puts "Added release #{release.name}."
242
+ end
243
+
244
+ operation :add_component, "Add a component"
245
+ def add_component project, config
246
+ component = Component.create_interactively(:args => [project, config]) or return
247
+ project.add_component component
248
+ puts "Added component #{component.name}."
249
+ end
250
+
251
+ operation :add_reference, "Add a reference to an issue", :issue do
252
+ opt :comment, "Specify a comment", :short => 'm', :type => String
253
+ opt :no_comment, "Skip asking for a comment", :default => false
254
+ end
255
+ def add_reference project, config, opts, issue
256
+ puts "Adding a reference to #{issue.name}: #{issue.title}."
257
+ reference = ask "Reference"
258
+ issue.add_reference reference
259
+ issue.log "added reference #{issue.references.size}", config.user, get_comment(opts)
260
+ puts "Added reference to #{issue.name}."
261
+ end
262
+
263
+ operation :status, "Show project status", :maybe_release
264
+ def status project, config, releases
265
+ run_pager config
266
+ releases ||= project.unreleased_releases + [:unassigned]
267
+
268
+ if releases.empty?
269
+ puts "No releases."
270
+ return
271
+ end
272
+
273
+ entries = releases.map do |r|
274
+ title, issues = (r == :unassigned ? r.to_s : r.name), project.issues_for_release(r)
275
+
276
+ middle = Issue::TYPES.map do |type|
277
+ type_issues = issues.select { |i| i.type == type }
278
+ num = type_issues.size
279
+ nc = type_issues.count_of { |i| i.closed? }
280
+ pc = 100.0 * (type_issues.empty? ? 1.0 : nc.to_f / num)
281
+ "%2d/%2d %s" % [nc, num, type.to_s.pluralize(num, false)]
282
+ end
283
+
284
+ bar = if r == :unassigned
285
+ ""
286
+ elsif r.released?
287
+ "(released)"
288
+ elsif issues.empty?
289
+ "(no issues)"
290
+ elsif issues.all? { |i| i.closed? }
291
+ "(ready for release)"
292
+ else
293
+ status_bar_for(issues)
294
+ end
295
+
296
+ [title, middle, bar]
297
+ end
298
+
299
+ title_size = 0
300
+ middle_sizes = []
301
+
302
+ entries.each do |title, middle, bar|
303
+ title_size = [title_size, title.length].max
304
+ middle_sizes = middle.zip(middle_sizes).map do |e, s|
305
+ [s || 0, e.length].max
306
+ end
307
+ end
308
+
309
+ entries.each do |title, middle, bar|
310
+ printf "%-#{title_size}s ", title
311
+ middle.zip(middle_sizes).each_with_index do |(e, s), i|
312
+ sep = i < middle.size - 1 ? "," : ""
313
+ printf "%-#{s + sep.length}s ", e + sep
314
+ end
315
+ puts bar
316
+ end
317
+ end
318
+
319
+ def status_bar_for issues
320
+ Issue::STATUS_WIDGET.
321
+ sort_by { |k, v| -Issue::STATUS_SORT_ORDER[k] }.
322
+ map { |k, v| v * issues.count_of { |i| i.status == k } }.
323
+ join
324
+ end
325
+
326
+ def todo_list_for issues, opts={}
327
+ return if issues.empty?
328
+ name_len = issues.max_of { |i| i.name.length }
329
+ issues.map do |i|
330
+ s = sprintf "%s %#{name_len}s: %s", i.status_widget, i.name, i.title
331
+ s += " [#{i.release}]" if opts[:show_release] && i.release
332
+ s + "\n"
333
+ end.join
334
+ end
335
+
336
+ def print_todo_list_by_release_for project, issues
337
+ by_release = issues.inject({}) do |h, i|
338
+ r = project.release_for(i.release) || :unassigned
339
+ h[r] ||= []
340
+ h[r] << i
341
+ h
342
+ end
343
+
344
+ (project.releases + [:unassigned]).each do |r|
345
+ next unless by_release.member? r
346
+ puts r == :unassigned ? "Unassigned:" : "#{r.name} (#{r.status}):"
347
+ print todo_list_for(by_release[r])
348
+ puts
349
+ end
350
+ end
351
+
352
+ operation :todo, "Generate todo list", :maybe_release do
353
+ opt :all, "Show all issues, included completed ones", :default => false
354
+ end
355
+ def todo project, config, opts, releases
356
+ actually_do_todo project, config, releases, opts[:all]
357
+ end
358
+
359
+ def actually_do_todo project, config, releases, full
360
+ run_pager config
361
+ releases ||= project.unreleased_releases + [:unassigned]
362
+ releases = [*releases]
363
+ releases.each do |r|
364
+ puts r == :unassigned ? "Unassigned:" : "#{r.name} (#{r.status}):"
365
+ issues = project.issues_for_release r
366
+ issues = issues.select { |i| i.open? } unless full
367
+ puts(todo_list_for(issues.sort_by { |i| i.sort_order }) || "No open issues.")
368
+ puts
369
+ end
370
+ end
371
+
372
+ operation :show, "Describe a single issue", :issue
373
+ def show project, config, issue
374
+ ScreenView.new(project, config).render_issue issue
375
+ end
376
+
377
+ operation :start, "Start work on an issue", :unstarted_issue do
378
+ opt :comment, "Specify a comment", :short => 'm', :type => String
379
+ opt :no_comment, "Skip asking for a comment", :default => false
380
+ end
381
+ def start project, config, opts, issue
382
+ puts "Starting work on issue #{issue.name}: #{issue.title}."
383
+ issue.start_work config.user, get_comment(opts)
384
+ puts "Recorded start of work for #{issue.name}."
385
+ end
386
+
387
+ operation :stop, "Stop work on an issue", :started_issue 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 stop project, config, opts, issue
392
+ puts "Stopping work on issue #{issue.name}: #{issue.title}."
393
+ issue.stop_work config.user, get_comment(opts)
394
+ puts "Recorded work stop for #{issue.name}."
395
+ end
396
+
397
+ operation :close, "Close an issue", :open_issue do
398
+ opt :comment, "Specify a comment", :short => 'm', :type => String
399
+ opt :no_comment, "Skip asking for a comment", :default => false
400
+ end
401
+ def close project, config, opts, issue
402
+ puts "Closing issue #{issue.name}: #{issue.title}."
403
+ disp = ask_for_selection Issue::DISPOSITIONS, "disposition", lambda { |x| Issue::DISPOSITION_STRINGS[x] || x.to_s }
404
+ issue.close disp, config.user, get_comment(opts)
405
+ puts "Closed issue #{issue.name} with disposition #{issue.disposition_string}."
406
+ end
407
+
408
+ operation :assign, "Assign an issue to a release", :issue, :maybe_release do
409
+ opt :comment, "Specify a comment", :short => 'm', :type => String
410
+ opt :no_comment, "Skip asking for a comment", :default => false
411
+ end
412
+ def assign project, config, opts, issue, maybe_release
413
+ if maybe_release && maybe_release.name == issue.release
414
+ raise Error, "issue #{issue.name} already assigned to release #{issue.release}"
415
+ end
416
+
417
+ puts "Issue #{issue.name} currently " + if issue.release
418
+ "assigned to release #{issue.release}."
419
+ else
420
+ "not assigned to any release."
421
+ end
422
+
423
+ release = maybe_release || begin
424
+ releases = project.releases.sort_by { |r| (r.release_time || 0).to_i }
425
+ releases -= [releases.find { |r| r.name == issue.release }] if issue.release
426
+ ask_for_selection(releases, "release") do |r|
427
+ r.name + if r.released?
428
+ " (released #{r.release_time.pretty_date})"
429
+ else
430
+ " (unreleased)"
431
+ end
432
+ end
433
+ end
434
+ issue.assign_to_release release, config.user, get_comment(opts)
435
+ puts "Assigned #{issue.name} to #{release.name}."
436
+ end
437
+
438
+ operation :set_component, "Set an issue's component", :issue, :maybe_component do
439
+ opt :comment, "Specify a comment", :short => 'm', :type => String
440
+ opt :no_comment, "Skip asking for a comment", :default => false
441
+ end
442
+ def set_component project, config, opts, issue, maybe_component
443
+ puts "Changing the component of issue #{issue.name}: #{issue.title}."
444
+
445
+ if project.components.size == 1
446
+ raise Error, "this project does not use multiple components"
447
+ end
448
+
449
+ if maybe_component && maybe_component.name == issue.component
450
+ raise Error, "issue #{issue.name} already assigned to component #{issue.component}"
451
+ end
452
+
453
+ component = maybe_component || begin
454
+ components = project.components
455
+ components -= [components.find { |r| r.name == issue.component }] if issue.component
456
+ ask_for_selection(components, "component") { |r| r.name }
457
+ end
458
+ issue.assign_to_component component, config.user, get_comment(opts)
459
+ oldname = issue.name
460
+ project.assign_issue_names!
461
+ puts <<EOS
462
+ Issue #{oldname} is now #{issue.name}. Note that the names of other issues may
463
+ have changed as well.
464
+ EOS
465
+ end
466
+
467
+ operation :unassign, "Unassign an issue from any releases", :assigned_issue do
468
+ opt :comment, "Specify a comment", :short => 'm', :type => String
469
+ opt :no_comment, "Skip asking for a comment", :default => false
470
+ end
471
+ def unassign project, config, opts, issue
472
+ puts "Unassigning issue #{issue.name}: #{issue.title}."
473
+ issue.unassign config.user, get_comment(opts)
474
+ puts "Unassigned #{issue.name}."
475
+ end
476
+
477
+ operation :comment, "Comment on an issue", :issue do
478
+ opt :comment, "Specify a comment", :short => 'm', :type => String
479
+ opt :no_comment, "Skip asking for a comment", :default => false
480
+ end
481
+ def comment project, config, opts, issue
482
+ puts "Commenting on issue #{issue.name}: #{issue.title}."
483
+ comment = get_comment opts
484
+ if comment.blank?
485
+ puts "Empty comment, aborted."
486
+ else
487
+ issue.log "commented", config.user, comment
488
+ puts "Comments recorded for #{issue.name}."
489
+ end
490
+ end
491
+
492
+ operation :releases, "Show releases"
493
+ def releases project, config
494
+ run_pager config
495
+ a, b = project.releases.partition { |r| r.released? }
496
+ (b + a.sort_by { |r| r.release_time }).each do |r|
497
+ status = r.released? ? "released #{r.release_time.pretty_date}" : r.status
498
+ puts "#{r.name} (#{status})"
499
+ end
500
+ end
501
+
502
+ operation :release, "Release a release", :unreleased_release do
503
+ opt :comment, "Specify a comment", :short => 'm', :type => String
504
+ opt :no_comment, "Skip asking for a comment", :default => false
505
+ end
506
+ def release project, config, opts, release
507
+ release.release! project, config.user, get_comment(opts)
508
+ puts "Release #{release.name} released!"
509
+ end
510
+
511
+ operation :changelog, "Generate a changelog for a release", :release
512
+ def changelog project, config, r
513
+ run_pager config
514
+ puts "== #{r.name} / #{r.released? ? r.release_time.pretty_date : 'unreleased'}"
515
+ project.group_issues(project.issues_for_release(r)).each do |type, issues|
516
+ issues.select { |i| i.closed? }.each do |i|
517
+ if type == :bugfix
518
+ puts "* #{type}: #{i.title}"
519
+ else
520
+ puts "* #{i.title}"
521
+ end
522
+ end
523
+ end
524
+ end
525
+
526
+ operation :html, "Generate html status pages", :maybe_dir
527
+ def html project, config, dir
528
+ dir ||= "html"
529
+ HtmlView.new(project, config, dir).render_all
530
+ end
531
+
532
+ operation :rdf, "Generate baetle file", :maybe_dir
533
+ def rdf project, config, dir
534
+ dir ||= "baetle"
535
+ BaetleView.new(project, config, dir).render_all
536
+ end
537
+
538
+ COL_ID = "ID"
539
+ COL_NAME = "NAME"
540
+ COL_RELEASE = "RELEASE"
541
+ operation :list, "Show all issues"
542
+ def list project, config
543
+ issues = project.issues
544
+ return if issues.empty?
545
+
546
+ run_pager config
547
+ name_len = issues.max_of { |i| i.name.length }
548
+ name_len = COL_NAME.length if name_len < COL_NAME.length
549
+ release_len = project.releases.max_of { |i| i.name.length }
550
+ release_len = COL_RELEASE.length if !release_len || release_len < COL_RELEASE.length
551
+ s = " #{COL_ID.ljust(40)} #{COL_NAME.ljust(name_len)} #{COL_RELEASE.ljust(release_len)} TITLE\n"
552
+ issues.map do |i|
553
+ s += sprintf "%s %s %-#{name_len}s %-#{release_len}s %s\n", i.status_widget, i.id, i.name, i.release, i.title
554
+ end.join
555
+ puts s
556
+ end
557
+
558
+ operation :validate, "Validate project status"
559
+ def validate project, config
560
+ ## a no-op
561
+ end
562
+
563
+ operation :grep, "Show issues matching a string or regular expression", :string do
564
+ opt :ignore_case, "Ignore case distinctions in both the expression and in the issue data", :default => false
565
+ end
566
+
567
+ def grep project, config, opts, match
568
+ run_pager config
569
+ re = Regexp.new match, opts[:ignore_case]
570
+ issues = project.issues.select do |i|
571
+ i.title =~ re || i.desc =~ re ||
572
+ i.log_events.map { |time, who, what, comments| comments }.join(" ") =~ re
573
+ end
574
+ puts(todo_list_for(issues) || "No matching issues.")
575
+ end
576
+
577
+ operation :log, "Show recent activity"
578
+ def log project, config
579
+ run_pager config
580
+ project.issues.map { |i| i.log_events.map { |e| [e, i] } }.
581
+ flatten_one_level.sort_by { |e| e.first.first }.reverse.
582
+ each do |(date, author, what, comment), i|
583
+ puts <<EOS
584
+ date : #{date.localtime} (#{date.ago} ago)
585
+ author: #{author}
586
+ id: #{i.id}
587
+ issue: [#{i.name}] #{i.title}
588
+
589
+ #{what}
590
+ #{comment.gsub(/^/, " > ") unless comment =~ /^\A\s*\z/}
591
+ EOS
592
+ puts unless comment.blank?
593
+ end
594
+ end
595
+
596
+ operation :shortlog, "Show recent activity (short form)"
597
+ def shortlog project, config
598
+ run_pager config
599
+ project.issues.map { |i| i.log_events.map { |e| [e, i] } }.
600
+ flatten_one_level.sort_by { |e| e.first.first }.reverse.
601
+ each do |(date, author, what, comment), i|
602
+ shortauthor = if author =~ /<(.*?)@/
603
+ $1
604
+ else
605
+ author
606
+ end[0..15]
607
+ printf "%13s|%13s|%13s|%s\n", date.ago, i.name, shortauthor,
608
+ what
609
+ end
610
+ end
611
+
612
+ operation :archive, "Archive a release", :release, :maybe_dir do
613
+ opt :dir, "Specify the archive directory", :short => 'd', :type => String
614
+ end
615
+ def archive project, config, opts, release
616
+ dir = opts[:dir] || "./ditz-archive-#{release.name}"
617
+ FileUtils.mkdir dir
618
+ FileUtils.cp project.pathname, dir
619
+ project.issues_for_release(release).each do |i|
620
+ FileUtils.cp i.pathname, dir
621
+ project.drop_issue i
622
+ end
623
+ project.drop_release release
624
+ puts "Archived to #{dir}. Note that issue names may have changed."
625
+ end
626
+
627
+ operation :edit, "Edit an issue", :issue do
628
+ opt :comment, "Specify a comment", :short => 'm', :type => String
629
+ opt :no_comment, "Skip asking for a comment", :default => false
630
+ opt :silent, "Don't add a log message detailing the change", :default => false
631
+ end
632
+ def edit project, config, opts, issue
633
+ data = { :title => issue.title, :description => issue.desc,
634
+ :reporter => issue.reporter }
635
+
636
+ fn = run_editor { |f| f.puts data.to_yaml }
637
+
638
+ unless fn
639
+ puts "Aborted."
640
+ return
641
+ end
642
+
643
+ begin
644
+ edits = YAML.load_file fn
645
+ comment = opts[:silent] ? nil : get_comment(opts)
646
+ if issue.change edits, config.user, comment, opts[:silent]
647
+ puts "Change recorded."
648
+ else
649
+ puts "No changes."
650
+ end
651
+ end
652
+ end
653
+ end
654
+
655
+ end