ditz-str 0.0.1

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