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.
- data/Changelog +1 -1
- data/Manifest.txt +0 -1
- data/Rakefile +3 -3
- data/ReleaseNotes +5 -0
- data/bin/coditz.rb +276 -0
- data/contrib/bookmarklet.js +1 -0
- data/lib/ditz.rb +1 -1
- data/lib/ditz/plugins/icalendar.rb +64 -0
- data/lib/ditz/plugins/issue-timetracker.rb +52 -0
- data/lib/ditz/plugins/mercurial.rb +48 -0
- data/lib/ditz/plugins/sha-names.rb +35 -0
- data/man/man1/ditz.1 +1 -1
- data/sheila/sheila.rb +876 -0
- metadata +43 -7
@@ -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
|
data/man/man1/ditz.1
CHANGED
data/sheila/sheila.rb
ADDED
@@ -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(" " * done) }
|
328
|
+
span.progress_meter_undone { text(" " * 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(/_/, " ") }
|
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 <bob@bobson.com>\"" }
|
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 <bob@bobson.com>\"" }
|
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(/ +#=\>.+$/, '<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
|