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.
@@ -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
@@ -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", "&laquo; #{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>