ursm-ditz 0.4

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.
@@ -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