ursm-ditz 0.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,314 @@
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
+ attr_accessor :pathname
40
+
41
+ field :name, :default_generator => lambda { File.basename(Dir.pwd) }
42
+ field :version, :default => Ditz::VERSION, :ask => false
43
+ field :components, :multi => true, :generator => :get_components
44
+ field :releases, :multi => true, :ask => false
45
+
46
+ ## issues are not model fields proper, so we build up their interface here.
47
+ attr_accessor :issues
48
+ def add_issue issue; added_issues << issue; issues << issue end
49
+ def drop_issue issue; deleted_issues << issue if issues.delete issue end
50
+ def added_issues; @added_issues ||= [] end
51
+ def deleted_issues; @deleted_issues ||= [] end
52
+
53
+ def get_components
54
+ puts <<EOS
55
+ Issues can be tracked across the project as a whole, or the project can be
56
+ split into components, and issues tracked separately for each component.
57
+ EOS
58
+ use_components = ask_yon "Track issues separately for different components?"
59
+ comp_names = use_components ? ask_for_many("components") : []
60
+
61
+ ([name] + comp_names).uniq.map { |n| Component.create_interactively :with => { :name => n } }
62
+ end
63
+
64
+ def issues_for ident
65
+ by_name = issues.find { |i| i.name == ident }
66
+ by_name ? [by_name] : issues.select { |i| i.id =~ /^#{ident}/ }
67
+ end
68
+
69
+ def component_for component_name
70
+ components.find { |i| i.name == component_name }
71
+ end
72
+
73
+ def release_for release_name
74
+ releases.find { |i| i.name == release_name }
75
+ end
76
+
77
+ def unreleased_releases; releases.select { |r| r.unreleased? } end
78
+
79
+ def issues_for_release release
80
+ release == :unassigned ? unassigned_issues : issues.select { |i| i.release == release.name }
81
+ end
82
+
83
+ def issues_for_component component
84
+ issues.select { |i| i.component == component.name }
85
+ end
86
+
87
+ def unassigned_issues
88
+ issues.select { |i| i.release.nil? }
89
+ end
90
+
91
+ def group_issues these_issues=issues
92
+ these_issues.group_by { |i| i.type }.sort_by { |(t,g)| Issue::TYPE_ORDER[t] }
93
+ end
94
+
95
+ def assign_issue_names!
96
+ prefixes = components.map { |c| [c.name, c.name.gsub(/^\s+/, "-").downcase] }.to_h
97
+ ids = components.map { |c| [c.name, 0] }.to_h
98
+ issues.sort_by { |i| i.creation_time }.each do |i|
99
+ i.name = "#{prefixes[i.component]}-#{ids[i.component] += 1}"
100
+ end
101
+ end
102
+
103
+ def validate!
104
+ if(dup = components.map { |c| c.name }.first_duplicate)
105
+ raise Error, "more than one component named #{dup.inspect}: #{components.inspect}"
106
+ elsif(dup = releases.map { |r| r.name }.first_duplicate)
107
+ raise Error, "more than one release named #{dup.inspect}"
108
+ end
109
+ end
110
+ end
111
+
112
+ class Issue < ModelObject
113
+ class Error < StandardError; end
114
+
115
+ field :title
116
+ field :desc, :prompt => "Description", :multiline => true
117
+ field :type, :generator => :get_type
118
+ field :component, :generator => :get_component
119
+ field :release, :generator => :get_release
120
+ field :reporter, :prompt => "Issue creator", :default_generator => lambda { |config, proj| config.user }
121
+ field :status, :ask => false, :default => :unstarted
122
+ field :disposition, :ask => false
123
+ field :creation_time, :ask => false, :generator => lambda { Time.now }
124
+ field :references, :ask => false, :multi => true
125
+ field :id, :ask => false, :generator => :make_id
126
+ changes_are_logged
127
+
128
+ attr_accessor :name, :pathname, :project
129
+
130
+ ## these are the fields we interpolate issue names on
131
+ INTERPOLATED_FIELDS = [:title, :desc, :log_events]
132
+
133
+ STATUS_SORT_ORDER = { :unstarted => 2, :paused => 1, :in_progress => 0, :closed => 3 }
134
+ STATUS_WIDGET = { :unstarted => "_", :in_progress => ">", :paused => "=", :closed => "x" }
135
+ DISPOSITIONS = [ :fixed, :wontfix, :reorg ]
136
+ TYPES = [ :bugfix, :feature, :task ]
137
+ TYPE_ORDER = { :bugfix => 0, :feature => 1, :task => 2 }
138
+ TYPE_LETTER = { 'b' => :bugfix, 'f' => :feature, 't' => :task }
139
+ STATUSES = STATUS_WIDGET.keys
140
+
141
+ STATUS_STRINGS = { :in_progress => "in progress", :wontfix => "won't fix" }
142
+ DISPOSITION_STRINGS = { :wontfix => "won't fix", :reorg => "reorganized" }
143
+
144
+ def serialized_form_of field, value
145
+ return super unless INTERPOLATED_FIELDS.member? field
146
+
147
+ if field == :log_events
148
+ value.map do |time, who, what, comment|
149
+ comment = @project.issues.inject(comment) do |s, i|
150
+ s.gsub(/\b#{i.name}\b/, "{issue #{i.id}}")
151
+ end
152
+ [time, who, what, comment]
153
+ end
154
+ else
155
+ @project.issues.inject(value) do |s, i|
156
+ s.gsub(/\b#{i.name}\b/, "{issue #{i.id}}")
157
+ end
158
+ end
159
+ end
160
+
161
+ def deserialized_form_of field, value
162
+ return super unless INTERPOLATED_FIELDS.member? field
163
+
164
+ if field == :log_events
165
+ value.map do |time, who, what, comment|
166
+ comment = @project.issues.inject(comment) do |s, i|
167
+ s.gsub(/\{issue #{i.id}\}/, i.name)
168
+ end.gsub(/\{issue \w+\}/, "[unknown issue]")
169
+ [time, who, what, comment]
170
+ end
171
+ else
172
+ @project.issues.inject(value) do |s, i|
173
+ s.gsub(/\{issue #{i.id}\}/, i.name)
174
+ end.gsub(/\{issue \w+\}/, "[unknown issue]")
175
+ end
176
+ end
177
+
178
+ ## make a unique id
179
+ def make_id config, project
180
+ SHA1.hexdigest [Time.now, rand, creation_time, reporter, title, desc].join("\n")
181
+ end
182
+
183
+ def sort_order; [STATUS_SORT_ORDER[status], creation_time] end
184
+ def status_widget; STATUS_WIDGET[status] end
185
+
186
+ def status_string; STATUS_STRINGS[status] || status.to_s end
187
+ def disposition_string; DISPOSITION_STRINGS[disposition] || disposition.to_s end
188
+
189
+ def closed?; status == :closed end
190
+ def open?; !closed? end
191
+ def in_progress?; status == :in_progress end
192
+ def unstarted?; !in_progress? end
193
+ def bug?; type == :bugfix end
194
+ def feature?; type == :feature end
195
+ def unassigned?; release.nil? end
196
+ def assigned?; !unassigned? end
197
+
198
+ def start_work who, comment; change_status :in_progress, who, comment end
199
+ def stop_work who, comment
200
+ raise Error, "unstarted" unless self.status == :in_progress
201
+ change_status :paused, who, comment
202
+ end
203
+
204
+ def close disp, who, comment
205
+ raise Error, "unknown disposition #{disp}" unless DISPOSITIONS.member? disp
206
+ log "closed with disposition #{disp}", who, comment
207
+ self.status = :closed
208
+ self.disposition = disp
209
+ end
210
+
211
+ def change_status to, who, comment
212
+ raise Error, "unknown status #{to}" unless STATUSES.member? to
213
+ raise Error, "already marked as #{to}" if status == to
214
+ log "changed status from #{status} to #{to}", who, comment
215
+ self.status = to
216
+ end
217
+ private :change_status
218
+
219
+ def change hash, who, comment
220
+ what = []
221
+ if title != hash[:title]
222
+ what << "changed title"
223
+ self.title = hash[:title]
224
+ end
225
+
226
+ if desc != hash[:description]
227
+ what << "changed description"
228
+ self.desc = hash[:description]
229
+ end
230
+
231
+ if reporter != hash[:reporter]
232
+ what << "changed reporter"
233
+ self.reporter = hash[:reporter]
234
+ end
235
+
236
+ unless what.empty?
237
+ log what.join(", "), who, comment
238
+ true
239
+ end
240
+ end
241
+
242
+ def assign_to_release release, who, comment
243
+ log "assigned to release #{release.name} from #{self.release || 'unassigned'}", who, comment
244
+ self.release = release.name
245
+ end
246
+
247
+ def assign_to_component component, who, comment
248
+ log "assigned to component #{component.name} from #{self.component}", who, comment
249
+ self.component = component.name
250
+ end
251
+
252
+ def unassign who, comment
253
+ raise Error, "not assigned to a release" unless release
254
+ log "unassigned from release #{release}", who, comment
255
+ self.release = nil
256
+ end
257
+
258
+ def get_type config, project
259
+ type = ask "Is this a (b)ugfix, a (f)eature, or a (t)ask?", :restrict => /^[bft]$/
260
+ TYPE_LETTER[type]
261
+ end
262
+
263
+ def get_component config, project
264
+ if project.components.size == 1
265
+ project.components.first
266
+ else
267
+ ask_for_selection project.components, "component", :name
268
+ end.name
269
+ end
270
+
271
+ def get_release config, project
272
+ releases = project.releases.select { |r| r.unreleased? }
273
+ if !releases.empty? && ask_yon("Assign to a release now?")
274
+ if releases.size == 1
275
+ r = releases.first
276
+ puts "Assigning to release #{r.name}."
277
+ r
278
+ else
279
+ ask_for_selection releases, "release", :name
280
+ end.name
281
+ end
282
+ end
283
+
284
+ def get_reporter config, project
285
+ reporter = ask "Creator", :default => config.user
286
+ end
287
+ end
288
+
289
+ class Config < ModelObject
290
+ field :name, :prompt => "Your name", :default_generator => :get_default_name
291
+ field :email, :prompt => "Your email address", :default_generator => :get_default_email
292
+ field :issue_dir, :ask => false, :default => "bugs"
293
+
294
+ def user; "#{name} <#{email}>" end
295
+
296
+ def get_default_name
297
+ require 'etc'
298
+
299
+ name = Etc.getpwnam(ENV["USER"])
300
+ name = name ? name.gecos.split(/,/).first : ""
301
+ end
302
+
303
+ def get_default_email
304
+ require 'socket'
305
+ email = (ENV["USER"] || "") + "@" +
306
+ begin
307
+ Socket.gethostbyname(Socket.gethostname).first
308
+ rescue SocketError
309
+ Socket.gethostname
310
+ end
311
+ end
312
+ end
313
+
314
+ end
data/lib/model.rb ADDED
@@ -0,0 +1,208 @@
1
+ require 'yaml'
2
+ require 'sha1'
3
+ require "lowline"; include Lowline
4
+ require "util"
5
+
6
+ class Time
7
+ alias :old_to_yaml :to_yaml
8
+ def to_yaml(opts = {})
9
+ self.utc.old_to_yaml(opts)
10
+ end
11
+ end
12
+
13
+ module Ditz
14
+
15
+ class ModelObject
16
+ class ModelError < StandardError; end
17
+
18
+ def initialize
19
+ @values = {}
20
+ @serialized_values = {}
21
+ self.class.fields.map { |f, opts| @values[f] = [] if opts[:multi] }
22
+ end
23
+
24
+ ## yamlability
25
+ def self.yaml_domain; "ditz.rubyforge.org,2008-03-06" end
26
+ def self.yaml_other_thing; name.split('::').last.dcfirst end
27
+ def to_yaml_type; "!#{self.class.yaml_domain}/#{self.class.yaml_other_thing}" end
28
+ def self.inherited subclass
29
+ YAML.add_domain_type(yaml_domain, subclass.yaml_other_thing) do |type, val|
30
+ o = subclass.new
31
+ val.each do |k, v|
32
+ m = "__serialized_#{k}="
33
+ if o.respond_to? m
34
+ o.send m, v
35
+ else
36
+ $stderr.puts "warning: unknown field #{k.inspect} in YAML for #{type}; ignoring"
37
+ end
38
+ end
39
+ o.unchanged!
40
+ o
41
+ end
42
+ end
43
+
44
+ ## override these two to model per-field transformations between disk and
45
+ ## memory.
46
+ ##
47
+ ## convert disk form => memory form
48
+ def deserialized_form_of field, value
49
+ @serialized_values[field]
50
+ end
51
+
52
+ ## convert memory form => disk form
53
+ def serialized_form_of field, value
54
+ @values[field]
55
+ end
56
+
57
+ ## add a new field to a model object
58
+ def self.field name, opts={}
59
+ @fields ||= [] # can't use a hash because we need to preserve field order
60
+ raise ModelError, "field with name #{name} already defined" if @fields.any? { |k, v| k == name }
61
+ @fields << [name, opts]
62
+
63
+ if opts[:multi]
64
+ single_name = name.to_s.sub(/s$/, "") # oh yeah
65
+ define_method "add_#{single_name}" do |obj|
66
+ array = send(name)
67
+ raise ModelError, "already has a #{single_name} with name #{obj.name.inspect}" if obj.respond_to?(:name) && array.any? { |o| o.name == obj.name }
68
+ changed!
69
+ @serialized_values.delete name
70
+ array << obj
71
+ end
72
+
73
+ define_method "drop_#{single_name}" do |obj|
74
+ return unless @values[name].delete obj
75
+ @serialized_values.delete name
76
+ changed!
77
+ obj
78
+ end
79
+ end
80
+
81
+ define_method "#{name}=" do |o|
82
+ changed!
83
+ @serialized_values.delete name
84
+ @values[name] = o
85
+ end
86
+
87
+ define_method "__serialized_#{name}=" do |o|
88
+ changed!
89
+ @values.delete name
90
+ @serialized_values[name] = o
91
+ end
92
+
93
+ define_method name do
94
+ return @values[name] if @values.member?(name)
95
+ @values[name] = deserialized_form_of name, @serialized_values[name]
96
+ end
97
+ end
98
+
99
+ def self.field_names; @fields.map { |name, opts| name } end
100
+ class << self
101
+ attr_reader :fields, :values, :serialized_values
102
+ end
103
+
104
+ def self.changes_are_logged
105
+ define_method(:changes_are_logged?) { true }
106
+ field :log_events, :multi => true, :ask => false
107
+ end
108
+
109
+ def self.from fn
110
+ returning YAML::load_file(fn) do |o|
111
+ raise ModelError, "error loading from yaml file #{fn.inspect}: expected a #{self}, got a #{o.class}" unless o.class == self
112
+ o.pathname = fn if o.respond_to? :pathname=
113
+ end
114
+ end
115
+
116
+ def to_s
117
+ "<#{self.class.name}: " + self.class.field_names.map { |f| "#{f}: " + (@values[f].to_s || @serialized_values[f]).inspect }.join(", ") + ">"
118
+ end
119
+
120
+ def inspect; to_s end
121
+
122
+ ## depth-first search on all reachable ModelObjects. fuck yeah.
123
+ def each_modelobject
124
+ seen = {}
125
+ to_see = [self]
126
+ until to_see.empty?
127
+ cur = to_see.pop
128
+ seen[cur] = true
129
+ yield cur
130
+ cur.class.field_names.each do |f|
131
+ val = cur.send(f)
132
+ next if seen[val]
133
+ if val.is_a?(ModelObject)
134
+ to_see.push val
135
+ elsif val.is_a?(Array)
136
+ to_see += val.select { |v| v.is_a?(ModelObject) }
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ def save! fn
143
+ #FileUtils.mv fn, "#{fn}~", :force => true rescue nil
144
+ File.open(fn, "w") { |f| f.puts to_yaml }
145
+ self
146
+ end
147
+
148
+ def to_yaml opts={}
149
+ ret = YAML::quick_emit(object_id, opts) do |out|
150
+ out.map(taguri, nil) do |map|
151
+ self.class.fields.each do |f, fops|
152
+ v = if @serialized_values.member?(f)
153
+ @serialized_values[f]
154
+ else
155
+ @serialized_values[f] = serialized_form_of f, @values[f]
156
+ end
157
+
158
+ map.add f.to_s, v
159
+ end
160
+ end
161
+ end
162
+ ret.decode
163
+ end
164
+
165
+ def log what, who, comment
166
+ add_log_event([Time.now, who, what, comment || ""])
167
+ self
168
+ end
169
+
170
+ def changed?; @changed ||= false end
171
+ def changed!; @changed = true end
172
+ def unchanged!; @changed = false end
173
+
174
+ def self.create_interactively opts={}
175
+ o = self.new
176
+ args = opts[:args] || []
177
+ @fields.each do |name, field_opts|
178
+ val = if opts[:with] && opts[:with][name]
179
+ opts[:with][name]
180
+ elsif field_opts[:generator].is_a? Proc
181
+ field_opts[:generator].call(*args)
182
+ elsif field_opts[:generator]
183
+ o.send field_opts[:generator], *args
184
+ elsif field_opts[:ask] == false # nil counts as true here
185
+ field_opts[:default] || (field_opts[:multi] ? [] : nil)
186
+ else
187
+ q = field_opts[:prompt] || name.to_s.capitalize
188
+ if field_opts[:multiline]
189
+ ask_multiline q
190
+ else
191
+ default = if field_opts[:default_generator].is_a? Proc
192
+ field_opts[:default_generator].call(*args)
193
+ elsif field_opts[:default_generator]
194
+ o.send field_opts[:default_generator], *args
195
+ elsif field_opts[:default]
196
+ field_opts[:default]
197
+ end
198
+
199
+ ask q, :default => default
200
+ end
201
+ end
202
+ o.send("#{name}=", val)
203
+ end
204
+ o
205
+ end
206
+ end
207
+
208
+ end