ursm-ditz 0.4
Sign up to get free protection for your applications and to get access to all the features.
- data/Changelog +35 -0
- data/README.txt +127 -0
- data/Rakefile +33 -0
- data/ReleaseNotes +50 -0
- data/bin/ditz +213 -0
- data/contrib/completion/_ditz.zsh +29 -0
- data/contrib/completion/ditz.bash +22 -0
- data/lib/component.rhtml +22 -0
- data/lib/ditz.rb +56 -0
- data/lib/hook.rb +67 -0
- data/lib/html.rb +69 -0
- data/lib/index.rhtml +113 -0
- data/lib/issue.rhtml +111 -0
- data/lib/issue_table.rhtml +33 -0
- data/lib/lowline.rb +202 -0
- data/lib/model-objects.rb +314 -0
- data/lib/model.rb +208 -0
- data/lib/operator.rb +549 -0
- data/lib/plugins/git.rb +114 -0
- data/lib/plugins/issue-claiming.rb +92 -0
- data/lib/release.rhtml +69 -0
- data/lib/style.css +127 -0
- data/lib/trollop.rb +518 -0
- data/lib/unassigned.rhtml +31 -0
- data/lib/util.rb +57 -0
- data/lib/vendor/yaml_waml.rb +28 -0
- data/lib/view.rb +16 -0
- data/lib/views.rb +136 -0
- data/man/ditz.1 +38 -0
- metadata +90 -0
data/lib/operator.rb
ADDED
@@ -0,0 +1,549 @@
|
|
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 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
|
+
exit 0
|
138
|
+
end
|
139
|
+
return help_single(command) if command
|
140
|
+
puts <<EOS
|
141
|
+
Ditz 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
|
+
operation :add, "Add an issue"
|
174
|
+
def add project, config
|
175
|
+
issue = Issue.create_interactively(:args => [config, project]) or return
|
176
|
+
comment = ask_multiline "Comments" unless $opts[:no_comment]
|
177
|
+
issue.log "created", config.user, comment
|
178
|
+
project.add_issue issue
|
179
|
+
project.assign_issue_names!
|
180
|
+
puts "Added issue #{issue.name}."
|
181
|
+
end
|
182
|
+
|
183
|
+
operation :drop, "Drop an issue", :issue
|
184
|
+
def drop project, config, issue
|
185
|
+
project.drop_issue issue
|
186
|
+
puts "Dropped #{issue.name}. Note that other issue names may have changed."
|
187
|
+
end
|
188
|
+
|
189
|
+
operation :add_release, "Add a release", :maybe_name
|
190
|
+
def add_release project, config, maybe_name
|
191
|
+
puts "Adding release #{maybe_name}." if maybe_name
|
192
|
+
release = Release.create_interactively(:args => [project, config], :with => { :name => maybe_name }) or return
|
193
|
+
comment = ask_multiline "Comments" unless $opts[:no_comment]
|
194
|
+
release.log "created", config.user, comment
|
195
|
+
project.add_release release
|
196
|
+
puts "Added release #{release.name}."
|
197
|
+
end
|
198
|
+
|
199
|
+
operation :add_component, "Add a component"
|
200
|
+
def add_component project, config
|
201
|
+
component = Component.create_interactively(:args => [project, config]) or return
|
202
|
+
project.add_component component
|
203
|
+
puts "Added component #{component.name}."
|
204
|
+
end
|
205
|
+
|
206
|
+
operation :add_reference, "Add a reference to an issue", :issue
|
207
|
+
def add_reference project, config, issue
|
208
|
+
puts "Adding a reference to #{issue.name}: #{issue.title}."
|
209
|
+
reference = ask "Reference"
|
210
|
+
comment = ask_multiline "Comments" unless $opts[:no_comment]
|
211
|
+
issue.add_reference reference
|
212
|
+
issue.log "added reference #{issue.references.size}", config.user, comment
|
213
|
+
puts "Added reference to #{issue.name}."
|
214
|
+
end
|
215
|
+
|
216
|
+
operation :status, "Show project status", :maybe_release
|
217
|
+
def status project, config, releases
|
218
|
+
releases ||= project.unreleased_releases + [:unassigned]
|
219
|
+
|
220
|
+
if releases.empty?
|
221
|
+
puts "No releases."
|
222
|
+
return
|
223
|
+
end
|
224
|
+
|
225
|
+
entries = releases.map do |r|
|
226
|
+
title, issues = (r == :unassigned ? r.to_s : r.name), project.issues_for_release(r)
|
227
|
+
|
228
|
+
middle = Issue::TYPES.map do |type|
|
229
|
+
type_issues = issues.select { |i| i.type == type }
|
230
|
+
num = type_issues.size
|
231
|
+
nc = type_issues.count_of { |i| i.closed? }
|
232
|
+
pc = 100.0 * (type_issues.empty? ? 1.0 : nc.to_f / num)
|
233
|
+
"%2d/%2d %s" % [nc, num, type.to_s.pluralize(num, false)]
|
234
|
+
end
|
235
|
+
|
236
|
+
bar = if r != :unassigned && r.released?
|
237
|
+
"(released)"
|
238
|
+
elsif issues.empty?
|
239
|
+
"(no issues)"
|
240
|
+
elsif issues.all? { |i| i.closed? }
|
241
|
+
"(ready for release)"
|
242
|
+
else
|
243
|
+
status_bar_for(issues)
|
244
|
+
end
|
245
|
+
|
246
|
+
[title, middle, bar]
|
247
|
+
end
|
248
|
+
|
249
|
+
title_size = 0
|
250
|
+
middle_sizes = []
|
251
|
+
|
252
|
+
entries.each do |title, middle, bar|
|
253
|
+
title_size = [title_size, title.length].max
|
254
|
+
middle_sizes = middle.zip(middle_sizes).map do |e, s|
|
255
|
+
[s || 0, e.length].max
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
entries.each do |title, middle, bar|
|
260
|
+
printf "%-#{title_size}s ", title
|
261
|
+
middle.zip(middle_sizes).each_with_index do |(e, s), i|
|
262
|
+
sep = i < middle.size - 1 ? "," : ""
|
263
|
+
printf "%-#{s + sep.length}s ", e + sep
|
264
|
+
end
|
265
|
+
puts bar
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
def status_bar_for issues
|
270
|
+
Issue::STATUS_WIDGET.
|
271
|
+
sort_by { |k, v| -Issue::STATUS_SORT_ORDER[k] }.
|
272
|
+
map { |k, v| v * issues.count_of { |i| i.status == k } }.
|
273
|
+
join
|
274
|
+
end
|
275
|
+
|
276
|
+
def todo_list_for issues
|
277
|
+
return if issues.empty?
|
278
|
+
name_len = issues.max_of { |i| i.name.length }
|
279
|
+
issues.map do |i|
|
280
|
+
sprintf "%s %#{name_len}s: %s\n", i.status_widget, i.name, i.title
|
281
|
+
end.join
|
282
|
+
end
|
283
|
+
|
284
|
+
def print_todo_list_by_release_for project, issues
|
285
|
+
by_release = issues.inject({}) do |h, i|
|
286
|
+
r = project.release_for i.release
|
287
|
+
h[r] ||= []
|
288
|
+
h[r] << i
|
289
|
+
h
|
290
|
+
end
|
291
|
+
|
292
|
+
project.releases.each do |r|
|
293
|
+
next unless by_release.member? r
|
294
|
+
puts r == :unassigned ? "Unassigned:" : "#{r.name} (#{r.status}):"
|
295
|
+
print todo_list_for(by_release[r])
|
296
|
+
puts
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
operation :todo, "Generate todo list", :maybe_release
|
301
|
+
def todo project, config, releases
|
302
|
+
actually_do_todo project, config, releases, false
|
303
|
+
end
|
304
|
+
|
305
|
+
operation :todo_full, "Generate full todo list, including completed items", :maybe_release
|
306
|
+
def todo_full project, config, releases
|
307
|
+
actually_do_todo project, config, releases, true
|
308
|
+
end
|
309
|
+
|
310
|
+
def actually_do_todo project, config, releases, full
|
311
|
+
releases ||= project.unreleased_releases + [:unassigned]
|
312
|
+
releases = [*releases]
|
313
|
+
releases.each do |r|
|
314
|
+
puts r == :unassigned ? "Unassigned:" : "#{r.name} (#{r.status}):"
|
315
|
+
issues = project.issues_for_release r
|
316
|
+
issues = issues.select { |i| i.open? } unless full
|
317
|
+
puts(todo_list_for(issues.sort_by { |i| i.sort_order }) || "No open issues.")
|
318
|
+
puts
|
319
|
+
end
|
320
|
+
end
|
321
|
+
|
322
|
+
operation :show, "Describe a single issue", :issue
|
323
|
+
def show project, config, issue
|
324
|
+
ScreenView.new(project, config).render_issue issue
|
325
|
+
end
|
326
|
+
|
327
|
+
operation :start, "Start work on an issue", :unstarted_issue
|
328
|
+
def start project, config, issue
|
329
|
+
puts "Starting work on issue #{issue.name}: #{issue.title}."
|
330
|
+
comment = ask_multiline "Comments" unless $opts[:no_comment]
|
331
|
+
issue.start_work config.user, comment
|
332
|
+
puts "Recorded start of work for #{issue.name}."
|
333
|
+
end
|
334
|
+
|
335
|
+
operation :stop, "Stop work on an issue", :started_issue
|
336
|
+
def stop project, config, issue
|
337
|
+
puts "Stopping work on issue #{issue.name}: #{issue.title}."
|
338
|
+
comment = ask_multiline "Comments" unless $opts[:no_comment]
|
339
|
+
issue.stop_work config.user, comment
|
340
|
+
puts "Recorded work stop for #{issue.name}."
|
341
|
+
end
|
342
|
+
|
343
|
+
operation :close, "Close an issue", :open_issue
|
344
|
+
def close project, config, issue
|
345
|
+
puts "Closing issue #{issue.name}: #{issue.title}."
|
346
|
+
disp = ask_for_selection Issue::DISPOSITIONS, "disposition", lambda { |x| Issue::DISPOSITION_STRINGS[x] || x.to_s }
|
347
|
+
comment = ask_multiline "Comments" unless $opts[:no_comment]
|
348
|
+
issue.close disp, config.user, comment
|
349
|
+
puts "Closed issue #{issue.name} with disposition #{issue.disposition_string}."
|
350
|
+
end
|
351
|
+
|
352
|
+
operation :assign, "Assign an issue to a release", :issue, :maybe_release
|
353
|
+
def assign project, config, issue, maybe_release
|
354
|
+
if maybe_release && maybe_release.name == issue.release
|
355
|
+
raise Error, "issue #{issue.name} already assigned to release #{issue.release}"
|
356
|
+
end
|
357
|
+
|
358
|
+
puts "Issue #{issue.name} currently " + if issue.release
|
359
|
+
"assigned to release #{issue.release}."
|
360
|
+
else
|
361
|
+
"not assigned to any release."
|
362
|
+
end
|
363
|
+
|
364
|
+
puts "Assigning to release #{maybe_release.name}." if maybe_release
|
365
|
+
|
366
|
+
release = maybe_release || begin
|
367
|
+
releases = project.releases.sort_by { |r| (r.release_time || 0).to_i }
|
368
|
+
releases -= [releases.find { |r| r.name == issue.release }] if issue.release
|
369
|
+
ask_for_selection(releases, "release") do |r|
|
370
|
+
r.name + if r.released?
|
371
|
+
" (released #{r.release_time.pretty_date})"
|
372
|
+
else
|
373
|
+
" (unreleased)"
|
374
|
+
end
|
375
|
+
end
|
376
|
+
end
|
377
|
+
comment = ask_multiline "Comments" unless $opts[:no_comment]
|
378
|
+
issue.assign_to_release release, config.user, comment
|
379
|
+
puts "Assigned #{issue.name} to #{release.name}."
|
380
|
+
end
|
381
|
+
|
382
|
+
operation :set_component, "Set an issue's component", :issue, :maybe_component
|
383
|
+
def set_component project, config, issue, maybe_component
|
384
|
+
puts "Changing the component of issue #{issue.name}: #{issue.title}."
|
385
|
+
|
386
|
+
if project.components.size == 1
|
387
|
+
raise Error, "this project does not use multiple components"
|
388
|
+
end
|
389
|
+
|
390
|
+
if maybe_component && maybe_component.name == issue.component
|
391
|
+
raise Error, "issue #{issue.name} already assigned to component #{issue.component}"
|
392
|
+
end
|
393
|
+
|
394
|
+
component = maybe_component || begin
|
395
|
+
components = project.components
|
396
|
+
components -= [components.find { |r| r.name == issue.component }] if issue.component
|
397
|
+
ask_for_selection(components, "component") { |r| r.name }
|
398
|
+
end
|
399
|
+
comment = ask_multiline "Comments" unless $opts[:no_comment]
|
400
|
+
issue.assign_to_component component, config.user, comment
|
401
|
+
oldname = issue.name
|
402
|
+
project.assign_issue_names!
|
403
|
+
puts <<EOS
|
404
|
+
Issue #{oldname} is now #{issue.name}. Note that the names of other issues may
|
405
|
+
have changed as well.
|
406
|
+
EOS
|
407
|
+
end
|
408
|
+
|
409
|
+
operation :unassign, "Unassign an issue from any releases", :assigned_issue
|
410
|
+
def unassign project, config, issue
|
411
|
+
puts "Unassigning issue #{issue.name}: #{issue.title}."
|
412
|
+
comment = ask_multiline "Comments" unless $opts[:no_comment]
|
413
|
+
issue.unassign config.user, comment
|
414
|
+
puts "Unassigned #{issue.name}."
|
415
|
+
end
|
416
|
+
|
417
|
+
operation :comment, "Comment on an issue", :issue
|
418
|
+
def comment project, config, issue
|
419
|
+
puts "Commenting on issue #{issue.name}: #{issue.title}."
|
420
|
+
comment = ask_multiline "Comments"
|
421
|
+
if comment.blank?
|
422
|
+
puts "Empty comment, aborted."
|
423
|
+
else
|
424
|
+
issue.log "commented", config.user, comment
|
425
|
+
puts "Comments recorded for #{issue.name}."
|
426
|
+
end
|
427
|
+
end
|
428
|
+
|
429
|
+
operation :releases, "Show releases"
|
430
|
+
def releases project, config
|
431
|
+
a, b = project.releases.partition { |r| r.released? }
|
432
|
+
(b + a.sort_by { |r| r.release_time }).each do |r|
|
433
|
+
status = r.released? ? "released #{r.release_time.pretty_date}" : r.status
|
434
|
+
puts "#{r.name} (#{status})"
|
435
|
+
end
|
436
|
+
end
|
437
|
+
|
438
|
+
operation :release, "Release a release", :unreleased_release
|
439
|
+
def release project, config, release
|
440
|
+
comment = ask_multiline "Comments" unless $opts[:no_comment]
|
441
|
+
release.release! project, config.user, comment
|
442
|
+
puts "Release #{release.name} released!"
|
443
|
+
end
|
444
|
+
|
445
|
+
operation :changelog, "Generate a changelog for a release", :release
|
446
|
+
def changelog project, config, r
|
447
|
+
puts "== #{r.name} / #{r.released? ? r.release_time.pretty_date : 'unreleased'}"
|
448
|
+
project.group_issues(project.issues_for_release(r)).each do |type, issues|
|
449
|
+
issues.select { |i| i.closed? }.each do |i|
|
450
|
+
if type == :bugfix
|
451
|
+
puts "* #{type}: #{i.title}"
|
452
|
+
else
|
453
|
+
puts "* #{i.title}"
|
454
|
+
end
|
455
|
+
end
|
456
|
+
end
|
457
|
+
end
|
458
|
+
|
459
|
+
operation :html, "Generate html status pages", :maybe_dir
|
460
|
+
def html project, config, dir
|
461
|
+
dir ||= "html"
|
462
|
+
HtmlView.new(project, config, dir).render_all
|
463
|
+
end
|
464
|
+
|
465
|
+
operation :validate, "Validate project status"
|
466
|
+
def validate project, config
|
467
|
+
## a no-op
|
468
|
+
end
|
469
|
+
|
470
|
+
operation :grep, "Show issues matching a string or regular expression", :string
|
471
|
+
def grep project, config, match
|
472
|
+
re = /#{match}/
|
473
|
+
issues = project.issues.select do |i|
|
474
|
+
i.title =~ re || i.desc =~ re ||
|
475
|
+
i.log_events.map { |time, who, what, comments| comments }.join(" ") =~ re
|
476
|
+
end
|
477
|
+
puts(todo_list_for(issues) || "No matching issues.")
|
478
|
+
end
|
479
|
+
|
480
|
+
operation :log, "Show recent activity"
|
481
|
+
def log project, config
|
482
|
+
project.issues.map { |i| i.log_events.map { |e| [e, i] } }.
|
483
|
+
flatten_one_level.sort_by { |e| e.first.first }.reverse.
|
484
|
+
each do |(date, author, what, comment), i|
|
485
|
+
puts <<EOS
|
486
|
+
date : #{date.localtime} (#{date.ago} ago)
|
487
|
+
author: #{author}
|
488
|
+
issue: [#{i.name}] #{i.title}
|
489
|
+
|
490
|
+
#{what}
|
491
|
+
#{comment.gsub(/^/, " > ") unless comment =~ /^\A\s*\z/}
|
492
|
+
EOS
|
493
|
+
puts unless comment.blank?
|
494
|
+
end
|
495
|
+
end
|
496
|
+
|
497
|
+
operation :shortlog, "Show recent activity (short form)"
|
498
|
+
def shortlog project, config
|
499
|
+
project.issues.map { |i| i.log_events.map { |e| [e, i] } }.
|
500
|
+
flatten_one_level.sort_by { |e| e.first.first }.reverse.
|
501
|
+
each do |(date, author, what, comment), i|
|
502
|
+
shortauthor = if author =~ /<(.*?)@/
|
503
|
+
$1
|
504
|
+
else
|
505
|
+
author
|
506
|
+
end[0..15]
|
507
|
+
printf "%13s|%13s|%13s|%s\n", date.ago, i.name, shortauthor,
|
508
|
+
what
|
509
|
+
end
|
510
|
+
end
|
511
|
+
|
512
|
+
operation :archive, "Archive a release", :release, :maybe_dir
|
513
|
+
def archive project, config, release, dir
|
514
|
+
dir ||= "ditz-archive-#{release.name}"
|
515
|
+
FileUtils.mkdir dir
|
516
|
+
FileUtils.cp project.pathname, dir
|
517
|
+
project.issues_for_release(release).each do |i|
|
518
|
+
FileUtils.cp i.pathname, dir
|
519
|
+
project.drop_issue i
|
520
|
+
end
|
521
|
+
puts "Archived to #{dir}."
|
522
|
+
end
|
523
|
+
|
524
|
+
operation :edit, "Edit an issue", :issue
|
525
|
+
def edit project, config, issue
|
526
|
+
data = { :title => issue.title, :description => issue.desc,
|
527
|
+
:reporter => issue.reporter }
|
528
|
+
|
529
|
+
fn = run_editor { |f| f.puts data.to_yaml }
|
530
|
+
|
531
|
+
unless fn
|
532
|
+
puts "Aborted."
|
533
|
+
return
|
534
|
+
end
|
535
|
+
|
536
|
+
comment = ask_multiline "Comments" unless $opts[:no_comment]
|
537
|
+
|
538
|
+
begin
|
539
|
+
edits = YAML.load_file fn
|
540
|
+
if issue.change edits, config.user, comment
|
541
|
+
puts "Change recorded."
|
542
|
+
else
|
543
|
+
puts "No changes."
|
544
|
+
end
|
545
|
+
end
|
546
|
+
end
|
547
|
+
end
|
548
|
+
|
549
|
+
end
|
data/lib/plugins/git.rb
ADDED
@@ -0,0 +1,114 @@
|
|
1
|
+
require 'time'
|
2
|
+
|
3
|
+
module Ditz
|
4
|
+
class Issue
|
5
|
+
field :git_branch, :ask => false
|
6
|
+
|
7
|
+
def git_commits
|
8
|
+
return @git_commits if @git_commits
|
9
|
+
|
10
|
+
filters = ["--grep=\"Ditz-issue: #{id}\""]
|
11
|
+
filters << "master..#{git_branch}" if git_branch
|
12
|
+
|
13
|
+
output = filters.map do |f|
|
14
|
+
`git log --pretty=format:\"%aD\t%an <%ae>\t%h\t%s\" #{f}`
|
15
|
+
end.join
|
16
|
+
|
17
|
+
@git_commits = output.split(/\n/).map { |l| l.split("\t") }.
|
18
|
+
map { |date, email, hash, msg| [Time.parse(date).utc, email, hash, msg] }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class Config
|
23
|
+
field :git_commit_url_prefix, :prompt => "URL prefix to link git commits to", :default => ""
|
24
|
+
field :git_branch_url_prefix, :prompt => "URL prefix to link git branches to", :default => ""
|
25
|
+
end
|
26
|
+
|
27
|
+
class ScreenView
|
28
|
+
add_to_view :issue_summary do |issue, config|
|
29
|
+
" Git branch: #{issue.git_branch || 'none'}\n"
|
30
|
+
end
|
31
|
+
|
32
|
+
add_to_view :issue_details do |issue, config|
|
33
|
+
commits = issue.git_commits[0...5]
|
34
|
+
next if commits.empty?
|
35
|
+
"Recent commits:\n" + commits.map do |date, email, hash, msg|
|
36
|
+
"- #{msg} [#{hash}] (#{email.shortened_email}, #{date.ago} ago)\n"
|
37
|
+
end.join + "\n"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
class HtmlView
|
42
|
+
add_to_view :issue_summary do |issue, config|
|
43
|
+
next unless issue.git_branch
|
44
|
+
[<<EOS, { :issue => issue, :url_prefix => config.git_branch_url_prefix }]
|
45
|
+
<tr>
|
46
|
+
<td class='attrname'>Git branch:</td>
|
47
|
+
<td class='attrval'><%= url_prefix && !url_prefix.blank? ? link_to([url_prefix, issue.git_branch].join, issue.git_branch) : h(issue.git_branch) %></td>
|
48
|
+
</tr>
|
49
|
+
EOS
|
50
|
+
end
|
51
|
+
|
52
|
+
add_to_view :issue_details do |issue, config|
|
53
|
+
commits = issue.git_commits
|
54
|
+
next if commits.empty?
|
55
|
+
|
56
|
+
[<<EOS, { :commits => commits, :url_prefix => config.git_commit_url_prefix }]
|
57
|
+
<h2>Commits for this issue</h2>
|
58
|
+
<table>
|
59
|
+
<% commits.each_with_index do |(time, who, hash, msg), i| %>
|
60
|
+
<% if i % 2 == 0 %>
|
61
|
+
<tr class="logentryeven">
|
62
|
+
<% else %>
|
63
|
+
<tr class="logentryodd">
|
64
|
+
<% end %>
|
65
|
+
<td class="logtime"><%=t time %></td>
|
66
|
+
<td class="logwho"><%=obscured_email who %></td>
|
67
|
+
<td class="logwhat"><%=h msg %> [<%= url_prefix && !url_prefix.blank? ? link_to([url_prefix, hash].join, hash) : hash %>]</td>
|
68
|
+
</tr>
|
69
|
+
<% end %>
|
70
|
+
</table>
|
71
|
+
EOS
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
class Operator
|
76
|
+
operation :set_branch, "Set the git feature branch of an issue", :issue, :maybe_string
|
77
|
+
def set_branch project, config, issue, maybe_string
|
78
|
+
puts "Issue #{issue.name} currently " + if issue.git_branch
|
79
|
+
"assigned to git branch #{issue.git_branch.inspect}."
|
80
|
+
else
|
81
|
+
"not assigned to any git branch."
|
82
|
+
end
|
83
|
+
|
84
|
+
branch = maybe_string || ask("Git feature branch name:")
|
85
|
+
return unless branch
|
86
|
+
|
87
|
+
if branch == issue.git_branch
|
88
|
+
raise Error, "issue #{issue.name} already assigned to branch #{issue.git_branch.inspect}"
|
89
|
+
end
|
90
|
+
|
91
|
+
puts "Assigning to branch #{branch.inspect}."
|
92
|
+
issue.git_branch = branch
|
93
|
+
end
|
94
|
+
|
95
|
+
operation :commit, "Runs git-commit and auto-fills the issue name in the commit message", :issue do
|
96
|
+
opt :all, "commit all changed files", :short => "-a", :default => false
|
97
|
+
opt :verbose, "show diff between HEAD and what would be committed", \
|
98
|
+
:short => "-v", :default => false
|
99
|
+
opt :message, "Use the given <s> as the commit message.", \
|
100
|
+
:short => "-m", :type => :string
|
101
|
+
end
|
102
|
+
def commit project, config, opts, issue
|
103
|
+
verbose_flag = opts[:verbose] ? "--verbose" : ""
|
104
|
+
all_flag = opts[:all] ? "--all" : ""
|
105
|
+
ditz_header = "Ditz-issue: #{issue.id}"
|
106
|
+
message = opts[:message] ? "#{opts[:message]}\n\n#{ditz_header}" : \
|
107
|
+
"#{ditz_header}"
|
108
|
+
edit_flag = opts[:message] ? "" : "--edit"
|
109
|
+
message_flag = %{--message="#{message}"}
|
110
|
+
exec "git commit #{all_flag} #{verbose_flag} #{message_flag} #{edit_flag}"
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
end
|