ditz-str 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. data/.document +5 -0
  2. data/Gemfile +14 -0
  3. data/Gemfile.lock +24 -0
  4. data/LICENSE +674 -0
  5. data/LICENSE.txt +20 -0
  6. data/README.rdoc +19 -0
  7. data/README.txt +143 -0
  8. data/Rakefile +55 -0
  9. data/VERSION +1 -0
  10. data/bin/ditz-str +189 -0
  11. data/bugs/issue-02615b8c3dd0382c92f350ce2158ecfe94d11ef8.yaml +22 -0
  12. data/bugs/issue-06a3bbf35a60c4da2d8ea0fdc86164263126d6b2.yaml +22 -0
  13. data/bugs/issue-0c00c1d7fdffaad304e62d79d9b3d5e92547055b.yaml +38 -0
  14. data/bugs/issue-20dad4b4533d6d76d496fe5970098f1eb8efd561.yaml +26 -0
  15. data/bugs/issue-360ae6529dbc66358fde6b532cbea79ece37a670.yaml +22 -0
  16. data/bugs/issue-5177d61bf3c2783f71ef63e6e2c5e720247ef699.yaml +18 -0
  17. data/bugs/issue-695b564c210da1965a2bb38eef782178aead6952.yaml +26 -0
  18. data/bugs/issue-7d0ce6429a9fb5fa09ce3376a8921a5ecb7ecfe5.yaml +34 -0
  19. data/bugs/issue-a04462fa22ab6e1b02cfdd052d1f6c6f491f08f5.yaml +22 -0
  20. data/bugs/issue-bca54ca5107eabc3b281701041cc36ea0641cbdd.yaml +26 -0
  21. data/bugs/issue-d0c7d04b014d705c5fd865e4d487b5e5b6983c33.yaml +26 -0
  22. data/bugs/issue-f94b879842aa0274aa74fc2833252d4a06ec65cc.yaml +22 -0
  23. data/bugs/project.yaml +18 -0
  24. data/data/ditz-str/blue-check.png +0 -0
  25. data/data/ditz-str/close.rhtml +39 -0
  26. data/data/ditz-str/component.rhtml +38 -0
  27. data/data/ditz-str/dropdown.css +11 -0
  28. data/data/ditz-str/dropdown.js +58 -0
  29. data/data/ditz-str/edit_issue.rhtml +53 -0
  30. data/data/ditz-str/green-bar.png +0 -0
  31. data/data/ditz-str/green-check.png +0 -0
  32. data/data/ditz-str/header.gif +0 -0
  33. data/data/ditz-str/header_over.gif +0 -0
  34. data/data/ditz-str/index.rhtml +148 -0
  35. data/data/ditz-str/issue.rhtml +152 -0
  36. data/data/ditz-str/issue_table.rhtml +28 -0
  37. data/data/ditz-str/new_component.rhtml +28 -0
  38. data/data/ditz-str/new_issue.rhtml +57 -0
  39. data/data/ditz-str/new_release.rhtml +29 -0
  40. data/data/ditz-str/plugins/git-sync.rb +83 -0
  41. data/data/ditz-str/plugins/git.rb +153 -0
  42. data/data/ditz-str/plugins/issue-claiming.rb +174 -0
  43. data/data/ditz-str/red-check.png +0 -0
  44. data/data/ditz-str/release.rhtml +111 -0
  45. data/data/ditz-str/style.css +236 -0
  46. data/data/ditz-str/unassigned.rhtml +37 -0
  47. data/data/ditz-str/yellow-bar.png +0 -0
  48. data/ditz-str.gemspec +121 -0
  49. data/lib/ditzstr/brick.rb +251 -0
  50. data/lib/ditzstr/file-storage.rb +54 -0
  51. data/lib/ditzstr/hook.rb +67 -0
  52. data/lib/ditzstr/html.rb +104 -0
  53. data/lib/ditzstr/lowline.rb +201 -0
  54. data/lib/ditzstr/model-objects.rb +346 -0
  55. data/lib/ditzstr/model.rb +265 -0
  56. data/lib/ditzstr/operator.rb +593 -0
  57. data/lib/ditzstr/trollop.rb +614 -0
  58. data/lib/ditzstr/util.rb +61 -0
  59. data/lib/ditzstr/view.rb +16 -0
  60. data/lib/ditzstr/views.rb +157 -0
  61. data/lib/ditzstr.rb +69 -0
  62. data/man/ditz.1 +38 -0
  63. data/test/helper.rb +18 -0
  64. data/test/test_ditz-str.rb +7 -0
  65. metadata +219 -0
