ditz 0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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>
|