ditz 0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/Changelog +2 -0
- data/README.txt +117 -0
- data/Rakefile +48 -0
- data/bin/ditz +77 -0
- data/lib/component.rhtml +18 -0
- data/lib/ditz.rb +13 -0
- data/lib/html.rb +41 -0
- data/lib/index.rhtml +83 -0
- data/lib/issue.rhtml +106 -0
- data/lib/issue_table.rhtml +29 -0
- data/lib/lowline.rb +150 -0
- data/lib/model-objects.rb +265 -0
- data/lib/model.rb +137 -0
- data/lib/operator.rb +408 -0
- data/lib/release.rhtml +67 -0
- data/lib/style.css +91 -0
- data/lib/unassigned.rhtml +27 -0
- data/lib/util.rb +33 -0
- metadata +79 -0
data/lib/operator.rb
ADDED
@@ -0,0 +1,408 @@
|
|
1
|
+
require 'tempfile'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
require "html"
|
5
|
+
|
6
|
+
module Ditz
|
7
|
+
|
8
|
+
class Operator
|
9
|
+
class Error < StandardError; end
|
10
|
+
|
11
|
+
class << self
|
12
|
+
def method_to_op meth; meth.to_s.gsub("_", "-") end
|
13
|
+
def op_to_method op; op.gsub("-", "_").intern end
|
14
|
+
|
15
|
+
def operation method, desc
|
16
|
+
@operations ||= {}
|
17
|
+
@operations[method] = desc
|
18
|
+
end
|
19
|
+
|
20
|
+
def operations
|
21
|
+
@operations.map { |k, v| [method_to_op(k), v] }.sort_by { |k, v| k }
|
22
|
+
end
|
23
|
+
def has_operation? op; @operations.member? op_to_method(op) end
|
24
|
+
end
|
25
|
+
|
26
|
+
def do op, *a; send self.class.op_to_method(op), *a end
|
27
|
+
%w(operations has_operation?).each do |m|
|
28
|
+
define_method(m) { |*a| self.class.send m, *a }
|
29
|
+
end
|
30
|
+
|
31
|
+
operation :init, "Initialize the issue database for a new project"
|
32
|
+
def init
|
33
|
+
Project.create_interactively
|
34
|
+
end
|
35
|
+
|
36
|
+
operation :help, "List all registered commands"
|
37
|
+
def help
|
38
|
+
puts <<EOS
|
39
|
+
Registered commands:
|
40
|
+
EOS
|
41
|
+
ops = self.class.operations
|
42
|
+
len = ops.map { |name, desc| name.to_s.length }.max
|
43
|
+
ops.each { |name, desc| printf "%#{len}s: %s\n", name, desc }
|
44
|
+
puts
|
45
|
+
end
|
46
|
+
|
47
|
+
operation :add, "Add a bug/feature request"
|
48
|
+
def add project, config
|
49
|
+
issue = Issue.create_interactively(:args => [config, project]) or return
|
50
|
+
comment = ask_multiline "Comments"
|
51
|
+
issue.log "created", config.user, comment
|
52
|
+
project.add_issue issue
|
53
|
+
project.assign_issue_names!
|
54
|
+
puts "Added issue #{issue.name}."
|
55
|
+
end
|
56
|
+
|
57
|
+
operation :drop, "Drop a bug/feature request"
|
58
|
+
def drop project, config, issue_name
|
59
|
+
issue = project.issue_for issue_name
|
60
|
+
project.drop_issue issue
|
61
|
+
puts "Dropped #{issue.name}. Note that other issue names may have changed."
|
62
|
+
end
|
63
|
+
|
64
|
+
operation :add_release, "Add a release"
|
65
|
+
def add_release project, config
|
66
|
+
release = Release.create_interactively(:args => [project, config]) or return
|
67
|
+
comment = ask_multiline "Comments"
|
68
|
+
release.log "created", config.user, comment
|
69
|
+
project.add_release release
|
70
|
+
puts "Added release #{release.name}."
|
71
|
+
end
|
72
|
+
|
73
|
+
operation :add_component, "Add a component"
|
74
|
+
def add_component project, config
|
75
|
+
component = Component.create_interactively(:args => [project, config]) or return
|
76
|
+
project.add_component component
|
77
|
+
puts "Added component #{component.name}."
|
78
|
+
end
|
79
|
+
|
80
|
+
operation :add_reference, "Add a reference to an issue"
|
81
|
+
def add_reference project, config, issue_name
|
82
|
+
issue = project.issue_for issue_name
|
83
|
+
reference = ask "Reference"
|
84
|
+
comment = ask_multiline "Comments"
|
85
|
+
issue.add_reference reference, config.user, comment
|
86
|
+
puts "Added reference to #{issue.name}"
|
87
|
+
end
|
88
|
+
|
89
|
+
def parse_releases_arg project, releases_arg
|
90
|
+
ret = []
|
91
|
+
|
92
|
+
releases, show_unassigned, force_show = case releases_arg
|
93
|
+
when nil; [project.releases, true, false]
|
94
|
+
when "unassigned"; [[], true, true]
|
95
|
+
else
|
96
|
+
[[project.release_for(releases_arg)], false, true]
|
97
|
+
end
|
98
|
+
|
99
|
+
releases.each do |r|
|
100
|
+
next if r.released? unless force_show
|
101
|
+
|
102
|
+
bugs = project.issues.
|
103
|
+
select { |i| i.type == :bugfix && i.release == r.name }
|
104
|
+
feats = project.issues.
|
105
|
+
select { |i| i.type == :feature && i.release == r.name }
|
106
|
+
|
107
|
+
#next if bugs.empty? && feats.empty? unless force_show
|
108
|
+
|
109
|
+
ret << [r, bugs, feats]
|
110
|
+
end
|
111
|
+
|
112
|
+
return ret unless show_unassigned
|
113
|
+
|
114
|
+
bugs = project.issues.select { |i| i.type == :bugfix && i.release.nil? }
|
115
|
+
feats = project.issues.select { |i| i.type == :feature && i.release.nil? }
|
116
|
+
|
117
|
+
return ret if bugs.empty? && feats.empty? unless force_show
|
118
|
+
ret << [nil, bugs, feats]
|
119
|
+
end
|
120
|
+
|
121
|
+
operation :status, "Show project status"
|
122
|
+
def status project, config, release=nil
|
123
|
+
if project.releases.empty?
|
124
|
+
puts "No releases."
|
125
|
+
return
|
126
|
+
end
|
127
|
+
|
128
|
+
parse_releases_arg(project, release).each do |r, bugs, feats|
|
129
|
+
title, bar = [r ? r.name : "unassigned", status_bar_for(bugs + feats)]
|
130
|
+
|
131
|
+
ncbugs = bugs.count_of { |b| b.closed? }
|
132
|
+
ncfeats = feats.count_of { |f| f.closed? }
|
133
|
+
pcbugs = 100.0 * (bugs.empty? ? 1.0 : ncbugs.to_f / bugs.size)
|
134
|
+
pcfeats = 100.0 * (feats.empty? ? 1.0 : ncfeats.to_f / feats.size)
|
135
|
+
|
136
|
+
special = if bugs.empty? && feats.empty?
|
137
|
+
"(no issues)"
|
138
|
+
elsif ncbugs == bugs.size && ncfeats == feats.size
|
139
|
+
"(ready for release)"
|
140
|
+
else
|
141
|
+
bar
|
142
|
+
end
|
143
|
+
|
144
|
+
printf "%-10s %2d/%2d (%3.0f%%) bugs, %2d/%2d (%3.0f%%) features %s\n",
|
145
|
+
title, ncbugs, bugs.size, pcbugs, ncfeats, feats.size, pcfeats, special
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def status_bar_for issues
|
150
|
+
Issue::STATUS_WIDGET.
|
151
|
+
sort_by { |k, v| -Issue::STATUS_SORT_ORDER[k] }.
|
152
|
+
map { |k, v| v * issues.count_of { |i| i.status == k } }.
|
153
|
+
join
|
154
|
+
end
|
155
|
+
|
156
|
+
def todo_list_for issues
|
157
|
+
name_len = issues.max_of { |i| i.name.length }
|
158
|
+
issues.map do |i|
|
159
|
+
sprintf "%s %#{name_len}s: %s\n", i.status_widget, i.name, i.title
|
160
|
+
end.join
|
161
|
+
end
|
162
|
+
|
163
|
+
operation :todo, "Generate todo list"
|
164
|
+
def todo project, config, release=nil
|
165
|
+
actually_do_todo project, config, release, false
|
166
|
+
end
|
167
|
+
|
168
|
+
operation :todo_full, "Generate full todo list, including completed items"
|
169
|
+
def todo_full project, config, release=nil
|
170
|
+
actually_do_todo project, config, release, true
|
171
|
+
end
|
172
|
+
|
173
|
+
def actually_do_todo project, config, release, full
|
174
|
+
parse_releases_arg(project, release).each do |r, bugs, feats|
|
175
|
+
if r
|
176
|
+
puts "Version #{r.name} (#{r.status}):"
|
177
|
+
else
|
178
|
+
puts "Unassigned:"
|
179
|
+
end
|
180
|
+
issues = bugs + feats
|
181
|
+
issues = issues.select { |i| i.open? } unless full
|
182
|
+
print todo_list_for(issues.sort_by { |i| i.sort_order })
|
183
|
+
puts
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
operation :show, "Describe a single issue"
|
188
|
+
def show project, config, name
|
189
|
+
issue = project.issue_for name
|
190
|
+
status = case issue.status
|
191
|
+
when :closed
|
192
|
+
"#{issue.status_string}: #{issue.disposition_string}"
|
193
|
+
else
|
194
|
+
issue.status_string
|
195
|
+
end
|
196
|
+
puts <<EOS
|
197
|
+
#{"Issue #{issue.name}".underline}
|
198
|
+
Title: #{issue.title}
|
199
|
+
Description: #{issue.interpolated_desc(project.issues).multiline " "}
|
200
|
+
Status: #{status}
|
201
|
+
Creator: #{issue.reporter}
|
202
|
+
Age: #{issue.creation_time.ago}
|
203
|
+
Release: #{issue.release}
|
204
|
+
References: #{issue.references.listify " "}
|
205
|
+
|
206
|
+
Event log:
|
207
|
+
#{format_log_events issue.log_events}
|
208
|
+
EOS
|
209
|
+
end
|
210
|
+
|
211
|
+
def format_log_events events
|
212
|
+
return "none" if events.empty?
|
213
|
+
events.map do |time, who, what, comment|
|
214
|
+
"- #{time.pretty} :: #{who}\n #{what}#{comment.multiline " > "}"
|
215
|
+
end.join("\n")
|
216
|
+
end
|
217
|
+
|
218
|
+
operation :start, "Start work on an issue"
|
219
|
+
def start project, config, name
|
220
|
+
issue = project.issue_for name
|
221
|
+
comment = ask_multiline "Comments"
|
222
|
+
issue.start_work config.user, comment
|
223
|
+
puts "Recorded start of work for #{issue.name}."
|
224
|
+
end
|
225
|
+
|
226
|
+
operation :stop, "Stop work on an issue"
|
227
|
+
def stop project, config, name
|
228
|
+
issue = project.issue_for name
|
229
|
+
comment = ask_multiline "Comments"
|
230
|
+
issue.stop_work config.user, comment
|
231
|
+
puts "Recorded work stop for #{issue.name}."
|
232
|
+
end
|
233
|
+
|
234
|
+
operation :close, "Close an issue"
|
235
|
+
def close project, config, name
|
236
|
+
issue = project.issue_for name
|
237
|
+
puts "Closing issue #{issue.name}: #{issue.title}."
|
238
|
+
disp = ask_for_selection Issue::DISPOSITIONS, "disposition", lambda { |x| Issue::DISPOSITION_STRINGS[x] || x.to_s }
|
239
|
+
comment = ask_multiline "Comments"
|
240
|
+
issue.close disp, config.user, comment
|
241
|
+
puts "Closed issue #{issue.name} with disposition #{issue.disposition_string}."
|
242
|
+
end
|
243
|
+
|
244
|
+
operation :assign, "Assign an issue to a release"
|
245
|
+
def assign project, config, issue_name
|
246
|
+
issue = project.issue_for issue_name
|
247
|
+
puts "Issue #{issue.name} currently " + if issue.release
|
248
|
+
"assigned to release #{issue.release}."
|
249
|
+
else
|
250
|
+
"not assigned to any release."
|
251
|
+
end
|
252
|
+
release = ask_for_selection project.releases, "release", :name
|
253
|
+
comment = ask_multiline "Comments"
|
254
|
+
issue.assign_to_release release, config.user, comment
|
255
|
+
puts "Assigned #{issue.name} to #{release.name}"
|
256
|
+
end
|
257
|
+
|
258
|
+
operation :unassign, "Unassign an issue from any releases"
|
259
|
+
def unassign project, config, issue_name
|
260
|
+
issue = project.issue_for issue_name
|
261
|
+
comment = ask_multiline "Comments"
|
262
|
+
issue.unassign config.user, comment
|
263
|
+
puts "Unassigned #{issue.name}."
|
264
|
+
end
|
265
|
+
|
266
|
+
operation :comment, "Comment on an issue"
|
267
|
+
def comment project, config, issue_name
|
268
|
+
issue = project.issue_for issue_name
|
269
|
+
comment = ask_multiline "Comments"
|
270
|
+
issue.log "commented", config.user, comment
|
271
|
+
puts "Comments recorded for #{issue.name}."
|
272
|
+
end
|
273
|
+
|
274
|
+
operation :releases, "Show releases"
|
275
|
+
def releases project, config
|
276
|
+
project.releases.each do |r|
|
277
|
+
status = r.released? ? "released #{r.release_time.pretty_date}" : r.status
|
278
|
+
puts "#{r.name} (#{status})"
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
operation :release, "Release a release"
|
283
|
+
def release project, config, release_name
|
284
|
+
release = project.release_for release_name
|
285
|
+
comment = ask_multiline "Comments"
|
286
|
+
release.release! project, config.user, comment
|
287
|
+
puts "Release #{release.name} released!"
|
288
|
+
end
|
289
|
+
|
290
|
+
operation :changelog, "Generate a changelog for a release"
|
291
|
+
def changelog project, config, relnames
|
292
|
+
parse_releases_arg(project, relnames).each do |r, bugs, feats|
|
293
|
+
puts "== #{r.name} / #{r.release_time.pretty_date}" if r.released?
|
294
|
+
feats.select { |f| f.closed? }.each { |i| puts "* #{i.title}" }
|
295
|
+
bugs.select { |f| f.closed? }.each { |i| puts "* bugfix: #{i.title}" }
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
299
|
+
operation :html, "Generate html status pages"
|
300
|
+
def html project, config, dir="html"
|
301
|
+
#FileUtils.rm_rf dir
|
302
|
+
Dir.mkdir dir unless File.exists? dir
|
303
|
+
|
304
|
+
## find the ERB templates. this is my brilliant approach
|
305
|
+
## to the 'gem datadir' problem.
|
306
|
+
template_dir = $:.find { |p| File.exists? File.join(p, "index.rhtml") }
|
307
|
+
|
308
|
+
FileUtils.cp File.join(template_dir, "style.css"), dir
|
309
|
+
|
310
|
+
## build up links
|
311
|
+
links = {}
|
312
|
+
project.releases.each { |r| links[r] = "release-#{r.name}.html" }
|
313
|
+
project.issues.each { |i| links[i] = "issue-#{i.id}.html" }
|
314
|
+
project.components.each { |c| links[c] = "component-#{c.name}.html" }
|
315
|
+
links["unassigned"] = "unassigned.html" # special case: unassigned
|
316
|
+
links["index"] = "index.html" # special case: index
|
317
|
+
|
318
|
+
project.issues.each do |issue|
|
319
|
+
fn = File.join dir, links[issue]
|
320
|
+
puts "Generating #{fn}..."
|
321
|
+
File.open(fn, "w") do |f|
|
322
|
+
f.puts ErbHtml.new(template_dir, "issue", links, :issue => issue,
|
323
|
+
:release => (issue.release ? project.release_for(issue.release) : nil),
|
324
|
+
:component => project.component_for(issue.component),
|
325
|
+
:project => project)
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
329
|
+
project.releases.each do |r|
|
330
|
+
fn = File.join dir, links[r]
|
331
|
+
puts "Generating #{fn}..."
|
332
|
+
File.open(fn, "w") do |f|
|
333
|
+
f.puts ErbHtml.new(template_dir, "release", links, :release => r,
|
334
|
+
:issues => project.issues_for_release(r), :project => project)
|
335
|
+
end
|
336
|
+
end
|
337
|
+
|
338
|
+
project.components.each do |c|
|
339
|
+
fn = File.join dir, links[c]
|
340
|
+
puts "Generating #{fn}..."
|
341
|
+
File.open(fn, "w") do |f|
|
342
|
+
f.puts ErbHtml.new(template_dir, "component", links, :component => c,
|
343
|
+
:issues => project.issues_for_component(c), :project => project)
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
fn = File.join dir, links["unassigned"]
|
348
|
+
puts "Generating #{fn}..."
|
349
|
+
File.open(fn, "w") do |f|
|
350
|
+
f.puts ErbHtml.new(template_dir, "unassigned", links,
|
351
|
+
:issues => project.unassigned_issues, :project => project)
|
352
|
+
end
|
353
|
+
|
354
|
+
past_rels, upcoming_rels = project.releases.partition { |r| r.released? }
|
355
|
+
fn = File.join dir, links["index"]
|
356
|
+
puts "Generating #{fn}..."
|
357
|
+
File.open(fn, "w") do |f|
|
358
|
+
f.puts ErbHtml.new(template_dir, "index", links, :project => project,
|
359
|
+
:past_releases => past_rels, :upcoming_releases => upcoming_rels,
|
360
|
+
:components => project.components)
|
361
|
+
end
|
362
|
+
end
|
363
|
+
|
364
|
+
operation :validate, "Validate project status"
|
365
|
+
def validate project, config
|
366
|
+
## a no-op
|
367
|
+
end
|
368
|
+
|
369
|
+
operation :grep, "Show issues matching a string or regular expression"
|
370
|
+
def grep project, config, match
|
371
|
+
re = /#{match}/
|
372
|
+
issues = project.issues.select { |i| i.title =~ re || i.desc =~ re }
|
373
|
+
print_todo issues
|
374
|
+
end
|
375
|
+
|
376
|
+
operation :edit, "Edit an issue"
|
377
|
+
def edit project, config, issue_name
|
378
|
+
issue = project.issue_for issue_name
|
379
|
+
data = { :title => issue.title, :description => issue.desc,
|
380
|
+
:reporter => issue.reporter }
|
381
|
+
|
382
|
+
f = Tempfile.new("ditz")
|
383
|
+
f.puts data.to_yaml
|
384
|
+
f.close
|
385
|
+
editor = ENV["EDITOR"] || "/usr/bin/vi"
|
386
|
+
cmd = "#{editor} #{f.path.inspect}"
|
387
|
+
Ditz::debug "running: #{cmd}"
|
388
|
+
|
389
|
+
mtime = File.mtime f.path
|
390
|
+
system cmd or raise Error, "cannot execute command: #{cmd.inspect}"
|
391
|
+
if File.mtime(f.path) == mtime
|
392
|
+
puts "Aborted."
|
393
|
+
return
|
394
|
+
end
|
395
|
+
|
396
|
+
comment = ask_multiline "Comments"
|
397
|
+
begin
|
398
|
+
edits = YAML.load_file f.path
|
399
|
+
if issue.change edits, config.user, comment
|
400
|
+
puts "Changed recorded."
|
401
|
+
else
|
402
|
+
puts "No changes."
|
403
|
+
end
|
404
|
+
end
|
405
|
+
end
|
406
|
+
end
|
407
|
+
|
408
|
+
end
|
data/lib/release.rhtml
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
2
|
+
<head>
|
3
|
+
<title><%= project.name %> release <%= release.name %></title>
|
4
|
+
<meta http-equiv="Content-Type" content="text/html; charset=utf8" />
|
5
|
+
<link rel="stylesheet" href="style.css" type="text/css" />
|
6
|
+
</head>
|
7
|
+
<body>
|
8
|
+
|
9
|
+
<%= link_to "index", "« #{project.name} project page" %>
|
10
|
+
|
11
|
+
<h1><%= project.name %> release <%= release.name %></h1>
|
12
|
+
|
13
|
+
<p>
|
14
|
+
<table>
|
15
|
+
<tr>
|
16
|
+
<td class="attrname">Status:</td>
|
17
|
+
<td class="attrval"><%= release.status %></td>
|
18
|
+
</tr>
|
19
|
+
<% if release.released? %>
|
20
|
+
<tr>
|
21
|
+
<td class="attrname">Release time:</td>
|
22
|
+
<td class="attrval"><%= release.release_time %></td>
|
23
|
+
</tr>
|
24
|
+
<% end %>
|
25
|
+
<tr>
|
26
|
+
<%
|
27
|
+
num_done = issues.count_of { |i| i.closed? }
|
28
|
+
pct_done = issues.size == 0 ? 100 : (100.0 * num_done / issues.size)
|
29
|
+
%>
|
30
|
+
<td class="attrname">Completion:</td>
|
31
|
+
<td class="attrval"><%= sprintf "%.0f%%", pct_done %></td>
|
32
|
+
</tr>
|
33
|
+
</table>
|
34
|
+
</p>
|
35
|
+
|
36
|
+
<h2>Issues</h2>
|
37
|
+
<% if issues.empty? %>
|
38
|
+
<p>No issues assigned to this release.</p>
|
39
|
+
<% else %>
|
40
|
+
<%= render "issue_table", :show_component => false, :show_release => false %>
|
41
|
+
<% end %>
|
42
|
+
|
43
|
+
<h2>Release log</h2>
|
44
|
+
<table>
|
45
|
+
<% release.log_events.each_with_index do |(time, who, what, comment), i| %>
|
46
|
+
<% if i % 2 == 0 %>
|
47
|
+
<tr class="logentryeven">
|
48
|
+
<% else %>
|
49
|
+
<tr class="logentryodd">
|
50
|
+
<% end %>
|
51
|
+
<td class="logtime"><%=h time %></td>
|
52
|
+
<td class="logwho"><%=obscured_email who %></td>
|
53
|
+
<td class="logwhat"><%=h what %></td>
|
54
|
+
</tr>
|
55
|
+
<tr><td colspan="3" class="logcomment">
|
56
|
+
<% if comment.empty? %>
|
57
|
+
<% else %>
|
58
|
+
<%=p comment %>
|
59
|
+
<% end %>
|
60
|
+
</td></tr>
|
61
|
+
<% end %>
|
62
|
+
</table>
|
63
|
+
|
64
|
+
<p class="footer">Generated <%= Time.now %> by <a href="http://ditz.rubyforge.org/">ditz</a>.
|
65
|
+
|
66
|
+
</body>
|
67
|
+
</html>
|