ursm-ditz 0.4
Sign up to get free protection for your applications and to get access to all the features.
- data/Changelog +35 -0
- data/README.txt +127 -0
- data/Rakefile +33 -0
- data/ReleaseNotes +50 -0
- data/bin/ditz +213 -0
- data/contrib/completion/_ditz.zsh +29 -0
- data/contrib/completion/ditz.bash +22 -0
- data/lib/component.rhtml +22 -0
- data/lib/ditz.rb +56 -0
- data/lib/hook.rb +67 -0
- data/lib/html.rb +69 -0
- data/lib/index.rhtml +113 -0
- data/lib/issue.rhtml +111 -0
- data/lib/issue_table.rhtml +33 -0
- data/lib/lowline.rb +202 -0
- data/lib/model-objects.rb +314 -0
- data/lib/model.rb +208 -0
- data/lib/operator.rb +549 -0
- data/lib/plugins/git.rb +114 -0
- data/lib/plugins/issue-claiming.rb +92 -0
- data/lib/release.rhtml +69 -0
- data/lib/style.css +127 -0
- data/lib/trollop.rb +518 -0
- data/lib/unassigned.rhtml +31 -0
- data/lib/util.rb +57 -0
- data/lib/vendor/yaml_waml.rb +28 -0
- data/lib/view.rb +16 -0
- data/lib/views.rb +136 -0
- data/man/ditz.1 +38 -0
- metadata +90 -0
@@ -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
|