File: model-objects.rb

package info (click to toggle)
ditz 0.5-1
  • links: PTS
  • area: main
  • in suites: squeeze, wheezy
  • size: 356 kB
  • ctags: 489
  • sloc: ruby: 3,664; sh: 15; makefile: 12
file content (346 lines) | stat: -rw-r--r-- 10,174 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
require 'model'

module Ditz

class Component < ModelObject
  field :name
  def name_prefix; name.gsub(/\s+/, "-").downcase end
end

class Release < ModelObject
  class Error < StandardError; end

  field :name
  field :status, :default => :unreleased, :ask => false
  field :release_time, :ask => false
  changes_are_logged

  def released?; self.status == :released end
  def unreleased?; !released? end

  def issues_from project; project.issues.select { |i| i.release == name } end

  def release! project, who, comment
    raise Error, "already released" if released?

    issues = issues_from project
    bad = issues.find { |i| i.open? }
    raise Error, "open issue #{bad.name} must be reassigned" if bad

    self.release_time = Time.now
    self.status = :released
    log "released", who, comment
  end
end

class Project < ModelObject
  class Error < StandardError; end

  field :name, :prompt => "Project name", :default_generator => lambda { File.basename(Dir.pwd) }
  field :version, :default => Ditz::VERSION, :ask => false
  field :components, :multi => true, :generator => :get_components
  field :releases, :multi => true, :ask => false

  attr_accessor :pathname

  ## issues are not model fields proper, so we build up their interface here.
  attr_reader :issues
  def issues= issues
    @issues = issues
    @issues.each { |i| i.project = self }
    assign_issue_names!
    issues
  end

  def add_issue issue
    added_issues << issue
    issues << issue
    issue.project = self
    assign_issue_names!
    issue
  end

  def drop_issue issue
    if issues.delete issue
      deleted_issues << issue
      assign_issue_names!
    end
  end

  def added_issues; @added_issues ||= [] end
  def deleted_issues; @deleted_issues ||= [] end

  def get_components
    puts <<EOS
