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