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.
Files changed (65) hide show
  1. data/.document +5 -0
  2. data/Gemfile +14 -0
  3. data/Gemfile.lock +24 -0
  4. data/LICENSE +674 -0
  5. data/LICENSE.txt +20 -0
  6. data/README.rdoc +19 -0
  7. data/README.txt +143 -0
  8. data/Rakefile +55 -0
  9. data/VERSION +1 -0
  10. data/bin/ditz-str +189 -0
  11. data/bugs/issue-02615b8c3dd0382c92f350ce2158ecfe94d11ef8.yaml +22 -0
  12. data/bugs/issue-06a3bbf35a60c4da2d8ea0fdc86164263126d6b2.yaml +22 -0
  13. data/bugs/issue-0c00c1d7fdffaad304e62d79d9b3d5e92547055b.yaml +38 -0
  14. data/bugs/issue-20dad4b4533d6d76d496fe5970098f1eb8efd561.yaml +26 -0
  15. data/bugs/issue-360ae6529dbc66358fde6b532cbea79ece37a670.yaml +22 -0
  16. data/bugs/issue-5177d61bf3c2783f71ef63e6e2c5e720247ef699.yaml +18 -0
  17. data/bugs/issue-695b564c210da1965a2bb38eef782178aead6952.yaml +26 -0
  18. data/bugs/issue-7d0ce6429a9fb5fa09ce3376a8921a5ecb7ecfe5.yaml +34 -0
  19. data/bugs/issue-a04462fa22ab6e1b02cfdd052d1f6c6f491f08f5.yaml +22 -0
  20. data/bugs/issue-bca54ca5107eabc3b281701041cc36ea0641cbdd.yaml +26 -0
  21. data/bugs/issue-d0c7d04b014d705c5fd865e4d487b5e5b6983c33.yaml +26 -0
  22. data/bugs/issue-f94b879842aa0274aa74fc2833252d4a06ec65cc.yaml +22 -0
  23. data/bugs/project.yaml +18 -0
  24. data/data/ditz-str/blue-check.png +0 -0
  25. data/data/ditz-str/close.rhtml +39 -0
  26. data/data/ditz-str/component.rhtml +38 -0
  27. data/data/ditz-str/dropdown.css +11 -0
  28. data/data/ditz-str/dropdown.js +58 -0
  29. data/data/ditz-str/edit_issue.rhtml +53 -0
  30. data/data/ditz-str/green-bar.png +0 -0
  31. data/data/ditz-str/green-check.png +0 -0
  32. data/data/ditz-str/header.gif +0 -0
  33. data/data/ditz-str/header_over.gif +0 -0
  34. data/data/ditz-str/index.rhtml +148 -0
  35. data/data/ditz-str/issue.rhtml +152 -0
  36. data/data/ditz-str/issue_table.rhtml +28 -0
  37. data/data/ditz-str/new_component.rhtml +28 -0
  38. data/data/ditz-str/new_issue.rhtml +57 -0
  39. data/data/ditz-str/new_release.rhtml +29 -0
  40. data/data/ditz-str/plugins/git-sync.rb +83 -0
  41. data/data/ditz-str/plugins/git.rb +153 -0
  42. data/data/ditz-str/plugins/issue-claiming.rb +174 -0
  43. data/data/ditz-str/red-check.png +0 -0
  44. data/data/ditz-str/release.rhtml +111 -0
  45. data/data/ditz-str/style.css +236 -0
  46. data/data/ditz-str/unassigned.rhtml +37 -0
  47. data/data/ditz-str/yellow-bar.png +0 -0
  48. data/ditz-str.gemspec +121 -0
  49. data/lib/ditzstr/brick.rb +251 -0
  50. data/lib/ditzstr/file-storage.rb +54 -0
  51. data/lib/ditzstr/hook.rb +67 -0
  52. data/lib/ditzstr/html.rb +104 -0
  53. data/lib/ditzstr/lowline.rb +201 -0
  54. data/lib/ditzstr/model-objects.rb +346 -0
  55. data/lib/ditzstr/model.rb +265 -0
  56. data/lib/ditzstr/operator.rb +593 -0
  57. data/lib/ditzstr/trollop.rb +614 -0
  58. data/lib/ditzstr/util.rb +61 -0
  59. data/lib/ditzstr/view.rb +16 -0
  60. data/lib/ditzstr/views.rb +157 -0
  61. data/lib/ditzstr.rb +69 -0
  62. data/man/ditz.1 +38 -0
  63. data/test/helper.rb +18 -0
  64. data/test/test_ditz-str.rb +7 -0
  65. metadata +219 -0
@@ -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
@@ -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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;") 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
+ ("&nbsp;" * done) +
91
+ "</span><span class='progress-meter-undone'>" +
92
+ ("&nbsp;" * 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