ohac-ditz 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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