ohac-ditz 0.5.1
Sign up to get free protection for your applications and to get access to all the features.
- data/Changelog +76 -0
- data/INSTALL +20 -0
- data/LICENSE +674 -0
- data/Manifest.txt +48 -0
- data/PLUGINS.txt +197 -0
- data/README.txt +146 -0
- data/Rakefile +66 -0
- data/ReleaseNotes +56 -0
- data/bin/ditz +230 -0
- data/contrib/completion/_ditz.zsh +29 -0
- data/contrib/completion/ditz.bash +38 -0
- data/lib/ditz/file-storage.rb +53 -0
- data/lib/ditz/hook.rb +67 -0
- data/lib/ditz/html.rb +107 -0
- data/lib/ditz/lowline.rb +244 -0
- data/lib/ditz/model-objects.rb +379 -0
- data/lib/ditz/model.rb +339 -0
- data/lib/ditz/operator.rb +655 -0
- data/lib/ditz/plugins/git-sync.rb +83 -0
- data/lib/ditz/plugins/git.rb +153 -0
- data/lib/ditz/plugins/issue-claiming.rb +193 -0
- data/lib/ditz/plugins/issue-labeling.rb +170 -0
- data/lib/ditz/util.rb +61 -0
- data/lib/ditz/view.rb +16 -0
- data/lib/ditz/views.rb +191 -0
- data/lib/ditz.rb +110 -0
- data/man/man1/ditz.1 +38 -0
- data/setup.rb +1585 -0
- data/share/ditz/blue-check.png +0 -0
- data/share/ditz/component.rhtml +24 -0
- data/share/ditz/green-bar.png +0 -0
- data/share/ditz/green-check.png +0 -0
- data/share/ditz/index.rhtml +130 -0
- data/share/ditz/issue.rhtml +119 -0
- data/share/ditz/issue_table.rhtml +28 -0
- data/share/ditz/red-check.png +0 -0
- data/share/ditz/release.rhtml +98 -0
- data/share/ditz/style.css +226 -0
- data/share/ditz/unassigned.rhtml +23 -0
- data/share/ditz/yellow-bar.png +0 -0
- metadata +116 -0
@@ -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
|