ditz 0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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>