ursm-ditz 0.4
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.
- 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
|