Issues can be tracked across the project as a whole, or the project can be
split into components, and issues tracked separately for each component.
EOS
    use_components = ask_yon "Track issues separately for different components?"
    comp_names = use_components ? ask_for_many("components") : []

    ([name] + comp_names).uniq.map { |n| Component.create_interactively :with => { :name => n } }
  end

  def issues_for ident
    by_name = issues.find { |i| i.name == ident }
    by_name ? [by_name] : issues.select { |i| i.id =~ /^#{Regexp::escape ident}/ }
  end

  def component_for component_name
    components.find { |i| i.name == component_name }
  end

  def release_for release_name
    releases.find { |i| i.name == release_name }
  end

  def unreleased_releases; releases.select { |r| r.unreleased? } end

  def issues_for_release release
    release == :unassigned ? unassigned_issues : issues.select { |i| i.release == release.name }
  end

  def issues_for_component component
    issues.select { |i| i.component == component.name }
  end

  def unassigned_issues
    issues.select { |i| i.release.nil? }
  end

  def group_issues these_issues=issues
    these_issues.group_by { |i| i.type }.sort_by { |(t,g)| Issue::TYPE_ORDER[t] }
  end

  def assign_issue_names!
    prefixes = components.map { |c| [c.name, c.name.gsub(/^\s+/, "-").downcase] }.to_h
    ids = components.map { |c| [c.name, 0] }.to_h
    issues.sort_by { |i| i.creation_time }.each do |i|
      i.name = "#{prefixes[i.component]}-#{ids[i.component] += 1}"
    end
  end

  def validate!
    if(dup = components.map { |c| c.name }.first_duplicate)
      raise Error, "more than one component named #{dup.inspect}: #{components.inspect}"
    elsif(dup = releases.map { |r| r.name }.first_duplicate)
      raise Error, "more than one release named #{dup.inspect}"
    end
  end

  def self.from *a
    p = super(*a)
    p.validate!
    p
  end
end

class Issue < ModelObject
  class Error < StandardError; end

  field :title
  field :desc, :prompt => "Description", :multiline => true
  field :type, :generator => :get_type
  field :component, :generator => :get_component
  field :release, :generator => :get_release
  field :reporter, :prompt => "Issue creator", :default_generator => lambda { |config, proj| config.user }
  field :status, :ask => false, :default => :unstarted
  field :disposition, :ask => false
  field :creation_time, :ask => false, :generator => lambda { Time.now }
  field :references, :ask => false, :multi => true
  field :id, :ask => false, :generator => :make_id
  changes_are_logged

  attr_accessor :name, :pathname, :project

  ## these are the fields we interpolate issue names on
  INTERPOLATED_FIELDS = [:title, :desc, :log_events]

  STATUS_SORT_ORDER = { :unstarted => 2, :paused => 1, :in_progress => 0, :closed => 3 }
  STATUS_WIDGET = { :unstarted => "_", :in_progress => ">", :paused => "=", :closed => "x" }
  DISPOSITIONS = [ :fixed, :wontfix, :reorg ]
  TYPES = [ :bugfix, :feature, :task ]
  TYPE_ORDER = { :bugfix => 0, :feature => 1, :task => 2 }
  TYPE_LETTER = { 'b' => :bugfix, 'f' => :feature, 't' => :task }
  STATUSES = STATUS_WIDGET.keys

  STATUS_STRINGS = { :in_progress => "in progress", :wontfix => "won't fix" }
  DISPOSITION_STRINGS = { :wontfix => "won't fix", :reorg => "reorganized" }

  def serialized_form_of field, value
    return super unless INTERPOLATED_FIELDS.member? field

    if field == :log_events
      value.map do |time, who, what, comment|
        comment = @project.issues.inject(comment) do |s, i|
          s.gsub(/\b#{i.name}\b/, "{issue #{i.id}}")
        end
        [time, who, what, comment]
      end
    else
      @project.issues.inject(value) do |s, i|
        s.gsub(/\b#{i.name}\b/, "{issue #{i.id}}")
      end
    end
  end

  def deserialized_form_of field, value
    return super unless INTERPOLATED_FIELDS.member? field

    if field == :log_events
      value.map do |time, who, what, comment|
        comment = @project.issues.inject(comment) do |s, i|
          s.gsub(/\{issue #{i.id}\}/, i.name)
        end.gsub(/\{issue \w+\}/, "[unknown issue]")
        [time, who, what, comment]
      end
    else
      @project.issues.inject(value) do |s, i|
        s.gsub(/\{issue #{i.id}\}/, i.name)
      end.gsub(/\{issue \w+\}/, "[unknown issue]")
    end
  end

  ## make a unique id
  def make_id config, project
    SHA1.hexdigest [Time.now, rand, creation_time, reporter, title, desc].join("\n")
  end

  def sort_order; [STATUS_SORT_ORDER[status], creation_time] end
  def status_widget; STATUS_WIDGET[status] end

  def status_string; STATUS_STRINGS[status] || status.to_s end
  def disposition_string; DISPOSITION_STRINGS[disposition] || disposition.to_s end

  def closed?; status == :closed end
  def open?; !closed? end
  def in_progress?; status == :in_progress end
  def unstarted?; !in_progress? end
  def bug?; type == :bugfix end
  def feature?; type == :feature end
  def unassigned?; release.nil? end
  def assigned?; !unassigned? end
  def paused?; status == :paused end

  def start_work who, comment; change_status :in_progress, who, comment end
  def stop_work who, comment
    raise Error, "unstarted" unless self.status == :in_progress
    change_status :paused, who, comment
  end

  def close disp, who, comment
    raise Error, "unknown disposition #{disp}" unless DISPOSITIONS.member? disp
    log "closed with disposition #{disp}", who, comment
    self.status = :closed
    self.disposition = disp
  end

  def change_status to, who, comment
    raise Error, "unknown status #{to}" unless STATUSES.member? to
    raise Error, "already marked as #{to}" if status == to
    log "changed status from #{status} to #{to}", who, comment
    self.status = to
  end
  private :change_status

  def change hash, who, comment, silent
    what = []
    if title != hash[:title]
      what << "title"
      self.title = hash[:title]
    end

    if desc != hash[:description]
      what << "description"
      self.desc = hash[:description]
    end

    if reporter != hash[:reporter]
      what << "reporter"
      self.reporter = hash[:reporter]
    end

    unless what.empty? || silent
      log "edited " + what.join(", "), who, comment
      true
    end

    !what.empty?
  end

  def assign_to_release release, who, comment
    log "assigned to release #{release.name} from #{self.release || 'unassigned'}", who, comment
    self.release = release.name
  end

  def assign_to_component component, who, comment
    log "assigned to component #{component.name} from #{self.component}", who, comment
    self.component = component.name
  end

  def unassign who, comment
    raise Error, "not assigned to a release" unless release
    log "unassigned from release #{release}", who, comment
    self.release = nil
  end

  def get_type config, project
    type = ask "Is this a (b)ugfix, a (f)eature, or a (t)ask?", :restrict => /^[bft]$/
    TYPE_LETTER[type]
  end

  def get_component config, project
    if project.components.size == 1
      project.components.first
    else
      ask_for_selection project.components, "component", :name
    end.name
  end

  def get_release config, project
    releases = project.releases.select { |r| r.unreleased? }
    if !releases.empty? && ask_yon("Assign to a release now?")
      if releases.size == 1
        r = releases.first
        puts "Assigning to release #{r.name}."
        r
      else
        ask_for_selection releases, "release", :name
      end.name
    end
  end

  def get_reporter config, project
    reporter = ask "Creator", :default => config.user
  end
end

class Config < ModelObject
  field :name, :prompt => "Your name", :default_generator => :get_default_name
  field :email, :prompt => "Your email address", :default_generator => :get_default_email
  field :issue_dir, :prompt => "Directory to store issues state in", :default => "bugs"

  def user; "#{name} <#{email}>" end

  def get_default_name
    require 'etc'

    name = if ENV["USER"]
      pwent = Etc.getpwnam ENV["USER"]
      pwent ? pwent.gecos.split(/,/).first : nil
    end
    name || "Ditz User"
  end

  def get_default_email
    require 'socket'
    email = (ENV["USER"] || "") + "@" +
      begin
        Socket.gethostbyname(Socket.gethostname).first
      rescue SocketError
        Socket.gethostname
      end
  end
end

end