@@ -0,0 +1,593 @@
1
+ require 'fileutils'
2
+
3
+ module DitzStr
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 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
+ return help_single(command) if command
140
+ puts <<EOS
141
+ DitzStr commands:
142
+
143
+ EOS
144
+ ops = self.class.operations
145
+ len = ops.map { |name, op| name.to_s.length }.max
146
+ ops.each do |name, opts|
147
+ printf " %#{len}s: %s\n", name, opts[:desc]
148
+ end
149
+ puts <<EOS
150
+
151
+ Use 'ditz help <command>' for details.
152
+ EOS
153
+ end
154
+
155
+ def help_single command
156
+ name, opts = self.class.operations.find { |name, spec| name == command }
157
+ raise Error, "no such ditz command '#{command}'" unless name
158
+ args = opts[:args_spec].map do |spec|
159
+ case spec.to_s
160
+ when /^maybe_(.*)$/
161
+ "[#{$1}]"
162
+ else
163
+ "<#{spec.to_s}>"
164
+ end
165
+ end.join(" ")
166
+
167
+ puts <<EOS
168
+ #{opts[:desc]}.
169
+ Usage: ditz #{name} #{args}
170
+ EOS
171
+ end
172
+
173
+ ## gets a comment from the user, assuming the standard argument setup
174
+ def get_comment opts
175
+ comment = if opts[:no_comment]
176
+ nil
177
+ elsif opts[:comment]
178
+ opts[:comment]
179
+ else
180
+ ask_multiline "Comments"
181
+ end
182
+ end
183
+ private :get_comment
184
+
185
+ operation :reconfigure, "Rerun configuration script"
186
+ def reconfigure project, config
187
+ new_config = Config.create_interactively :defaults_from => config
188
+ new_config.save! $opts[:config_file]
189
+ puts "Configuration written."
190
+ end
191
+
192
+ operation :add, "Add an issue" do
193
+ opt :comment, "Specify a comment", :short => 'm', :type => String
194
+ opt :no_comment, "Skip asking for a comment", :default => false
195
+ end
196
+ def add project, config, opts
197
+ issue = Issue.create_interactively(:args => [config, project]) or return
198
+ issue.log "created", config.user, get_comment(opts)
199
+ project.add_issue issue
200
+ project.assign_issue_names!
201
+ puts "Added issue #{issue.name}."
202
+ end
203
+
204
+ operation :drop, "Drop an issue", :issue
205
+ def drop project, config, issue
206
+ project.drop_issue issue
207
+ puts "Dropped #{issue.name}. Note that other issue names may have changed."
208
+ end
209
+
210
+ operation :add_release, "Add a release", :maybe_name do
211
+ opt :comment, "Specify a comment", :short => 'm', :type => String
212
+ opt :no_comment, "Skip asking for a comment", :default => false
213
+ end
214
+ def add_release project, config, opts, maybe_name
215
+ puts "Adding release #{maybe_name}." if maybe_name
216
+ release = Release.create_interactively(:args => [project, config], :with => { :name => maybe_name }) or return
217
+ release.log "created", config.user, get_comment(opts)
218
+ project.add_release release
219
+ puts "Added release #{release.name}."
220
+ end
221
+
222
+ operation :add_component, "Add a component"
223
+ def add_component project, config
224
+ component = Component.create_interactively(:args => [project, config]) or return
225
+ project.add_component component
226
+ puts "Added component #{component.name}."
227
+ end
228
+
229
+ operation :add_reference, "Add a reference to an issue", :issue do
230
+ opt :comment, "Specify a comment", :short => 'm', :type => String
231
+ opt :no_comment, "Skip asking for a comment", :default => false
232
+ end
233
+ def add_reference project, config, opts, issue
234
+ puts "Adding a reference to #{issue.name}: #{issue.title}."
235
+ reference = ask "Reference"
236
+ issue.add_reference reference
237
+ issue.log "added reference #{issue.references.size}", config.user, get_comment(opts)
238
+ puts "Added reference to #{issue.name}."
239
+ end
240
+
241
+ operation :status, "Show project status", :maybe_release
242
+ def status project, config, releases
243
+ releases ||= project.unreleased_releases + [:unassigned]
244
+
245
+ if releases.empty?
246
+ puts "No releases."
247
+ return
248
+ end
249
+
250
+ entries = releases.map do |r|
251
+ title, issues = (r == :unassigned ? r.to_s : r.name), project.issues_for_release(r)
252
+
253
+ middle = Issue::TYPES.map do |type|
254
+ type_issues = issues.select { |i| i.type == type }
255
+ num = type_issues.size
256
+ nc = type_issues.count_of { |i| i.closed? }
257
+ pc = 100.0 * (type_issues.empty? ? 1.0 : nc.to_f / num)
258
+ "%2d/%2d %s" % [nc, num, type.to_s.pluralize(num, false)]
259
+ end
260
+
261
+ bar = if r == :unassigned
262
+ ""
263
+ elsif r.released?
264
+ "(released)"
265
+ elsif issues.empty?
266
+ "(no issues)"
267
+ elsif issues.all? { |i| i.closed? }
268
+ "(ready for release)"
269
+ else
270
+ status_bar_for(issues)
271
+ end
272
+
273
+ [title, middle, bar]
274
+ end
275
+
276
+ title_size = 0
277
+ middle_sizes = []
278
+
279
+ entries.each do |title, middle, bar|
280
+ title_size = [title_size, title.length].max
281
+ middle_sizes = middle.zip(middle_sizes).map do |e, s|
282
+ [s || 0, e.length].max
283
+ end
284
+ end
285
+
286
+ entries.each do |title, middle, bar|
287
+ printf "%-#{title_size}s ", title
288
+ middle.zip(middle_sizes).each_with_index do |(e, s), i|
289
+ sep = i < middle.size - 1 ? "," : ""
290
+ printf "%-#{s + sep.length}s ", e + sep
291
+ end
292
+ puts bar
293
+ end
294
+ end
295
+
296
+ def status_bar_for issues
297
+ Issue::STATUS_WIDGET.
298
+ sort_by { |k, v| -Issue::STATUS_SORT_ORDER[k] }.
299
+ map { |k, v| v * issues.count_of { |i| i.status == k } }.
300
+ join
301
+ end
302
+
303
+ def todo_list_for issues, opts={}
304
+ return if issues.empty?
305
+ name_len = issues.max_of { |i| i.name.length }
306
+ issues.map do |i|
307
+ s = sprintf "%s %#{name_len}s: %s", i.status_widget, i.name, i.title
308
+ s += " [#{i.release}]" if opts[:show_release] && i.release
309
+ s + "\n"
310
+ end.join
311
+ end
312
+
313
+ def print_todo_list_by_release_for project, issues
314
+ by_release = issues.inject({}) do |h, i|
315
+ r = project.release_for(i.release) || :unassigned
316
+ h[r] ||= []
317
+ h[r] << i
318
+ h
319
+ end
320
+
321
+ (project.releases + [:unassigned]).each do |r|
322
+ next unless by_release.member? r
323
+ puts r == :unassigned ? "Unassigned:" : "#{r.name} (#{r.status}):"
324
+ print todo_list_for(by_release[r])
325
+ puts
326
+ end
327
+ end
328
+
329
+ operation :todo, "Generate todo list", :maybe_release do
330
+ opt :all, "Show all issues, included completed ones", :default => false
331
+ end
332
+ def todo project, config, opts, releases
333
+ actually_do_todo project, config, releases, opts[:all]
334
+ end
335
+
336
+ def actually_do_todo project, config, releases, full
337
+ releases ||= project.unreleased_releases + [:unassigned]
338
+ releases = [*releases]
339
+ releases.each do |r|
340
+ puts r == :unassigned ? "Unassigned:" : "#{r.name} (#{r.status}):"
341
+ issues = project.issues_for_release r
342
+ issues = issues.select { |i| i.open? } unless full
343
+ puts(todo_list_for(issues.sort_by { |i| i.sort_order }) || "No open issues.")
344
+ puts
345
+ end
346
+ end
347
+
348
+ operation :show, "Describe a single issue", :issue
349
+ def show project, config, issue
350
+ ScreenView.new(project, config).render_issue issue
351
+ end
352
+
353
+ operation :start, "Start work on an issue", :unstarted_issue do
354
+ opt :comment, "Specify a comment", :short => 'm', :type => String
355
+ opt :no_comment, "Skip asking for a comment", :default => false
356
+ end
357
+ def start project, config, opts, issue
358
+ puts "Starting work on issue #{issue.name}: #{issue.title}."
359
+ issue.start_work config.user, get_comment(opts)
360
+ puts "Recorded start of work for #{issue.name}."
361
+ end
362
+
363
+ operation :stop, "Stop work on an issue", :started_issue do
364
+ opt :comment, "Specify a comment", :short => 'm', :type => String
365
+ opt :no_comment, "Skip asking for a comment", :default => false
366
+ end
367
+ def stop project, config, opts, issue
368
+ puts "Stopping work on issue #{issue.name}: #{issue.title}."
369
+ issue.stop_work config.user, get_comment(opts)
370
+ puts "Recorded work stop for #{issue.name}."
371
+ end
372
+
373
+ operation :close, "Close an issue", :open_issue do
374
+ opt :comment, "Specify a comment", :short => 'm', :type => String
375
+ opt :no_comment, "Skip asking for a comment", :default => false
376
+ end
377
+ def close project, config, opts, issue
378
+ puts "Closing issue #{issue.name}: #{issue.title}."
379
+ disp = ask_for_selection Issue::DISPOSITIONS, "disposition", lambda { |x| Issue::DISPOSITION_STRINGS[x] || x.to_s }
380
+ issue.close disp, config.user, get_comment(opts)
381
+ puts "Closed issue #{issue.name} with disposition #{issue.disposition_string}."
382
+ end
383
+
384
+ operation :assign, "Assign an issue to a release", :issue, :maybe_release do
385
+ opt :comment, "Specify a comment", :short => 'm', :type => String
386
+ opt :no_comment, "Skip asking for a comment", :default => false
387
+ end
388
+ def assign project, config, opts, issue, maybe_release
389
+ if maybe_release && maybe_release.name == issue.release
390
+ raise Error, "issue #{issue.name} already assigned to release #{issue.release}"
391
+ end
392
+
393
+ puts "Issue #{issue.name} currently " + if issue.release
394
+ "assigned to release #{issue.release}."
395
+ else
396
+ "not assigned to any release."
397
+ end
398
+
399
+ release = maybe_release || begin
400
+ releases = project.releases.sort_by { |r| (r.release_time || 0).to_i }
401
+ releases -= [releases.find { |r| r.name == issue.release }] if issue.release
402
+ ask_for_selection(releases, "release") do |r|
403
+ r.name + if r.released?
404
+ " (released #{r.release_time.pretty_date})"
405
+ else
406
+ " (unreleased)"
407
+ end
408
+ end
409
+ end
410
+ issue.assign_to_release release, config.user, get_comment(opts)
411
+ puts "Assigned #{issue.name} to #{release.name}."
412
+ end
413
+
414
+ operation :set_component, "Set an issue's component", :issue, :maybe_component do
415
+ opt :comment, "Specify a comment", :short => 'm', :type => String
416
+ opt :no_comment, "Skip asking for a comment", :default => false
417
+ end
418
+ def set_component project, config, opts, issue, maybe_component
419
+ puts "Changing the component of issue #{issue.name}: #{issue.title}."
420
+
421
+ if project.components.size == 1
422
+ raise Error, "this project does not use multiple components"
423
+ end
424
+
425
+ if maybe_component && maybe_component.name == issue.component
426
+ raise Error, "issue #{issue.name} already assigned to component #{issue.component}"
427
+ end
428
+
429
+ component = maybe_component || begin
430
+ components = project.components
431
+ components -= [components.find { |r| r.name == issue.component }] if issue.component
432
+ ask_for_selection(components, "component") { |r| r.name }
433
+ end
434
+ issue.assign_to_component component, config.user, get_comment(opts)
435
+ oldname = issue.name
436
+ project.assign_issue_names!
437
+ puts <<EOS
438
+ Issue #{oldname} is now #{issue.name}. Note that the names of other issues may
439
+ have changed as well.
440
+ EOS
441
+ end
442
+
443
+ operation :unassign, "Unassign an issue from any releases", :assigned_issue do
444
+ opt :comment, "Specify a comment", :short => 'm', :type => String
445
+ opt :no_comment, "Skip asking for a comment", :default => false
446
+ end
447
+ def unassign project, config, opts, issue
448
+ puts "Unassigning issue #{issue.name}: #{issue.title}."
449
+ issue.unassign config.user, get_comment(opts)
450
+ puts "Unassigned #{issue.name}."
451
+ end
452
+
453
+ operation :comment, "Comment on an issue", :issue do
454
+ opt :comment, "Specify a comment", :short => 'm', :type => String
455
+ opt :no_comment, "Skip asking for a comment", :default => false
456
+ end
457
+ def comment project, config, opts, issue
458
+ puts "Commenting on issue #{issue.name}: #{issue.title}."
459
+ comment = get_comment opts
460
+ if comment.blank?
461
+ puts "Empty comment, aborted."
462
+ else
463
+ issue.log "commented", config.user, comment
464
+ puts "Comments recorded for #{issue.name}."
465
+ end
466
+ end
467
+
468
+ operation :releases, "Show releases"
469
+ def releases project, config
470
+ a, b = project.releases.partition { |r| r.released? }
471
+ (b + a.sort_by { |r| r.release_time }).each do |r|
472
+ status = r.released? ? "released #{r.release_time.pretty_date}" : r.status
473
+ puts "#{r.name} (#{status})"
474
+ end
475
+ end
476
+
477
+ operation :release, "Release a release", :unreleased_release 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 release project, config, opts, release
482
+ release.release! project, config.user, get_comment(opts)
483
+ puts "Release #{release.name} released!"
484
+ end
485
+
486
+ operation :changelog, "Generate a changelog for a release", :release
487
+ def changelog project, config, r
488
+ puts "== #{r.name} / #{r.released? ? r.release_time.pretty_date : 'unreleased'}"
489
+ project.group_issues(project.issues_for_release(r)).each do |type, issues|
490
+ issues.select { |i| i.closed? }.each do |i|
491
+ if type == :bugfix
492
+ puts "* #{type}: #{i.title}"
493
+ else
494
+ puts "* #{i.title}"
495
+ end
496
+ end
497
+ end
498
+ end
499
+
500
+ operation :html, "Generate html status pages", :maybe_dir
501
+ def html project, config, dir
502
+ dir ||= "html"
503
+ HtmlView.new(project, config, dir).render_all
504
+ end
505
+
506
+ operation :validate, "Validate project status"
507
+ def validate project, config
508
+ ## a no-op
509
+ end
510
+
511
+ operation :grep, "Show issues matching a string or regular expression", :string
512
+ def grep project, config, match
513
+ re = /#{match}/
514
+ issues = project.issues.select do |i|
515
+ i.title =~ re || i.desc =~ re ||
516
+ i.log_events.map { |time, who, what, comments| comments }.join(" ") =~ re
517
+ end
518
+ puts(todo_list_for(issues) || "No matching issues.")
519
+ end
520
+
521
+ operation :log, "Show recent activity"
522
+ def log project, config
523
+ project.issues.map { |i| i.log_events.map { |e| [e, i] } }.
524
+ flatten_one_level.sort_by { |e| e.first.first }.reverse.
525
+ each do |(date, author, what, comment), i|
526
+ puts <<EOS
527
+ date : #{date.localtime} (#{date.ago} ago)
528
+ author: #{author}
529
+ issue: [#{i.name}] #{i.title}
530
+
531
+ #{what}
532
+ #{comment.gsub(/^/, " > ") unless comment =~ /^\A\s*\z/}
533
+ EOS
534
+ puts unless comment.blank?
535
+ end
536
+ end
537
+
538
+ operation :shortlog, "Show recent activity (short form)"
539
+ def shortlog project, config
540
+ project.issues.map { |i| i.log_events.map { |e| [e, i] } }.
541
+ flatten_one_level.sort_by { |e| e.first.first }.reverse.
542
+ each do |(date, author, what, comment), i|
543
+ shortauthor = if author =~ /<(.*?)@/
544
+ $1
545
+ else
546
+ author
547
+ end[0..15]
548
+ printf "%13s|%13s|%13s|%s\n", date.ago, i.name, shortauthor,
549
+ what
550
+ end
551
+ end
552
+
553
+ operation :archive, "Archive a release", :release, :maybe_dir
554
+ def archive project, config, release, dir
555
+ dir ||= "ditz-archive-#{release.name}"
556
+ FileUtils.mkdir dir
557
+ FileUtils.cp project.pathname, dir
558
+ project.issues_for_release(release).each do |i|
559
+ FileUtils.cp i.pathname, dir
560
+ project.drop_issue i
561
+ end
562
+ puts "Archived to #{dir}."
563
+ end
564
+
565
+ operation :edit, "Edit an issue", :issue do
566
+ opt :comment, "Specify a comment", :short => 'm', :type => String
567
+ opt :no_comment, "Skip asking for a comment", :default => false
568
+ opt :silent, "Don't add a log message detailing the change", :default => false
569
+ end
570
+ def edit project, config, opts, issue
571
+ data = { :title => issue.title, :description => issue.desc,
572
+ :reporter => issue.reporter }
573
+
574
+ fn = run_editor { |f| f.puts data.to_yaml }
575
+
576
+ unless fn
577
+ puts "Aborted."
578
+ return
579
+ end
580
+
581
+ begin
582
+ edits = YAML.load_file fn
583
+ comment = opts[:silent] ? nil : get_comment(opts)
584
+ if issue.change edits, config.user, comment, opts[:silent]
585
+ puts "Change recorded."
586
+ else
587
+ puts "No changes."
588
+ end
589
+ end
590
+ end
591
+ end
592
+
593
+ end