ditz-str 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.document +5 -0
- data/Gemfile +14 -0
- data/Gemfile.lock +24 -0
- data/LICENSE +674 -0
- data/LICENSE.txt +20 -0
- data/README.rdoc +19 -0
- data/README.txt +143 -0
- data/Rakefile +55 -0
- data/VERSION +1 -0
- data/bin/ditz-str +189 -0
- data/bugs/issue-02615b8c3dd0382c92f350ce2158ecfe94d11ef8.yaml +22 -0
- data/bugs/issue-06a3bbf35a60c4da2d8ea0fdc86164263126d6b2.yaml +22 -0
- data/bugs/issue-0c00c1d7fdffaad304e62d79d9b3d5e92547055b.yaml +38 -0
- data/bugs/issue-20dad4b4533d6d76d496fe5970098f1eb8efd561.yaml +26 -0
- data/bugs/issue-360ae6529dbc66358fde6b532cbea79ece37a670.yaml +22 -0
- data/bugs/issue-5177d61bf3c2783f71ef63e6e2c5e720247ef699.yaml +18 -0
- data/bugs/issue-695b564c210da1965a2bb38eef782178aead6952.yaml +26 -0
- data/bugs/issue-7d0ce6429a9fb5fa09ce3376a8921a5ecb7ecfe5.yaml +34 -0
- data/bugs/issue-a04462fa22ab6e1b02cfdd052d1f6c6f491f08f5.yaml +22 -0
- data/bugs/issue-bca54ca5107eabc3b281701041cc36ea0641cbdd.yaml +26 -0
- data/bugs/issue-d0c7d04b014d705c5fd865e4d487b5e5b6983c33.yaml +26 -0
- data/bugs/issue-f94b879842aa0274aa74fc2833252d4a06ec65cc.yaml +22 -0
- data/bugs/project.yaml +18 -0
- data/data/ditz-str/blue-check.png +0 -0
- data/data/ditz-str/close.rhtml +39 -0
- data/data/ditz-str/component.rhtml +38 -0
- data/data/ditz-str/dropdown.css +11 -0
- data/data/ditz-str/dropdown.js +58 -0
- data/data/ditz-str/edit_issue.rhtml +53 -0
- data/data/ditz-str/green-bar.png +0 -0
- data/data/ditz-str/green-check.png +0 -0
- data/data/ditz-str/header.gif +0 -0
- data/data/ditz-str/header_over.gif +0 -0
- data/data/ditz-str/index.rhtml +148 -0
- data/data/ditz-str/issue.rhtml +152 -0
- data/data/ditz-str/issue_table.rhtml +28 -0
- data/data/ditz-str/new_component.rhtml +28 -0
- data/data/ditz-str/new_issue.rhtml +57 -0
- data/data/ditz-str/new_release.rhtml +29 -0
- data/data/ditz-str/plugins/git-sync.rb +83 -0
- data/data/ditz-str/plugins/git.rb +153 -0
- data/data/ditz-str/plugins/issue-claiming.rb +174 -0
- data/data/ditz-str/red-check.png +0 -0
- data/data/ditz-str/release.rhtml +111 -0
- data/data/ditz-str/style.css +236 -0
- data/data/ditz-str/unassigned.rhtml +37 -0
- data/data/ditz-str/yellow-bar.png +0 -0
- data/ditz-str.gemspec +121 -0
- data/lib/ditzstr/brick.rb +251 -0
- data/lib/ditzstr/file-storage.rb +54 -0
- data/lib/ditzstr/hook.rb +67 -0
- data/lib/ditzstr/html.rb +104 -0
- data/lib/ditzstr/lowline.rb +201 -0
- data/lib/ditzstr/model-objects.rb +346 -0
- data/lib/ditzstr/model.rb +265 -0
- data/lib/ditzstr/operator.rb +593 -0
- data/lib/ditzstr/trollop.rb +614 -0
- data/lib/ditzstr/util.rb +61 -0
- data/lib/ditzstr/view.rb +16 -0
- data/lib/ditzstr/views.rb +157 -0
- data/lib/ditzstr.rb +69 -0
- data/man/ditz.1 +38 -0
- data/test/helper.rb +18 -0
- data/test/test_ditz-str.rb +7 -0
- metadata +219 -0
data/lib/ditzstr/hook.rb
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
module DitzStr
|
2
|
+
class HookManager
|
3
|
+
def initialize
|
4
|
+
@descs = {}
|
5
|
+
@blocks = {}
|
6
|
+
end
|
7
|
+
|
8
|
+
@@instance = nil
|
9
|
+
def self.method_missing m, *a, &b
|
10
|
+
@@instance ||= self.new
|
11
|
+
@@instance.send m, *a, &b
|
12
|
+
end
|
13
|
+
|
14
|
+
def register name, desc
|
15
|
+
@descs[name] = desc
|
16
|
+
@blocks[name] = []
|
17
|
+
end
|
18
|
+
|
19
|
+
def on *names, &block
|
20
|
+
names.each do |name|
|
21
|
+
raise "unregistered hook #{name.inspect}" unless @descs[name]
|
22
|
+
@blocks[name] << block
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def run name, *args
|
27
|
+
raise "unregistered hook #{name.inspect}" unless @descs[name]
|
28
|
+
blocks = hooks_for name
|
29
|
+
return false if blocks.empty?
|
30
|
+
blocks.each { |block| block[*args] }
|
31
|
+
true
|
32
|
+
end
|
33
|
+
|
34
|
+
def print_hooks f=$stdout
|
35
|
+
puts <<EOS
|
36
|
+
Ditz has #{@descs.size} registered hooks:
|
37
|
+
|
38
|
+
EOS
|
39
|
+
|
40
|
+
@descs.map{ |k,v| [k.to_s,v] }.sort.each do |name, desc|
|
41
|
+
f.puts <<EOS
|
42
|
+
#{name}
|
43
|
+
#{"-" * name.length}
|
44
|
+
#{desc}
|
45
|
+
EOS
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def enabled? name; !hooks_for(name).empty? end
|
50
|
+
|
51
|
+
def hooks_for name
|
52
|
+
if @blocks[name].nil? || @blocks[name].empty?
|
53
|
+
dirs = [DitzStr::home_dir, DitzStr::find_dir_containing(".ditz")].compact.map do |d|
|
54
|
+
File.join d, ".ditz", "hooks"
|
55
|
+
end
|
56
|
+
DitzStr::debug "looking for hooks in #{dirs.join(" and ")}"
|
57
|
+
files = dirs.map { |d| Dir[File.join(d, "*.rb")] }.flatten
|
58
|
+
files.each do |fn|
|
59
|
+
DitzStr::debug "loading hook file #{fn}"
|
60
|
+
require File.expand_path(fn)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
@blocks[name] || []
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
data/lib/ditzstr/html.rb
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
require 'erb'
|
2
|
+
|
3
|
+
module DitzStr
|
4
|
+
|
5
|
+
## pass through any variables needed for template generation, and add a bunch
|
6
|
+
## of HTML formatting utility methods.
|
7
|
+
class ErbHtml
|
8
|
+
def initialize template_dir, links, binding={}
|
9
|
+
@template_dir = template_dir
|
10
|
+
@links = links
|
11
|
+
@binding = binding
|
12
|
+
end
|
13
|
+
|
14
|
+
## return an ErbHtml object that has the current binding plus extra_binding merged in
|
15
|
+
def clone_for_binding extra_binding={}
|
16
|
+
extra_binding.empty? ? self : ErbHtml.new(@template_dir, @links, @binding.merge(extra_binding))
|
17
|
+
end
|
18
|
+
|
19
|
+
def render_template template_name, extra_binding={}
|
20
|
+
if extra_binding.empty?
|
21
|
+
@@erbs ||= {}
|
22
|
+
@@erbs[template_name] ||= ERB.new IO.read(File.join(@template_dir, "#{template_name}.rhtml"))
|
23
|
+
@@erbs[template_name].result binding
|
24
|
+
else
|
25
|
+
clone_for_binding(extra_binding).render_template template_name
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def render_string s, extra_binding={}
|
30
|
+
if extra_binding.empty?
|
31
|
+
ERB.new(s).result binding
|
32
|
+
else
|
33
|
+
clone_for_binding(extra_binding).render_string s
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
###
|
38
|
+
### the following methods are meant to be called from the ERB itself
|
39
|
+
###
|
40
|
+
|
41
|
+
def h o; o.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">") end
|
42
|
+
def t o; o.strftime "%Y-%m-%d %H:%M %Z" end
|
43
|
+
def p o; "<p>" + h(o.to_s).gsub("\n\n", "</p><p>") + "</p>" end
|
44
|
+
def obscured_email e; h e.gsub(/@.*?(>|$)/, "@...\\1") end
|
45
|
+
def link_to o, name
|
46
|
+
dest = @links[o]
|
47
|
+
dest = o if dest.nil? && o.is_a?(String)
|
48
|
+
raise ArgumentError, "no link for #{o.inspect}" unless dest
|
49
|
+
"<a href=\"#{dest}\">#{name}</a>"
|
50
|
+
end
|
51
|
+
|
52
|
+
def issue_status_img_for i, opts={}
|
53
|
+
fn, title = if i.closed?
|
54
|
+
case i.disposition
|
55
|
+
when :fixed; ["green-check.png", "fixed"]
|
56
|
+
when :wontfix; ["red-check.png", "won't fix"]
|
57
|
+
when :reorg; ["blue-check.png", "reorganized"]
|
58
|
+
end
|
59
|
+
elsif i.in_progress?
|
60
|
+
["green-bar.png", "in progress"]
|
61
|
+
elsif i.paused?
|
62
|
+
["yellow-bar.png", "paused"]
|
63
|
+
end
|
64
|
+
|
65
|
+
return "" unless fn
|
66
|
+
|
67
|
+
args = {:src => fn, :alt => title, :title => title}
|
68
|
+
args[:class] = opts[:class] if opts[:class]
|
69
|
+
|
70
|
+
"<img " + args.map { |k, v| "#{k}=#{v.inspect}" }.join(" ") + "/>"
|
71
|
+
end
|
72
|
+
|
73
|
+
def issue_link_for i, opts={}
|
74
|
+
link = link_to i, "#{i.title}"
|
75
|
+
link = "<span class=\"inline-issue-link\">" + link + "</span>" if opts[:inline]
|
76
|
+
link = link + " " + issue_status_img_for(i, :class => "inline-status-image") if opts[:status_image]
|
77
|
+
link
|
78
|
+
end
|
79
|
+
|
80
|
+
def link_issue_names project, s, opts={}
|
81
|
+
project.issues.inject(s) do |s, i|
|
82
|
+
s.gsub(/\b#{i.name}\b/, issue_link_for(i, {:inline => true, :status_image => true}.merge(opts)))
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def progress_meter p, size=50
|
87
|
+
done = (p * size).to_i
|
88
|
+
undone = [size - done, 0].max
|
89
|
+
"<span class='progress-meter'><span class='progress-meter-done'>" +
|
90
|
+
(" " * done) +
|
91
|
+
"</span><span class='progress-meter-undone'>" +
|
92
|
+
(" " * undone) +
|
93
|
+
"</span></span>"
|
94
|
+
end
|
95
|
+
|
96
|
+
## render a nested ERB
|
97
|
+
alias :render :render_template
|
98
|
+
|
99
|
+
def method_missing meth, *a
|
100
|
+
@binding.member?(meth) ? @binding[meth] : super
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
end
|
@@ -0,0 +1,201 @@
|
|
1
|
+
require 'tempfile'
|
2
|
+
require "ditzstr/util"
|
3
|
+
|
4
|
+
class Numeric
|
5
|
+
def to_pretty_s
|
6
|
+
%w(zero one two three four five six seven eight nine ten)[self] || to_s
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
class String
|
11
|
+
def dcfirst; self[0..0].downcase + self[1..-1] end
|
12
|
+
def blank?; self =~ /\A\s*\z/ end
|
13
|
+
def underline; self + "\n" + ("-" * self.length) end
|
14
|
+
def pluralize n, b=true
|
15
|
+
s = (n == 1 ? self : (self == 'bugfix' ? 'bugfixes' : self + "s")) # oh yeah
|
16
|
+
b ? n.to_pretty_s + " " + s : s
|
17
|
+
end
|
18
|
+
def shortened_email; self =~ /<?(\S+?)@.+/ ? $1 : self end
|
19
|
+
def multistrip; strip.gsub(/\n\n+/, "\n\n") end
|
20
|
+
end
|
21
|
+
|
22
|
+
class Array
|
23
|
+
def listify prefix=""
|
24
|
+
return "" if empty?
|
25
|
+
"\n" +
|
26
|
+
map_with_index { |x, i| x.to_s.gsub(/^/, "#{prefix}#{i + 1}. ") }.
|
27
|
+
join("\n")
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class Time
|
32
|
+
def pretty; strftime "%c" end
|
33
|
+
def pretty_date; strftime "%Y-%m-%d" end
|
34
|
+
def ago
|
35
|
+
diff = (Time.now - self).to_i.abs
|
36
|
+
if diff < 60
|
37
|
+
"second".pluralize diff
|
38
|
+
elsif diff < 60*60*3
|
39
|
+
"minute".pluralize(diff / 60)
|
40
|
+
elsif diff < 60*60*24*3
|
41
|
+
"hour".pluralize(diff / (60*60))
|
42
|
+
elsif diff < 60*60*24*7*2
|
43
|
+
"day".pluralize(diff / (60*60*24))
|
44
|
+
elsif diff < 60*60*24*7*8
|
45
|
+
"week".pluralize(diff / (60*60*24*7))
|
46
|
+
elsif diff < 60*60*24*7*52
|
47
|
+
"month".pluralize(diff / (60*60*24*7*4))
|
48
|
+
else
|
49
|
+
"year".pluralize(diff / (60*60*24*7*52))
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
module Lowline
|
55
|
+
def run_editor
|
56
|
+
f = Tempfile.new "ditz"
|
57
|
+
yield f
|
58
|
+
f.close
|
59
|
+
|
60
|
+
editor = ENV["EDITOR"] || "/usr/bin/vi"
|
61
|
+
cmd = "#{editor} #{f.path.inspect}"
|
62
|
+
|
63
|
+
mtime = File.mtime f.path
|
64
|
+
system cmd or raise Error, "cannot execute command: #{cmd.inspect}"
|
65
|
+
|
66
|
+
File.mtime(f.path) == mtime ? nil : f.path
|
67
|
+
end
|
68
|
+
|
69
|
+
def ask q, opts={}
|
70
|
+
default_s = case opts[:default]
|
71
|
+
when nil; nil
|
72
|
+
when ""; " (enter for none)"
|
73
|
+
else; " (enter for #{opts[:default].inspect})"
|
74
|
+
end
|
75
|
+
|
76
|
+
tail = case q
|
77
|
+
when /[:?]$/; " "
|
78
|
+
when /[:?]\s+$/; ""
|
79
|
+
else; ": "
|
80
|
+
end
|
81
|
+
|
82
|
+
while true
|
83
|
+
prompt = [q, default_s, tail].compact.join
|
84
|
+
if DitzStr::has_readline?
|
85
|
+
ans = Readline::readline(prompt)
|
86
|
+
else
|
87
|
+
print prompt
|
88
|
+
ans = STDIN.gets.strip
|
89
|
+
end
|
90
|
+
if opts[:default]
|
91
|
+
ans = opts[:default] if ans.blank?
|
92
|
+
else
|
93
|
+
next if ans.blank? && !opts[:empty_ok]
|
94
|
+
end
|
95
|
+
break ans unless (opts[:restrict] && ans !~ opts[:restrict])
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def ask_via_editor q, default=nil
|
100
|
+
fn = run_editor do |f|
|
101
|
+
f.puts(default || "")
|
102
|
+
f.puts q.gsub(/^/, "## ")
|
103
|
+
f.puts "##"
|
104
|
+
f.puts "## Enter your text above. Lines starting with a '#' will be ignored."
|
105
|
+
end
|
106
|
+
return unless fn
|
107
|
+
IO.read(fn).gsub(/^#.*$/, "").multistrip
|
108
|
+
end
|
109
|
+
|
110
|
+
def ask_multiline q
|
111
|
+
puts "#{q} (ctrl-d, ., or /stop to stop, /edit to edit, /reset to reset):"
|
112
|
+
ans = ""
|
113
|
+
while true
|
114
|
+
if DitzStr::has_readline?
|
115
|
+
line = Readline::readline('> ')
|
116
|
+
else
|
117
|
+
(line = STDIN.gets) && line.strip!
|
118
|
+
end
|
119
|
+
if line
|
120
|
+
if DitzStr::has_readline?
|
121
|
+
Readline::HISTORY.push(line)
|
122
|
+
end
|
123
|
+
case line
|
124
|
+
when /^\.$/, "/stop"
|
125
|
+
break
|
126
|
+
when "/reset"
|
127
|
+
return ask_multiline(q)
|
128
|
+
when "/edit"
|
129
|
+
return ask_via_editor(q, ans)
|
130
|
+
else
|
131
|
+
ans << line + "\n"
|
132
|
+
end
|
133
|
+
else
|
134
|
+
puts
|
135
|
+
break
|
136
|
+
end
|
137
|
+
end
|
138
|
+
ans.multistrip
|
139
|
+
end
|
140
|
+
|
141
|
+
def ask_yon q
|
142
|
+
while true
|
143
|
+
print "#{q} (y/n): "
|
144
|
+
a = STDIN.gets.strip
|
145
|
+
break a if a =~ /^[yn]$/i
|
146
|
+
end =~ /y/i
|
147
|
+
end
|
148
|
+
|
149
|
+
def ask_for_many plural_name, name=nil
|
150
|
+
name ||= plural_name.gsub(/s$/, "")
|
151
|
+
stuff = []
|
152
|
+
|
153
|
+
while true
|
154
|
+
puts
|
155
|
+
puts "Current #{plural_name}:"
|
156
|
+
if stuff.empty?
|
157
|
+
puts "None!"
|
158
|
+
else
|
159
|
+
stuff.each_with_index { |c, i| puts " #{i + 1}) #{c}" }
|
160
|
+
end
|
161
|
+
puts
|
162
|
+
ans = ask "(A)dd #{name}, (r)emove #{name}, or (d)one"
|
163
|
+
case ans
|
164
|
+
when "a", "A"
|
165
|
+
ans = ask "#{name.capitalize} name", ""
|
166
|
+
stuff << ans unless ans =~ /^\s*$/
|
167
|
+
when "r", "R"
|
168
|
+
ans = ask "Remove which #{name}? (1--#{stuff.size})"
|
169
|
+
stuff.delete_at(ans.to_i - 1) if ans
|
170
|
+
when "d", "D"
|
171
|
+
break
|
172
|
+
end
|
173
|
+
end
|
174
|
+
stuff
|
175
|
+
end
|
176
|
+
|
177
|
+
def ask_for_selection stuff, name, to_string=:to_s
|
178
|
+
puts "Choose a #{name}:"
|
179
|
+
stuff.each_with_index do |c, i|
|
180
|
+
pretty = case to_string
|
181
|
+
when block_given? && to_string # heh
|
182
|
+
yield c
|
183
|
+
when Symbol
|
184
|
+
c.send to_string
|
185
|
+
when Proc
|
186
|
+
to_string.call c
|
187
|
+
else
|
188
|
+
raise ArgumentError, "unknown to_string argument type; expecting Proc or Symbol"
|
189
|
+
end
|
190
|
+
puts " #{i + 1}) #{pretty}"
|
191
|
+
end
|
192
|
+
|
193
|
+
j = while true
|
194
|
+
i = ask "#{name.capitalize} (1--#{stuff.size})"
|
195
|
+
break i.to_i if i && (1 .. stuff.size).member?(i.to_i)
|
196
|
+
end
|
197
|
+
|
198
|
+
stuff[j - 1]
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
@@ -0,0 +1,346 @@
|
|
1
|
+
require 'ditzstr/model'
|
2
|
+
|
3
|
+
module DitzStr
|
4
|
+
|
5
|
+
class Component < ModelObject
|
6
|
+
field :name
|
7
|
+
def name_prefix; name.gsub(/\s+/, "-").downcase end
|
8
|
+
end
|
9
|
+
|
10
|
+
class Release < ModelObject
|
11
|
+
class Error < StandardError; end
|
12
|
+
|
13
|
+
field :name
|
14
|
+
field :status, :default => :unreleased, :ask => false
|
15
|
+
field :release_time, :ask => false
|
16
|
+
changes_are_logged
|
17
|
+
|
18
|
+
def released?; self.status == :released end
|
19
|
+
def unreleased?; !released? end
|
20
|
+
|
21
|
+
def issues_from project; project.issues.select { |i| i.release == name } end
|
22
|
+
|
23
|
+
def release! project, who, comment
|
24
|
+
raise Error, "already released" if released?
|
25
|
+
|
26
|
+
issues = issues_from project
|
27
|
+
bad = issues.find { |i| i.open? }
|
28
|
+
raise Error, "open issue #{bad.name} must be reassigned" if bad
|
29
|
+
|
30
|
+
self.release_time = Time.now
|
31
|
+
self.status = :released
|
32
|
+
log "released", who, comment
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
class Project < ModelObject
|
37
|
+
class Error < StandardError; end
|
38
|
+
|
39
|
+
field :name, :prompt => "Project name", :default_generator => lambda { File.basename(Dir.pwd) }
|
40
|
+
field :version, :default => DitzStr::VERSION, :ask => false
|
41
|
+
field :components, :multi => true, :generator => :get_components
|
42
|
+
field :releases, :multi => true, :ask => false
|
43
|
+
|
44
|
+
attr_accessor :pathname
|
45
|
+
|
46
|
+
## issues are not model fields proper, so we build up their interface here.
|
47
|
+
attr_reader :issues
|
48
|
+
def issues= issues
|
49
|
+
@issues = issues
|
50
|
+
@issues.each { |i| i.project = self }
|
51
|
+
assign_issue_names!
|
52
|
+
issues
|
53
|
+
end
|
54
|
+
|
55
|
+
def add_issue issue
|
56
|
+
added_issues << issue
|
57
|
+
issues << issue
|
58
|
+
issue.project = self
|
59
|
+
assign_issue_names!
|
60
|
+
issue
|
61
|
+
end
|
62
|
+
|
63
|
+
def drop_issue issue
|
64
|
+
if issues.delete issue
|
65
|
+
deleted_issues << issue
|
66
|
+
assign_issue_names!
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def added_issues; @added_issues ||= [] end
|
71
|
+
def deleted_issues; @deleted_issues ||= [] end
|
72
|
+
|
73
|
+
def get_components
|
74
|
+
puts <<EOS
|
75
|
+
Issues can be tracked across the project as a whole, or the project can be
|
76
|
+
split into components, and issues tracked separately for each component.
|
77
|
+
EOS
|
78
|
+
use_components = ask_yon "Track issues separately for different components?"
|
79
|
+
comp_names = use_components ? ask_for_many("components") : []
|
80
|
+
|
81
|
+
([name] + comp_names).uniq.map { |n| Component.create_interactively :with => { :name => n } }
|
82
|
+
end
|
83
|
+
|
84
|
+
def issues_for ident
|
85
|
+
by_name = issues.find { |i| i.name == ident }
|
86
|
+
by_name ? [by_name] : issues.select { |i| i.id =~ /^#{Regexp::escape ident}/ }
|
87
|
+
end
|
88
|
+
|
89
|
+
def component_for component_name
|
90
|
+
components.find { |i| i.name == component_name }
|
91
|
+
end
|
92
|
+
|
93
|
+
def release_for release_name
|
94
|
+
releases.find { |i| i.name == release_name }
|
95
|
+
end
|
96
|
+
|
97
|
+
def unreleased_releases; releases.select { |r| r.unreleased? } end
|
98
|
+
|
99
|
+
def issues_for_release release
|
100
|
+
release == :unassigned ? unassigned_issues : issues.select { |i| i.release == release.name }
|
101
|
+
end
|
102
|
+
|
103
|
+
def issues_for_component component
|
104
|
+
issues.select { |i| i.component == component.name }
|
105
|
+
end
|
106
|
+
|
107
|
+
def unassigned_issues
|
108
|
+
issues.select { |i| i.release.nil? }
|
109
|
+
end
|
110
|
+
|
111
|
+
def group_issues these_issues=issues
|
112
|
+
these_issues.group_by { |i| i.type }.sort_by { |(t,g)| Issue::TYPE_ORDER[t] }
|
113
|
+
end
|
114
|
+
|
115
|
+
def assign_issue_names!
|
116
|
+
prefixes = components.map { |c| [c.name, c.name.gsub(/^\s+/, "-").downcase] }.to_h
|
117
|
+
ids = components.map { |c| [c.name, 0] }.to_h
|
118
|
+
issues.sort_by { |i| i.creation_time }.each do |i|
|
119
|
+
i.name = "#{prefixes[i.component]}-#{ids[i.component] += 1}"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def validate!
|
124
|
+
if(dup = components.map { |c| c.name }.first_duplicate)
|
125
|
+
raise Error, "more than one component named #{dup.inspect}: #{components.inspect}"
|
126
|
+
elsif(dup = releases.map { |r| r.name }.first_duplicate)
|
127
|
+
raise Error, "more than one release named #{dup.inspect}"
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def self.from *a
|
132
|
+
p = super(*a)
|
133
|
+
p.validate!
|
134
|
+
p
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
class Issue < ModelObject
|
139
|
+
class Error < StandardError; end
|
140
|
+
|
141
|
+
field :title
|
142
|
+
field :desc, :prompt => "Description", :multiline => true
|
143
|
+
field :type, :generator => :get_type
|
144
|
+
field :component, :generator => :get_component
|
145
|
+
field :release, :generator => :get_release
|
146
|
+
field :reporter, :prompt => "Issue creator", :default_generator => lambda { |config, proj| config.user }
|
147
|
+
field :status, :ask => false, :default => :unstarted
|
148
|
+
field :disposition, :ask => false
|
149
|
+
field :creation_time, :ask => false, :generator => lambda { Time.now }
|
150
|
+
field :references, :ask => false, :multi => true
|
151
|
+
field :id, :ask => false, :generator => :make_id
|
152
|
+
changes_are_logged
|
153
|
+
|
154
|
+
attr_accessor :name, :pathname, :project
|
155
|
+
|
156
|
+
## these are the fields we interpolate issue names on
|
157
|
+
INTERPOLATED_FIELDS = [:title, :desc, :log_events]
|
158
|
+
|
159
|
+
STATUS_SORT_ORDER = { :unstarted => 2, :paused => 1, :in_progress => 0, :closed => 3 }
|
160
|
+
STATUS_WIDGET = { :unstarted => "_", :in_progress => ">", :paused => "=", :closed => "x" }
|
161
|
+
DISPOSITIONS = [ :fixed, :wontfix, :reorg ]
|
162
|
+
TYPES = [ :bugfix, :feature, :task ]
|
163
|
+
TYPE_ORDER = { :bugfix => 0, :feature => 1, :task => 2 }
|
164
|
+
TYPE_LETTER = { 'b' => :bugfix, 'f' => :feature, 't' => :task }
|
165
|
+
STATUSES = STATUS_WIDGET.keys
|
166
|
+
|
167
|
+
STATUS_STRINGS = { :in_progress => "in progress", :wontfix => "won't fix" }
|
168
|
+
DISPOSITION_STRINGS = { :wontfix => "won't fix", :reorg => "reorganized" }
|
169
|
+
|
170
|
+
def serialized_form_of field, value
|
171
|
+
return super unless INTERPOLATED_FIELDS.member? field
|
172
|
+
|
173
|
+
if field == :log_events
|
174
|
+
value.map do |time, who, what, comment|
|
175
|
+
comment = @project.issues.inject(comment) do |s, i|
|
176
|
+
s.gsub(/\b#{i.name}\b/, "{issue #{i.id}}")
|
177
|
+
end
|
178
|
+
[time, who, what, comment]
|
179
|
+
end
|
180
|
+
else
|
181
|
+
@project.issues.inject(value) do |s, i|
|
182
|
+
s.gsub(/\b#{i.name}\b/, "{issue #{i.id}}")
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
def deserialized_form_of field, value
|
188
|
+
return super unless INTERPOLATED_FIELDS.member? field
|
189
|
+
|
190
|
+
if field == :log_events
|
191
|
+
value.map do |time, who, what, comment|
|
192
|
+
comment = @project.issues.inject(comment) do |s, i|
|
193
|
+
s.gsub(/\{issue #{i.id}\}/, i.name)
|
194
|
+
end.gsub(/\{issue \w+\}/, "[unknown issue]")
|
195
|
+
[time, who, what, comment]
|
196
|
+
end
|
197
|
+
else
|
198
|
+
@project.issues.inject(value) do |s, i|
|
199
|
+
s.gsub(/\{issue #{i.id}\}/, i.name)
|
200
|
+
end.gsub(/\{issue \w+\}/, "[unknown issue]")
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
## make a unique id
|
205
|
+
def make_id config, project
|
206
|
+
SHA1.hexdigest [Time.now, rand, creation_time, reporter, title, desc].join("\n")
|
207
|
+
end
|
208
|
+
|
209
|
+
def sort_order; [STATUS_SORT_ORDER[status], creation_time] end
|
210
|
+
def status_widget; STATUS_WIDGET[status] end
|
211
|
+
|
212
|
+
def status_string; STATUS_STRINGS[status] || status.to_s end
|
213
|
+
def disposition_string; DISPOSITION_STRINGS[disposition] || disposition.to_s end
|
214
|
+
|
215
|
+
def closed?; status == :closed end
|
216
|
+
def open?; !closed? end
|
217
|
+
def in_progress?; status == :in_progress end
|
218
|
+
def unstarted?; !in_progress? end
|
219
|
+
def bug?; type == :bugfix end
|
220
|
+
def feature?; type == :feature end
|
221
|
+
def unassigned?; release.nil? end
|
222
|
+
def assigned?; !unassigned? end
|
223
|
+
def paused?; status == :paused end
|
224
|
+
|
225
|
+
def start_work who, comment; change_status :in_progress, who, comment end
|
226
|
+
def stop_work who, comment
|
227
|
+
raise Error, "unstarted" unless self.status == :in_progress
|
228
|
+
change_status :paused, who, comment
|
229
|
+
end
|
230
|
+
|
231
|
+
def close disp, who, comment
|
232
|
+
raise Error, "unknown disposition #{disp}" unless DISPOSITIONS.member? disp
|
233
|
+
log "closed with disposition #{disp}", who, comment
|
234
|
+
self.status = :closed
|
235
|
+
self.disposition = disp
|
236
|
+
end
|
237
|
+
|
238
|
+
def change_status to, who, comment
|
239
|
+
raise Error, "unknown status #{to}" unless STATUSES.member? to
|
240
|
+
raise Error, "already marked as #{to}" if status == to
|
241
|
+
log "changed status from #{status} to #{to}", who, comment
|
242
|
+
self.status = to
|
243
|
+
end
|
244
|
+
private :change_status
|
245
|
+
|
246
|
+
def change hash, who, comment, silent
|
247
|
+
what = []
|
248
|
+
if title != hash[:title]
|
249
|
+
what << "title"
|
250
|
+
self.title = hash[:title]
|
251
|
+
end
|
252
|
+
|
253
|
+
if desc != hash[:description]
|
254
|
+
what << "description"
|
255
|
+
self.desc = hash[:description]
|
256
|
+
end
|
257
|
+
|
258
|
+
if reporter != hash[:reporter]
|
259
|
+
what << "reporter"
|
260
|
+
self.reporter = hash[:reporter]
|
261
|
+
end
|
262
|
+
|
263
|
+
unless what.empty? || silent
|
264
|
+
log "edited " + what.join(", "), who, comment
|
265
|
+
true
|
266
|
+
end
|
267
|
+
|
268
|
+
!what.empty?
|
269
|
+
end
|
270
|
+
|
271
|
+
def assign_to_release release, who, comment
|
272
|
+
log "assigned to release #{release.name} from #{self.release || 'unassigned'}", who, comment
|
273
|
+
self.release = release.name
|
274
|
+
end
|
275
|
+
|
276
|
+
def assign_to_component component, who, comment
|
277
|
+
log "assigned to component #{component.name} from #{self.component}", who, comment
|
278
|
+
self.component = component.name
|
279
|
+
end
|
280
|
+
|
281
|
+
def unassign who, comment
|
282
|
+
raise Error, "not assigned to a release" unless release
|
283
|
+
log "unassigned from release #{release}", who, comment
|
284
|
+
self.release = nil
|
285
|
+
end
|
286
|
+
|
287
|
+
def get_type config, project
|
288
|
+
type = ask "Is this a (b)ugfix, a (f)eature, or a (t)ask?", :restrict => /^[bft]$/
|
289
|
+
TYPE_LETTER[type]
|
290
|
+
end
|
291
|
+
|
292
|
+
def get_component config, project
|
293
|
+
if project.components.size == 1
|
294
|
+
project.components.first
|
295
|
+
else
|
296
|
+
ask_for_selection project.components, "component", :name
|
297
|
+
end.name
|
298
|
+
end
|
299
|
+
|
300
|
+
def get_release config, project
|
301
|
+
releases = project.releases.select { |r| r.unreleased? }
|
302
|
+
if !releases.empty? && ask_yon("Assign to a release now?")
|
303
|
+
if releases.size == 1
|
304
|
+
r = releases.first
|
305
|
+
puts "Assigning to release #{r.name}."
|
306
|
+
r
|
307
|
+
else
|
308
|
+
ask_for_selection releases, "release", :name
|
309
|
+
end.name
|
310
|
+
end
|
311
|
+
end
|
312
|
+
|
313
|
+
def get_reporter config, project
|
314
|
+
reporter = ask "Creator", :default => config.user
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
class Config < ModelObject
|
319
|
+
field :name, :prompt => "Your name", :default_generator => :get_default_name
|
320
|
+
field :email, :prompt => "Your email address", :default_generator => :get_default_email
|
321
|
+
field :issue_dir, :prompt => "Directory to store issues state in", :default => "bugs"
|
322
|
+
|
323
|
+
def user; "#{name} <#{email}>" end
|
324
|
+
|
325
|
+
def get_default_name
|
326
|
+
require 'etc'
|
327
|
+
|
328
|
+
name = if ENV["USER"]
|
329
|
+
pwent = Etc.getpwnam ENV["USER"]
|
330
|
+
pwent ? pwent.gecos.split(/,/).first : nil
|
331
|
+
end
|
332
|
+
name || "Ditz User"
|
333
|
+
end
|
334
|
+
|
335
|
+
def get_default_email
|
336
|
+
require 'socket'
|
337
|
+
email = (ENV["USER"] || "") + "@" +
|
338
|
+
begin
|
339
|
+
Socket.gethostbyname(Socket.gethostname).first
|
340
|
+
rescue SocketError
|
341
|
+
Socket.gethostname
|
342
|
+
end
|
343
|
+
end
|
344
|
+
end
|
345
|
+
|
346
|
+
end
|