ohac-ditz 0.5.1 → 0.5.2

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,48 @@
1
+ require 'time'
2
+
3
+ module Ditz
4
+ class Issue
5
+ def hg_commits
6
+ return @hg_commits if @hg_commits
7
+ output = `hg log --template '{date|rfc822date}\t{author}\t{node|short}\t{desc|firstline}\n' --keyword '#{id}'`
8
+
9
+ @hg_commits = output.split("\n").map {|line|
10
+ date, *vals = line.split("\t")
11
+ [Time.parse(date), *vals]
12
+ }
13
+ end
14
+ end
15
+
16
+ class Config
17
+ field :mercurial_commit_url_prefix, :prompt => "URL prefix (if any) to link mercurial commits to"
18
+ end
19
+
20
+ class ScreenView
21
+ add_to_view :issue_details do |issue, config|
22
+ next if (commits = issue.hg_commits[0...5]).empty?
23
+
24
+ commits.map {|date, author, node, desc|
25
+ "- #{desc} [#{node}] (#{author.shortened_email}, #{date.ago} ago)"
26
+ }.unshift('Recent commits:').join("\n") + "\n"
27
+ end
28
+ end
29
+
30
+ class HtmlView
31
+ add_to_view :issue_details do |issue, config|
32
+ next if (commits = issue.hg_commits).empty?
33
+
34
+ [<<-EOS, {:commits => commits, :url_prefix => config.mercurial_commit_url_prefix}]
35
+ <h2>Commits for this issue</h2>
36
+ <table>
37
+ <% commits.each_with_index do |(date, author, node, desc), i| %>
38
+ <tr class="logentry<%= i.even? ? 'even' : 'odd' %>">
39
+ <td class="logtime"><%=t date %></td>
40
+ <td class="logwho"><%=obscured_email author %></td>
41
+ <td class="logwhat"><%=h desc %> [<%= url_prefix ? link_to([url_prefix, node].join, node) : node %>]</td>
42
+ </tr>
43
+ <% end %>
44
+ </table>
45
+ EOS
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,35 @@
1
+ ## sha-names ditz plugin
2
+ ##
3
+ ## This world's-smallest-ditz-plugin uses the initial 5 characters of
4
+ ## the SHA id instead of an identifier like "ditz-999". Installing
5
+ ## this plugin will cause all references of the form 'ditz-123' and
6
+ ## 'engine-57' to change to '1a2bc', 'f33d0' and similarly memorable
7
+ ## IDs. If you are comfortable working with them (your clients may
8
+ ## not be...) these make all issue IDs unique across the project, so
9
+ ## long as you do not get a collision between two 5-hex-char IDs.
10
+ ##
11
+ ## Without this plugin, the standard ID for an issue will be of the
12
+ ## form 'design-123'. Whilst this is easier to remember, it is also
13
+ ## liable to change - for example, if two ditz trees are merged
14
+ ## together, or if an issue is re-assigned from one component to
15
+ ## another. This plugin provides a canonical, immutable ID from the
16
+ ## time of issue creation.
17
+ ##
18
+ ## Usage:
19
+ ## 1. add a line "- sha-names" to the .ditz-plugins file in the
20
+ ## project root
21
+
22
+ module Ditz
23
+
24
+ class Project
25
+
26
+ SHA_NAME_LENGTH = 5
27
+
28
+ def assign_issue_names!
29
+ issues.sort_by { |i| i.creation_time }.each do |i|
30
+ i.name = i.id.slice(0,SHA_NAME_LENGTH)
31
+ end
32
+ end
33
+
34
+ end
35
+ end
@@ -1,4 +1,4 @@
1
- .TH "ditz" "1" "0.5" "" ""
1
+ .TH "ditz" "1" "0.5.2" "" ""
2
2
  .SH "NAME"
3
3
  ditz \- simple, light\-weight distributed issue tracker
4
4
  .SH "SYNOPSIS"
