ditz 0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,29 @@
1
+ <table>
2
+ <% issues.sort_by { |i| i.sort_order }.each do |i| %>
3
+ <tr>
4
+ <td class="issuestatus_<%= i.status %>">
5
+ <%= i.status_string %><% if i.closed? %>: <%= i.disposition_string %><% end %>
6
+ </td>
7
+ <td class="issuename">
8
+ <%= link_to i, i.title %>
9
+ <%= i.bug? ? '(bug)' : '' %>
10
+ </td>
11
+ <% if show_release %>
12
+ <td class="issuerelease">
13
+ <% if i.release %>
14
+ <% r = project.release_for i.release %>
15
+ in <%= link_to r, "release #{i.release}" %>
16
+ (<%= r.status %>)
17
+ <% else %>
18
+ <% end %>
19
+ </td>
20
+ <% end %>
21
+ <% if show_component %>
22
+ <td class="issuecomponent">
23
+ component <%= link_to project.component_for(i.component), i.component %>
24
+ </td>
25
+ <% end %>
26
+ </tr>
27
+ <% end %>
28
+ </table>
29
+
@@ -0,0 +1,150 @@
1
+ require 'util'
2
+
3
+ class Numeric
4
+ def to_pretty_s
5
+ if self < 20
6
+ %w(no one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen)[self]
7
+ else
8
+ to_s
9
+ end
10
+ end
11
+ end
12
+
13
+ class String
14
+ def ucfirst; self[0..0].upcase + self[1..-1] end
15
+ def dcfirst; self[0..0].downcase + self[1..-1] end
16
+ def blank?; self =~ /\A\s*\z/ end
17
+ def underline; self + "\n" + ("-" * self.length) end
18
+ def multiline prefix=""; blank? ? "" : "\n" + self.gsub(/^/, prefix) end
19
+ def pluralize n; n.to_pretty_s + " " + (n == 1 ? self : self + "s") end # oh yeah
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 ask q, opts={}
56
+ default_s = case opts[:default]
57
+ when nil; nil
58
+ when ""; " (enter for none)"
59
+ else; " (enter for #{opts[:default].inspect})"
60
+ end
61
+
62
+ tail = case q
63
+ when /[:?]$/; " "
64
+ when /[:?]\s+$/; ""
65
+ else; ": "
66
+ end
67
+
68
+ while true
69
+ print [q, default_s, tail].compact.join
70
+ ans = gets.strip
71
+ if opts[:default]
72
+ ans = opts[:default] if ans.blank?
73
+ else
74
+ next if ans.blank? && !opts[:empty_ok]
75
+ end
76
+ break ans unless (opts[:restrict] && ans !~ opts[:restrict])
77
+ end
78
+ end
79
+
80
+ def ask_multiline q
81
+ puts "#{q} (ctrl-d or . by itself to stop):"
82
+ ans = ""
83
+ while true
84
+ print "> "
85
+ line = gets
86
+ break if line =~ /^\.$/ || line.nil?
87
+ ans << line.strip + "\n"
88
+ end
89
+ ans.sub(/\n+$/, "")
90
+ end
91
+
92
+ def ask_yon q
93
+ while true
94
+ print "#{q} (y/n): "
95
+ a = gets.strip
96
+ break a if a =~ /^[yn]$/i
97
+ end =~ /y/i
98
+ end
99
+
100
+ def ask_for_many plural_name, name=nil
101
+ name ||= plural_name.gsub(/s$/, "")
102
+ stuff = []
103
+
104
+ while true
105
+ puts
106
+ puts "Current #{plural_name}:"
107
+ if stuff.empty?
108
+ puts "None!"
109
+ else
110
+ stuff.each_with_index { |c, i| puts " #{i + 1}) #{c}" }
111
+ end
112
+ puts
113
+ ans = ask "(A)dd #{name}, (r)emove #{name}, or (d)one"
114
+ case ans
115
+ when "a", "A"
116
+ ans = ask "#{name.ucfirst} name", ""
117
+ stuff << ans unless ans =~ /^\s*$/
118
+ when "r", "R"
119
+ ans = ask "Remove which component? (1--#{stuff.size})"
120
+ stuff.delete_at(ans.to_i - 1) if ans
121
+ when "d", "D"
122
+ break
123
+ end
124
+ end
125
+ stuff
126
+ end
127
+
128
+ def ask_for_selection stuff, name, to_string=:to_s
129
+ puts "Choose a #{name}:"
130
+ stuff.each_with_index do |c, i|
131
+ pretty = case to_string
132
+ when Symbol
133
+ c.send to_string
134
+ when Proc
135
+ to_string.call c
136
+ else
137
+ raise ArgumentError, "unknown to_string argument type; expecting Proc or Symbol"
138
+ end
139
+ puts " #{i + 1}) #{pretty}"
140
+ end
141
+
142
+ j = while true
143
+ i = ask "#{name.ucfirst} (1--#{stuff.size})"
144
+ break i.to_i if i && (1 .. stuff.size).member?(i.to_i)
145
+ end
146
+
147
+ stuff[j - 1]
148
+ end
149
+ end
150
+
@@ -0,0 +1,265 @@
1
+ require 'model'
2
+
3
+ module Ditz
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, :default_generator => lambda { File.basename(Dir.pwd) }
40
+ field :version, :default => Ditz::VERSION, :ask => false
41
+ field :issues, :multi => true, :ask => false
42
+ field :components, :multi => true, :generator => :get_components
43
+ field :releases, :multi => true, :ask => false
44
+
45
+ def get_components
46
+ puts <<EOS
47
+ Issues can be tracked across the project as a whole, or the project can be
48
+ split into components, and issues tracked separately for each component.
49
+ EOS
50
+ use_components = ask_yon "Track issues separately for different components?"
51
+ comp_names = use_components ? ask_for_many("components") : []
52
+
53
+ ([name] + comp_names).uniq.map { |n| Component.create_interactively :with => { :name => n } }
54
+ end
55
+
56
+ def issue_for issue_name
57
+ issues.find { |i| i.name == issue_name } or
58
+ raise Error, "has no issue with name #{issue_name.inspect}"
59
+ end
60
+
61
+ def component_for component_name
62
+ components.find { |i| i.name == component_name } or
63
+ raise Error, "has no component with name #{component_name.inspect}"
64
+ end
65
+
66
+ def release_for release_name
67
+ releases.find { |i| i.name == release_name } or
68
+ raise Error, "has no release with name #{release_name.inspect}"
69
+ end
70
+
71
+ def issues_for_release release
72
+ issues.select { |i| i.release == release.name }
73
+ end
74
+
75
+ def issues_for_component component
76
+ issues.select { |i| i.component == component.name }
77
+ end
78
+
79
+ def unassigned_issues
80
+ issues.select { |i| i.release.nil? }
81
+ end
82
+
83
+ def assign_issue_names!
84
+ prefixes = components.map { |c| [c.name, c.name.gsub(/^\s+/, "-").downcase] }.to_h
85
+ ids = components.map { |c| [c.name, 0] }.to_h
86
+ issues.each do |i|
87
+ i.name = "#{prefixes[i.component]}-#{ids[i.component] += 1}"
88
+ end
89
+ end
90
+
91
+ def validate!
92
+ if(dup = components.map { |c| c.name }.first_duplicate)
93
+ raise Error, "more than one component named #{dup.inspect}"
94
+ elsif(dup = releases.map { |r| r.name }.first_duplicate)
95
+ raise Error, "more than one release named #{dup.inspect}"
96
+ end
97
+ end
98
+ end
99
+
100
+ class Issue < ModelObject
101
+ class Error < StandardError; end
102
+
103
+ field :title
104
+ field :desc, :prompt => "Description", :multiline => true
105
+ field :type, :generator => :get_type
106
+ field :component, :generator => :get_component
107
+ field :release, :generator => :get_release
108
+ field :reporter, :prompt => "Issue creator", :default_generator => lambda { |config, proj| config.user }
109
+ field :status, :ask => false, :default => :unstarted
110
+ field :disposition, :ask => false
111
+ field :creation_time, :ask => false, :generator => lambda { Time.now }
112
+ field :references, :ask => false, :multi => true
113
+ field :id, :ask => false, :generator => :make_id
114
+ changes_are_logged
115
+
116
+ attr_accessor :name
117
+
118
+ STATUS_SORT_ORDER = { :unstarted => 2, :paused => 1, :in_progress => 0, :closed => 3 }
119
+ STATUS_WIDGET = { :unstarted => "_", :in_progress => ">", :paused => "=", :closed => "x" }
120
+ DISPOSITIONS = [ :fixed, :wontfix, :reorg ]
121
+ TYPES = [ :bugfix, :feature ]
122
+ STATUSES = STATUS_WIDGET.keys
123
+
124
+ STATUS_STRINGS = { :in_progress => "in progress", :wontfix => "won't fix" }
125
+ DISPOSITION_STRINGS = { :wontfix => "won't fix", :reorg => "reorganized" }
126
+
127
+ def before_serialize project
128
+ self.desc = project.issues.inject(desc) do |s, i|
129
+ s.gsub(/\b#{i.name}\b/, "{issue #{i.id}}")
130
+ end
131
+ end
132
+
133
+ def interpolated_desc issues
134
+ issues.inject(desc) do |s, i|
135
+ s.gsub(/\{issue #{i.id}\}/, block_given? ? yield(i) : i.name)
136
+ end.gsub(/\{issue \w+\}/, "[unknown issue]")
137
+ end
138
+
139
+ ## make a unique id
140
+ def make_id config, project
141
+ SHA1.hexdigest [Time.now, rand, creation_time, reporter, title, desc].join("\n")
142
+ end
143
+
144
+ def sort_order; [STATUS_SORT_ORDER[@status], creation_time] end
145
+ def status_widget; STATUS_WIDGET[@status] end
146
+
147
+ def status_string; STATUS_STRINGS[status] || status.to_s end
148
+ def disposition_string; DISPOSITION_STRINGS[disposition] || disposition.to_s end
149
+
150
+ def closed?; status == :closed end
151
+ def open?; !closed? end
152
+ def in_progress?; status == :in_progress end
153
+ def bug?; type == :bugfix end
154
+ def feature?; type == :feature end
155
+
156
+ def start_work who, comment; change_status :in_progress, who, comment end
157
+ def stop_work who, comment
158
+ raise Error, "unstarted" unless self.status == :in_progress
159
+ change_status :paused, who, comment
160
+ end
161
+
162
+ def close disp, who, comment
163
+ raise Error, "unknown disposition #{disp}" unless DISPOSITIONS.member? disp
164
+ log "closed issue with disposition #{disp}", who, comment
165
+ self.status = :closed
166
+ self.disposition = disp
167
+ end
168
+
169
+ def change_status to, who, comment
170
+ raise Error, "unknown status #{to}" unless STATUSES.member? to
171
+ raise Error, "already marked as #{to}" if status == to
172
+ log "changed status from #{@status} to #{to}", who, comment
173
+ self.status = to
174
+ end
175
+ private :change_status
176
+
177
+ def change hash, who, comment
178
+ what = []
179
+ if title != hash[:title]
180
+ what << "changed title"
181
+ self.title = hash[:title]
182
+ end
183
+
184
+ if desc != hash[:description]
185
+ what << "changed description"
186
+ self.desc = hash[:description]
187
+ end
188
+
189
+ if reporter != hash[:reporter]
190
+ what << "changed reporter"
191
+ self.reporter = hash[:reporter]
192
+ end
193
+
194
+ unless what.empty?
195
+ log what.join(", "), who, comment
196
+ true
197
+ end
198
+ end
199
+
200
+ def assign_to_release release, who, comment
201
+ log "assigned to release #{release.name} from #{self.release || 'unassigned'}", who, comment
202
+ self.release = release.name
203
+ end
204
+
205
+ def unassign who, comment
206
+ raise Error, "not assigned to a release" unless release
207
+ log "unassigned from release #{release}", who, comment
208
+ self.release = nil
209
+ end
210
+
211
+ def get_type config, project
212
+ ask "Is this a (b)ugfix or a (f)eature?", :restrict => /^[bf]$/
213
+ type == "b" ? :bugfix : :feature
214
+ end
215
+
216
+ def get_component config, project
217
+ if project.components.size == 1
218
+ project.components.first
219
+ else
220
+ ask_for_selection project.components, "component", :name
221
+ end.name
222
+ end
223
+
224
+ def get_release config, project
225
+ releases = project.releases.select { |r| r.unreleased? }
226
+ if !releases.empty? && ask_yon("Assign to a release now?")
227
+ if releases.size == 1
228
+ r = releases.first
229
+ puts "Assigning to release #{r.name}."
230
+ r
231
+ else
232
+ ask_for_selection releases, "release", :name
233
+ end.name
234
+ end
235
+ end
236
+
237
+ def get_reporter config, project
238
+ reporter = ask "Creator", :default => config.user
239
+ end
240
+ end
241
+
242
+ class Config < ModelObject
243
+ field :name, :prompt => "Your name", :default_generator => :get_default_name
244
+ field :email, :prompt => "Your email address", :default_generator => :get_default_email
245
+
246
+ def user; "#{name} <#{email}>" end
247
+
248
+ def get_default_name
249
+ require 'etc'
250
+
251
+ name = Etc.getpwnam(ENV["USER"]).gecos.split(/,/).first
252
+ end
253
+
254
+ def get_default_email
255
+ require 'socket'
256
+ email = ENV["USER"] + "@" +
257
+ begin
258
+ Socket.gethostbyname(Socket.gethostname).first
259
+ rescue SocketError
260
+ Socket.gethostname
261
+ end
262
+ end
263
+ end
264
+
265
+ end
@@ -0,0 +1,137 @@
1
+ require 'yaml'
2
+ require "lowline"; include Lowline
3
+ require "util"
4
+ require 'sha1'
5
+
6
+ module Ditz
7
+
8
+ class ModelObject
9
+ class ModelError < StandardError; end
10
+
11
+ ## yamlability
12
+ def self.yaml_domain; "ditz.rubyforge.org,2008-03-06" end
13
+ def self.yaml_other_thing; name.split('::').last.dcfirst end
14
+ def to_yaml_type; "!#{self.class.yaml_domain}/#{self.class.yaml_other_thing}" end
15
+ def to_yaml_properties; self.class.fields.map { |f| "@#{f.to_s}" } end
16
+ def self.inherited subclass
17
+ YAML.add_domain_type(yaml_domain, subclass.yaml_other_thing) do |type, val|
18
+ YAML.object_maker(subclass, val)
19
+ end
20
+ end
21
+ def before_serialize(*a); end
22
+ def after_deserialize(*a); end
23
+
24
+ def self.field name, opts={}
25
+ @fields ||= [] # can't use a hash because want to preserve field order when serialized
26
+ raise ModelError, "field with name #{name} already defined" if @fields.any? { |k, v| k == name }
27
+ @fields << [name, opts]
28
+
29
+ attr_reader name
30
+ if opts[:multi]
31
+ single_name = name.to_s.sub(/s$/, "") # oh yeah
32
+ define_method "add_#{single_name}" do |obj|
33
+ array = self.instance_variable_get("@#{name}")
34
+ raise ModelError, "already has a #{single_name} with name #{obj.name.inspect}" if obj.respond_to?(:name) && array.any? { |o| o.name == obj.name }
35
+ changed!
36
+ array << obj
37
+ end
38
+
39
+ define_method "drop_#{single_name}" do |obj|
40
+ return unless self.instance_variable_get("@#{name}").delete obj
41
+ changed!
42
+ obj
43
+ end
44
+ end
45
+ define_method "#{name}=" do |o|
46
+ changed!
47
+ instance_variable_set "@#{name}", o
48
+ end
49
+ end
50
+
51
+ def self.fields; @fields.map { |name, opts| name } end
52
+
53
+ def self.changes_are_logged
54
+ define_method(:changes_are_logged?) { true }
55
+ field :log_events, :multi => true, :ask => false
56
+ end
57
+
58
+ def self.from fn
59
+ returning YAML::load_file(fn) do |o|
60
+ raise ModelError, "error loading from yaml file #{fn.inspect}: expected a #{self}, got a #{o.class}" unless o.class == self
61
+ end
62
+ end
63
+
64
+ ## depth-first search on all reachable ModelObjects. fuck yeah.
65
+ def each_modelobject
66
+ seen = {}
67
+ to_see = [self]
68
+ until to_see.empty?
69
+ cur = to_see.pop
70
+ seen[cur] = true
71
+ yield cur
72
+ cur.class.fields.each do |f|
73
+ val = cur.send(f)
74
+ next if seen[val]
75
+ if val.is_a?(ModelObject)
76
+ to_see.push val
77
+ elsif val.is_a?(Array)
78
+ to_see += val.select { |v| v.is_a?(ModelObject) }
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ def save! fn
85
+ Ditz::debug "saving configuration to #{fn}"
86
+ FileUtils.mv fn, "#{fn}~", :force => true rescue nil
87
+ File.open(fn, "w") { |f| f.puts to_yaml }
88
+ end
89
+
90
+ def log what, who, comment
91
+ add_log_event([Time.now, who, what, comment])
92
+ self
93
+ end
94
+
95
+ def initialize
96
+ @changed = false
97
+ @log_events = []
98
+ end
99
+
100
+ def changed?; @changed end
101
+ def changed!; @changed = true end
102
+
103
+ def self.create_interactively opts={}
104
+ o = self.new
105
+ args = opts[:args] || []
106
+ @fields.each do |name, field_opts|
107
+ val = if opts[:with] && opts[:with][name]
108
+ opts[:with][name]
109
+ elsif field_opts[:generator].is_a? Proc
110
+ field_opts[:generator].call(*args)
111
+ elsif field_opts[:generator]
112
+ o.send field_opts[:generator], *args
113
+ elsif field_opts[:ask] == false # nil counts as true here
114
+ field_opts[:default] || (field_opts[:multi] ? [] : nil)
115
+ else
116
+ q = field_opts[:prompt] || name.to_s.ucfirst
117
+ if field_opts[:multiline]
118
+ ask_multiline q
119
+ else
120
+ default = if field_opts[:default_generator].is_a? Proc
121
+ field_opts[:default_generator].call(*args)
122
+ elsif field_opts[:default_generator]
123
+ o.send field_opts[:default_generator], *args
124
+ elsif field_opts[:default]
125
+ field_opts[:default]
126
+ end
127
+
128
+ ask q, :default => default
129
+ end
130
+ end
131
+ o.send("#{name}=", val)
132
+ end
133
+ o
134
+ end
135
+ end
136
+
137
+ end