@@ -0,0 +1,876 @@
1
+ require 'rubygems'
2
+ require 'ditz'
3
+ require 'socket'
4
+ require 'trollop'
5
+ require 'fastthread'
6
+
7
+ require 'camping'
8
+ require 'camping/server'
9
+ require 'digest/md5'
10
+
11
+ Camping.goes :Sheila
12
+
13
+ class String
14
+ def obfu; gsub(/( <.+?)@.+>$/, '\1@...>') end
15
+ def prefix; self[0,8] end
16
+ def gravatar(s=20)
17
+ email = split.last
18
+ email = email[1, email.size - 2] if email[0, 1] == '<'
19
+ "http://www.gravatar.com/avatar/#{Digest::MD5.hexdigest(email)}?s=#{s}"
20
+ end
21
+ end
22
+
23
+ class Ditz::Release
24
+ def fancy_name
25
+ if released?
26
+ "#{name} (#{release_time.ago} ago)"
27
+ else
28
+ name
29
+ end
30
+ end
31
+ end
32
+
33
+ hostname = begin
34
+ Socket.gethostbyname(Socket.gethostname).first
35
+ rescue SocketError
36
+ Socket.gethostname
37
+ end
38
+
39
+ ## tweak these if you want!
40
+ GIT_AUTHOR_NAME = "Sheila"
41
+ GIT_AUTHOR_EMAIL = "sheila@#{hostname}"
42
+ CONFIG_FN = ".ditz-config"
43
+ PLUGIN_FN = ".ditz-plugins"
44
+
45
+ class Hash
46
+ ## allow "a[b]" lookups for two-level nested hashes. returns empty strings
47
+ ## instead of nil.
48
+ def resolve s
49
+ raise ArgumentError, "not in expected format" unless s =~ /(\S+?)\[(\S+?)\]$/
50
+ a, b = $1, $2
51
+ (self[a] && self[a][b]) || ""
52
+ end
53
+ end
54
+
55
+ ## config holders
56
+ class << Sheila
57
+ attr_reader :project, :config, :storage, :private, :reporter
58
+ def create opts
59
+ ## load plugins
60
+ plugin_fn = File.join Ditz::find_dir_containing(PLUGIN_FN) || ".", PLUGIN_FN
61
+ Ditz::load_plugins(plugin_fn) if File.exist?(plugin_fn)
62
+
63
+ ## load config
64
+ config_fn = File.join Ditz::find_dir_containing(CONFIG_FN) || ".", CONFIG_FN
65
+ Ditz::debug "loading config from #{config_fn}"
66
+ @private = opts[:private]
67
+ @config = Ditz::Config.from config_fn
68
+ if @private
69
+ @reporter = "#{@config.name} <#{@config.email}>"
70
+ else
71
+ @config.name = GIT_AUTHOR_NAME # just overwrite these two fields
72
+ @config.email = GIT_AUTHOR_EMAIL
73
+ end
74
+
75
+ ## load project
76
+ @storage = Ditz::FileStorage.new File.join(File.dirname(config_fn), @config.issue_dir)
77
+ @project = @storage.load
78
+
79
+ @mutex = Mutex.new
80
+ end
81
+
82
+ def add_issue! issue
83
+ @mutex.synchronize do
84
+ @project.add_issue issue
85
+ save! "added issue #{issue.id.prefix}"
86
+ end
87
+ end
88
+
89
+ def add_comment! issue, author, comment
90
+ @mutex.synchronize do
91
+ issue.log "commented", author, comment
92
+ save! "comment on #{issue.id.prefix}"
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def save! message
99
+ message = message.gsub(/'/, "")
100
+ @storage.save @project
101
+ end
102
+ end
103
+
104
+ module Sheila::Controllers
105
+ class Index
106
+ def get
107
+ ## process filter parameters
108
+ type, type_check = case(t = @input["type"])
109
+ when "open"; ["open", lambda { |i| i.open? }]
110
+ when "closed"; ["closed", lambda { |i| i.closed? }]
111
+ when "in_progress"; ["in progress", lambda { |i| i.in_progress? }]
112
+ end
113
+
114
+ release, release_check = case(r = @input["release"])
115
+ when "unassigned"; [:unassigned, lambda { |i| i.release.nil? }]
116
+ when String
117
+ release = Sheila.project.release_for r
118
+ [release, release && lambda { |i| i.release == release.name }]
119
+ end
120
+
121
+ component, component_check = case(c = @input["component"])
122
+ when String
123
+ component = Sheila.project.component_for c
124
+ [component, component && lambda { |i| i.component == component.name }]
125
+ end
126
+
127
+ query, query_check = case(q = @input["query"])
128
+ when /\S/
129
+ re = Regexp.new q, true
130
+ [q, lambda { |i| i.title =~ re || i.desc =~ re || i.log_events.map { |time, who, what, comments| comments }.join(" ") =~ re }]
131
+ end
132
+
133
+ ## build issue list
134
+ @issues = Sheila.project.issues.select do |i|
135
+ (type_check.nil? || type_check[i]) &&
136
+ (release_check.nil? || release_check[i]) &&
137
+ (component_check.nil? || component_check[i]) &&
138
+ (query_check.nil? || query_check[i])
139
+ end.sort_by { |i| i.last_event_time || i.creation_time }.reverse
140
+
141
+ ## build title
142
+ @title = [
143
+ @issues.size,
144
+ type,
145
+ (release == :unassigned ? "unassigned" : nil),
146
+ (component ? component.name : nil),
147
+ (@issues.size == 1 ? "issue" : "issues"),
148
+ (Ditz::Release === release ? "in #{release.name}" : nil),
149
+ (query ? "matching #{query.inspect}" : nil),
150
+ ].compact.join(" ")
151
+
152
+ ## decide whether to show the "add new issue" link
153
+ @show_add_link = true
154
+
155
+ ## go!
156
+ @releases = Sheila.project.releases.select { |r| r.unreleased? }
157
+ render :index
158
+ end
159
+ end
160
+ class TicketX
161
+ def initialize(*a)
162
+ super(*a)
163
+ @errors = []
164
+ end
165
+ def get sha
166
+ len = sha.length
167
+ @issue = Sheila.project.issues.find { |i| i.id[0 ... len] == sha }
168
+ render :ticket
169
+ end
170
+ def post sha
171
+ len = sha.length
172
+ @issue = Sheila.project.issues.find { |i| i.id[0 ... len] == sha }
173
+
174
+ # extra validation. probably not great that it's here.
175
+ @errors << "email address is invalid" unless @input.resolve("comment[author]") =~ /@/
176
+ @errors << "comment text is empty" unless @input.resolve("comment[text]") =~ /\S/
177
+
178
+ if @errors.empty?
179
+ comment = @input.resolve "comment[text]"
180
+ unless Sheila.private
181
+ comment += "\n\n(submitted via Sheila by #{@env['REMOTE_HOST']} (#{@env['REMOTE_ADDR']}))"
182
+ end
183
+
184
+ Sheila.add_comment! @issue, @input.resolve("comment[author]"), comment
185
+ @input["comment"] = {} # clear fields
186
+ end
187
+
188
+ render :ticket
189
+ end
190
+ end
191
+ class New
192
+ def initialize *a
193
+ super(*a)
194
+ @errors = []
195
+ end
196
+
197
+ def get
198
+ url = @input['u']
199
+ title = @input['t']
200
+ quote = @input['s']
201
+ desc = url
202
+ desc = "#{url} \"#{quote}\"" if quote && quote.size > 0
203
+ reporter = @input['r'] || Sheila.reporter
204
+ type = @input['y']
205
+ component = @input['c']
206
+ release = @input['R']
207
+ @input['ticket'] = {
208
+ 'title' => title,
209
+ 'desc' => desc,
210
+ 'reporter' => reporter,
211
+ 'type' => type,
212
+ 'component' => component,
213
+ 'release' => release,
214
+ }
215
+ render :editor
216
+ end
217
+ def post
218
+ @input['ticket']['release'] = nil if @input['ticket']['release'] == ""
219
+ @input['ticket']['type'] = @input.resolve("ticket[type]").intern unless @input.resolve("ticket[type]") == ""
220
+ @input['ticket']['component'] ||= Sheila.project.components.first.name
221
+
222
+ # extra validation. probably not great that it's here.
223
+ @errors << "the email address was invalid" unless @input["ticket"]["reporter"] =~ /@/
224
+
225
+ if @errors.empty?
226
+ begin
227
+ @issue = Ditz::Issue.create @input['ticket'], [Sheila.config, Sheila.project]
228
+ comment = "(Created via Sheila by #{@env['REMOTE_HOST']} (#{@env['REMOTE_ADDR']}))" unless Sheila.private
229
+ @issue.log "created", @input.resolve("ticket[reporter]"), comment
230
+ Sheila.add_issue! @issue
231
+ rescue Ditz::ModelError => e
232
+ @errors << e.message
233
+ end
234
+ end
235
+
236
+ if @errors.empty?
237
+ redirect TicketX, @issue.id
238
+ else
239
+ render :editor
240
+ end
241
+ end
242
+ end
243
+ class ReleaseX
244
+ def get num
245
+ @release = Sheila.project.releases[num.to_i] # see docs for make_release_link
246
+ @created, @desc = @release.log_events[0].first, @release.log_events[0].last
247
+ @issues = Sheila.project.issues_for_release @release
248
+ render :release
249
+ end
250
+ end
251
+ class Unassigned
252
+ def get
253
+ @release = nil
254
+ @issues = Sheila.project.unassigned_issues
255
+ render :release
256
+ end
257
+ end
258
+ class Style < R '/styles.css'
259
+ def get
260
+ @headers["Content-Type"] = "text/css; charset=utf-8"
261
+ @body = Sheila::CSS
262
+ end
263
+ end
264
+ end
265
+
266
+ module Sheila::Views
267
+ def layout
268
+ html do
269
+ head do
270
+ title "Sheila: #{Sheila.project.name}"
271
+ link :rel => 'stylesheet', :type => 'text/css',
272
+ :href => '/styles.css', :media => 'screen'
273
+ end
274
+ body do
275
+ h1.header { a Sheila.project.name, :href => R(Index) }
276
+ div.content do
277
+ self << yield
278
+ end
279
+ end
280
+ end
281
+ end
282
+
283
+ def filter_list
284
+ form.filters :action => R(Index) do
285
+ fieldset.filters do
286
+ span "Status: "
287
+ select.filter :name => "type" do
288
+ option "all", prune_opts(:selected => ["all", "", nil].member?(@input["type"]), :value => "")
289
+ option "open", prune_opts(:selected => @input["type"] == "open", :value => "open")
290
+ option "closed", prune_opts(:selected => @input["type"] == "closed", :value => "closed")
291
+ option "in progress", prune_opts(:selected => @input["type"] == "in_progress", :value => "in_progress")
292
+ end
293
+ span " Release: "
294
+ select.filter :name => "release" do
295
+ option "all", prune_opts(:selected => ["all", "", nil].member?(@input["release"]), :value => "")
296
+ Sheila.project.releases.sort_by { |r| r.release_time || Time.now }.reverse.each do |r|
297
+ option r.fancy_name, prune_opts(:value => r.name, :selected => @input["release"] == r.name)
298
+ end
299
+ option "Unassigned", prune_opts(:selected => @input["release"] == "unassigned", :value => "")
300
+ end
301
+ if Sheila.project.components.size > 1
302
+ span " Component: "
303
+ select.filter :name => "component" do
304
+ option "all", prune_opts(:selected => ["all", "", nil].member?(@input["component"]), :value => "")
305
+ Sheila.project.components.each do |c|
306
+ option c.name, prune_opts(:value => c.name, :selected => @input["component"] == c.name)
307
+ end
308
+ end
309
+ end
310
+ span " Search: "
311
+ input :value => @input["query"], :name => "query", :size => 10
312
+ text " "
313
+ input :value => ">", :type => "submit"
314
+ end
315
+ end
316
+ end
317
+
318
+ def index
319
+ unreleased_release_table @releases
320
+ ticket_table @issues, :show_add_link => @show_add_link, :add_link_params => @add_link_params
321
+ end
322
+
323
+ def progress_meter p, size=50
324
+ done = (p * size).to_i
325
+ undone = [size - done, 0].max
326
+ span.progress_meter do
327
+ span.progress_meter_done { text("&nbsp;" * done) }
328
+ span.progress_meter_undone { text("&nbsp;" * undone) }
329
+ end
330
+ end
331
+
332
+ ## unfortunately R(ReleaseX, release_name) raises a "bad route" if the
333
+ ## release name has any dots or dashes in it
334
+ ##
335
+ ## so instead, we do this foul thing:
336
+ def make_release_link r
337
+ r = Sheila.project.releases.find { |x| x.name == r } if r.is_a?(String)
338
+ R ReleaseX, Sheila.project.releases.index(r)
339
+ end
340
+
341
+ def unreleased_release_row r
342
+ issues = Sheila.project.issues_for_release r
343
+ num_done = issues.count_of { |i| i.closed? }
344
+ pct_done = issues.size == 0 ? 1.0 : (num_done.to_f / issues.size.to_f)
345
+ open_issues = issues.select { |i| i.open? }
346
+
347
+ tr do
348
+ td.release_name do
349
+ a r.name, :href => make_release_link(r)
350
+ text " "
351
+ a.filter "[filter]", :href => R(Index) + "?release=#{r.name}"
352
+ end
353
+ td.release_desc do
354
+ progress_meter pct_done
355
+ text " "
356
+ if issues.empty?
357
+ text "(no issues)."
358
+ else
359
+ text sprintf(" %.0f%% complete ", pct_done * 100.0)
360
+ if open_issues.empty?
361
+ text "(ready for release!)."
362
+ else
363
+ #text "(#{num_done} / #{issues.size} issues)."
364
+ end
365
+ end
366
+ end
367
+ end
368
+ end
369
+
370
+ def unreleased_release_table releases
371
+ h4 "Upcoming Releases"
372
+
373
+ table.releases do
374
+ releases.select { |r| r.unreleased? }.each do |r|
375
+ unreleased_release_row r
376
+ end
377
+ end
378
+ end
379
+
380
+ def ticket_table issues, opts={}
381
+ show_add_link = opts[:show_add_link]
382
+ add_link_params = opts[:add_link_params]
383
+
384
+ h4 "Issues"
385
+ filter_list
386
+
387
+ table.tickets! do
388
+ tr do
389
+ th "ID"
390
+ th "Title"
391
+ th "State"
392
+ end
393
+ tr do
394
+ td.unique ""
395
+ add_link_extra = add_link_params ? "?" + add_link_params.map { |k, v| "#{k}=#{v}" }.join("&") : ""
396
+ td.title { a "Add a new issue", :href => R(New) + add_link_extra }
397
+ td.status ""
398
+ end if show_add_link
399
+ issues.each do |issue|
400
+ tr do
401
+ td.unique issue.id.prefix
402
+ td.title do
403
+ h3 { a issue.title, :href => R(TicketX, issue.id) }
404
+ p.about do
405
+ unless issue.log_events.empty?
406
+ whenn, who, what, comment = issue.log_events.last
407
+ #strong("#{(issue.last_event_time || issue.creation_time).ago} ago")
408
+ span what
409
+ strong " #{whenn.ago} ago"
410
+ span " by #{who.obfu} "
411
+ img :src => who.gravatar
412
+ end
413
+ end
414
+ comments = issue.log_events.select { |e| e[2] == "commented" } # :(
415
+ unless comments.empty?
416
+ p.about do
417
+ a "comment".pluralize(comments.size).capitalize, :href => R(TicketX, issue.id) + "#log"
418
+ end
419
+ end
420
+ end
421
+ td.status { issue.status.to_s.gsub(/_/, "&nbsp;") }
422
+ end
423
+ end
424
+ end
425
+ end
426
+
427
+ def release
428
+ h2(@release ? @release.name : "Unassigned issues")
429
+
430
+ if @release.release_time
431
+ h3 "Released #{@release.release_time.ago} ago"
432
+ else
433
+ h3 "Started #{@created.ago} ago"
434
+ end if @release
435
+
436
+ description @desc
437
+
438
+ p do
439
+ text "issue".pluralize(@issues.size).capitalize
440
+ text ". "
441
+ a "See all.", :href => R(Index) + "?release=#{@release.name}"
442
+ end
443
+
444
+ h4 "Log"
445
+ a :name => "log"
446
+
447
+ table.log { event_log @release.log_events }
448
+ end
449
+
450
+ def commit_log commits
451
+ commits.each do |at, email, hash, message|
452
+ tr.logentry do
453
+ td.ago "#{at.ago} ago"
454
+ td.who { span email.obfu; img :src => email.gravatar }
455
+ td do
456
+ text message
457
+ text " ["
458
+ a hash, :href => Sheila.config.git_commit_url_prefix + hash
459
+ text "]"
460
+ end
461
+ end
462
+ end
463
+ end
464
+
465
+ def description desc
466
+ div.description do
467
+ d = link_issue_names desc
468
+ if desc =~ /\n/
469
+ text d.gsub(/\r?\n/, "<br/>")
470
+ else
471
+ text d
472
+ end
473
+ end unless desc.blank?
474
+ end
475
+
476
+ def ticket
477
+ h2 @issue.title
478
+ h3 { span.unique.right @issue.id.prefix; span "created #{@issue.creation_time.ago} ago by #{@issue.reporter.obfu}"; img :src => @issue.reporter.gravatar }
479
+
480
+ description @issue.desc
481
+
482
+ div.details do
483
+ p { strong "Type: "; span @issue.type.to_s }
484
+ p do
485
+ strong "Release: "
486
+ if @issue.release
487
+ a @issue.release, :href => make_release_link(@issue.release)
488
+ else
489
+ text "unassigned"
490
+ end
491
+ end
492
+ p { strong "Component: "; span @issue.component } if Sheila.project.components.size > 1
493
+ p { strong "Status: "; span @issue.status.to_s }
494
+ p do
495
+ strong "References: "
496
+ @issue.references.each do |ref|
497
+ a ref, :href => ref
498
+ end
499
+ end
500
+ end
501
+
502
+ if @issue.respond_to?(:git_commits)
503
+ commits = @issue.git_commits
504
+ unless commits.empty?
505
+ h4 "Commits"
506
+ a :name => "commits"
507
+
508
+ table.log { commit_log commits }
509
+ end
510
+ end
511
+
512
+ h4 "Log"
513
+ a :name => "log"
514
+
515
+ table.log { event_log @issue.log_events }
516
+ div do
517
+ a :name => "new-comment"
518
+ issue_comment_form @issue, @errors
519
+ end
520
+ end
521
+
522
+ def link_issue_names s
523
+ Sheila.project.issues.inject(s) do |s, i|
524
+ s.gsub(/\b#{i.name}\b/, a("[#{i.id.prefix}]", :href => R(TicketX, i.id), :title => i.title, :name => i.title))
525
+ end
526
+ end
527
+
528
+ def event_log log
529
+ log.each do |at, name, action, comment|
530
+ tr.logentry do
531
+ td.ago "#{at.ago} ago"
532
+ td.who { span name.obfu; img :src => name.gravatar }
533
+ td.action action
534
+ end
535
+ unless comment.blank?
536
+ tr { td.comment(:colspan => 3) { text link_issue_names(comment) } }
537
+ end
538
+ end
539
+ end
540
+
541
+ def issue_comment_form issue, errors
542
+ reporter = @input['r'] || Sheila.reporter
543
+ @input['comment'] = {
544
+ 'author' => reporter,
545
+ }
546
+ form :method => 'POST', :action => R(TicketX, issue.id) + "#new-comment" do
547
+ fieldset do
548
+ div.required do
549
+ p.error "Sorry, I couldn't add that comment: #{errors.first}" unless errors.empty?
550
+ label.fieldname 'Comment', :for => 'comment'
551
+ textarea.standard @input.resolve("comment[text]"), :name => 'comment[text]'
552
+ end
553
+ div.required do
554
+ label.fieldname 'Your name & email', :for => 'comment[author]'
555
+ div.fielddesc { "In standard email format, e.g. \"Bob Bobson &lt;bob@bobson.com&gt;\"" }
556
+ input.standard :name => 'comment[author]', :type => 'text', :value => @input.resolve("comment[author]")
557
+ end
558
+ div.buttons do
559
+ input :name => 'submit', :value => 'Submit comment', :type => 'submit'
560
+ end
561
+ end
562
+ end
563
+ end
564
+
565
+ ## removes any instances of the tags in +remove+ that have values of
566
+ ## false or nil. this is because they can't appear in the HTML, even
567
+ ## if they're empty.
568
+ def prune_opts opts, remove=[:selected, :checked]
569
+ remove.each { |k| opts.delete(k) unless opts[k] }
570
+ opts
571
+ end
572
+
573
+ def editor
574
+ h2 "Submit a new #{Sheila.project.name} issue"
575
+
576
+ p.error "Sorry, I couldn't create that issue: #{@errors.first}" unless @errors.empty?
577
+
578
+ form :method => 'POST', :action => R(New) do
579
+ fieldset do
580
+ div.required do
581
+ label.fieldname 'Summary', :for => 'ticket[title]'
582
+ div.fielddesc { "A brief summary of the issue" }
583
+ input.standard :name => 'ticket[title]', :type => 'text', :value => @input.resolve("ticket[title]")
584
+ end
585
+ div.required do
586
+ label.fieldname 'Details', :for => 'ticket[desc]'
587
+ div.fielddesc { "All relevant details. For bug reports, be sure to include the version of #{Sheila.project.name}, and all information necessary to reproduce the bug." }
588
+ textarea.standard @input.resolve("ticket[desc]"), :name => 'ticket[desc]'
589
+ end
590
+ div.required do
591
+ label.fieldname 'Your name & email', :for => 'ticket[reporter]'
592
+ div.fielddesc { "In standard email format, e.g. \"Bob Bobson &lt;bob@bobson.com&gt;\"" }
593
+ input.standard :name => 'ticket[reporter]', :type => 'text', :value => @input.resolve("ticket[reporter]")
594
+ end
595
+ div.required do
596
+ label.fieldname 'Issue type'
597
+ div do
598
+
599
+ current_type = case(x = @input.resolve("ticket[type]"))
600
+ when ""; :bugfix
601
+ when String; x.intern
602
+ end
603
+
604
+ Ditz::Issue::TYPES.each do |t|
605
+ input prune_opts(:type => 'radio', :name => 'ticket[type]', :value => t.to_s, :id => "ticket[type]-#{t}", :checked => (current_type == t))
606
+ label " #{t} ", :for => "ticket[type]-#{t}"
607
+ end
608
+ end
609
+ end
610
+ div.required do
611
+ label.fieldname 'Release, if any', :for => 'ticket[release]'
612
+ current_r = @input.resolve "ticket[release]"
613
+ current_r = @input["release"] if current_r.blank?
614
+ select.standard :name => 'ticket[release]' do
615
+ option "No release", prune_opts(:selected => current_r.blank? || current_r == "unassigned", :value => "")
616
+ Sheila.project.releases.select { |r| r.unreleased? }.each do |r|
617
+ option r.fancy_name, prune_opts(:value => r.name, :selected => current_r == r.name)
618
+ end
619
+ end
620
+ end
621
+ if Sheila.project.components.size > 1
622
+ label.fieldname "Component", :for => 'ticket[component]'
623
+ select.standard :name => 'ticket[component]' do
624
+ Sheila.project.components.each { |c| option c.name, prune_opts(:selected => @input.resolve("ticket[component]") == c.name) }
625
+ end
626
+ end
627
+ div.buttons do
628
+ input :name => 'submit', :value => 'Submit issue', :type => 'submit'
629
+ end
630
+ end
631
+ end
632
+ end
633
+
634
+ def dewikify(str)
635
+ str.split(/\s*?(\{{3}(?:.+?)\}{3})|\n\n/m).map do |para|
636
+ next if para.empty?
637
+ if para =~ /\{{3}(?:\s*\#![^\n]+)?(.+?)\}{3}/m
638
+ self <<
639
+ pre($1).to_s.gsub(/ +#=\&gt;.+$/, '<span class="outputs">\0</span>').
640
+ gsub(/ +# .+$/, '<span class="comment">\0</span>')
641
+ else
642
+ case para
643
+ when /\A\* (.+)/m
644
+ ul { $1.split(/^\* /).map { |x| li x } }
645
+ when /\A==== (.+) ====/
646
+ h4($1)
647
+ when /\A=== (.+) ===/
648
+ h3($1)
649
+ when /\A== (.+) ==/
650
+ h2($1)
651
+ when /\A= (.+) =/
652
+ h1($1)
653
+ else
654
+ p(para)
655
+ end
656
+ # txt.gsub(/`(.+?)`/m, '<code>\1</code>').gsub(/\[\[BR\]\]/i, '<br />').
657
+ # gsub(/'''(.+?)'''/m, '<strong>\1</strong>').gsub(/''(.+?)''/m, '<em>\1</em>').
658
+ # gsub(/\[\[(\S+?) (.+?)\]\]/m, '<a href="\1">\2</a>').
659
+ # gsub(/\(\!\)/m, '<img src="/static/exclamation.png" />').
660
+ # gsub(/\!\\(\S+\.png)\!/, '<img class="inline" src="/static/\1" />').
661
+ # gsub(/\!(\S+\.png)\!/, '<img src="/static/\1" />')
662
+ end
663
+ end
664
+ end
665
+ end
666
+
667
+ Sheila::CSS = <<END
668
+ body { font: 0.75em/1.5 'Lucida Grande', sans-serif; color: #333; }
669
+ * { margin: 0; padding: 0; }
670
+ a { text-decoration: none; color: blue; }
671
+ a:hover { text-decoration: underline; }
672
+
673
+ h2 {
674
+ font-size: 36px;
675
+ font-weight: normal;
676
+ line-height: 120%;
677
+ padding-bottom: 0.1em;
678
+ padding-top: 0.5em;
679
+ }
680
+
681
+ label.fieldname {
682
+ font-size: large;
683
+ display: block;
684
+ }
685
+ div.fielddesc {
686
+ font-size: x-small;
687
+ }
688
+ h1.header {
689
+ background-color: #660;
690
+ margin: 0; padding: 4px 16px;
691
+ width: 800px;
692
+ margin: 0 auto;
693
+ }
694
+ h1.header a {
695
+ color: #fef;
696
+ }
697
+ fieldset {
698
+ border: none;
699
+ }
700
+ h3.field {
701
+ display: inline;
702
+ background-color: #eee;
703
+ padding: 4px;
704
+ }
705
+ div.required {
706
+ margin: 6px 0;
707
+ }
708
+ div.buttons {
709
+ border-top: solid 1px #eee;
710
+ padding: 6px 0;
711
+ }
712
+ input {
713
+ padding: 4px;
714
+ }
715
+ input.standard {
716
+ width: 100%;
717
+ }
718
+ select.standard {
719
+ width: 100%;
720
+ }
721
+ textarea.standard {
722
+ width: 100%;
723
+ height: 10em;
724
+ }
725
+ .right {
726
+ float: right;
727
+ width: 200px;
728
+ }
729
+ .full {
730
+ width: 500px;
731
+ }
732
+ div.right {
733
+ margin-right: 120px;
734
+ }
735
+
736
+ #tickets {
737
+ margin: 20px 0;
738
+ }
739
+ #tickets td {
740
+ font-size: 14px;
741
+ padding: 5px;
742
+ border-bottom: solid 1px #eee;
743
+ }
744
+ #tickets th {
745
+ font-size: 14px;
746
+ padding: 5px;
747
+ border-bottom: solid 3px #ccc;
748
+ }
749
+ #tickets td .about {
750
+ font-size: 11px;
751
+ }
752
+ div.content {
753
+ padding: 10px;
754
+ width: 800px;
755
+ margin: 0 auto;
756
+ }
757
+ p.error {
758
+ color: red;
759
+ }
760
+ div.details {
761
+ border-top: solid 1px #eee;
762
+ margin: 10px 0;
763
+ padding: 16px 0;
764
+ }
765
+ h4 {
766
+ color: white;
767
+ background-color: #ccc;
768
+ padding: 2px 6px;
769
+ margin-top: 2.0em;
770
+ margin-bottom: 1.0em;
771
+ }
772
+
773
+ table.log {
774
+ width: 100%;
775
+ }
776
+
777
+ table.log td {
778
+ border-bottom: solid 1px #eee;
779
+ }
780
+
781
+ tr.logentry {
782
+ padding-bottom: 10px;
783
+ margin-bottom: 10px;
784
+ font-size: small;
785
+ }
786
+ div.description {
787
+ padding: 10px;
788
+ font-size: large;
789
+ }
790
+ td.ago {
791
+ font-weight: bold;
792
+ }
793
+ td.action {
794
+ color: #a09;
795
+ }
796
+ td.comment {
797
+ background-color: #ffc;
798
+ padding: 10px;
799
+ color: #777;
800
+ white-space: pre;
801
+ }
802
+ .unique {
803
+ color: #999;
804
+ }
805
+ fieldset.filters input {
806
+ font-size: x-small;
807
+ padding: 1px;
808
+ }
809
+ form.filters {
810
+ text-align: right;
811
+ }
812
+
813
+ .progress_meter_done {
814
+ background-color: #50a9d1;
815
+ }
816
+
817
+ .progress_meter {
818
+ border: solid 1px #bbb;
819
+ }
820
+
821
+ td.release_name {
822
+ padding-right: 2em;
823
+ }
824
+
825
+ a.filter {
826
+ font-size: x-small;
827
+ }
828
+
829
+ END
830
+
831
+ ##### EXECUTION STARTS HERE #####
832
+ if __FILE__ == $0
833
+
834
+ opts = Trollop::options do
835
+ version "sheila (ditz version #{Ditz::VERSION})"
836
+
837
+ opt :verbose, "Verbose output", :default => false
838
+ opt :host, "Host on which to run", :default => "0.0.0.0"
839
+ opt :port, "Port on which to run", :default => 1234
840
+ opt :server, "Camping server type to use (mongrel, webrick, console, any)", :default => "any"
841
+ opt :private, "Private mode", :default => false
842
+ end
843
+
844
+ Ditz::verbose = opts[:verbose]
845
+
846
+ if opts[:server] == "any"
847
+ begin
848
+ require 'mongrel'
849
+ opts[:server] = "mongrel"
850
+ rescue LoadError
851
+ $stderr.puts "!! Could not load mongrel. Falling back to webrick."
852
+ opts[:server] = "webrick"
853
+ end
854
+ end
855
+
856
+ ## next part stolen from camping/server.rb.
857
+ handler, conf = case opts[:server]
858
+ when "console"
859
+ ARGV.clear
860
+ IRB.start
861
+ exit
862
+ when "mongrel"
863
+ puts "** Starting Mongrel on #{opts[:host]}:#{opts[:port]}"
864
+ [Rack::Handler::Mongrel, {:Port => opts[:port], :Host => opts[:host]}]
865
+ when "webrick"
866
+ [Rack::Handler::WEBrick, {:Port => opts[:port], :BindAddress => opts[:host]}]
867
+ end
868
+
869
+ Sheila.create opts
870
+ rapp = Sheila
871
+ rapp = Rack::Lint.new rapp
872
+ rapp = Camping::Server::XSendfile.new rapp
873
+ rapp = Rack::ShowExceptions.new rapp
874
+ handler.run rapp, conf
875
+
876
+ end