ohac-ditz 0.5.1

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,244 @@
1
+ require 'tempfile'
2
+ require "ditz/util"
3
+
4
+ class Numeric
5
+ def to_pretty_s
6
+ %w(zero one two three four five six seven eight nine ten)[self] || to_s
7
+ end
8
+ end
9
+
10
+ class String
11
+ def dcfirst; self[0..0].downcase + self[1..-1] end
12
+ def blank?; self =~ /\A\s*\z/ end
13
+ def underline; self + "\n" + ("-" * self.length) end
14
+ def pluralize n, b=true
15
+ s = (n == 1 ? self : (self == 'bugfix' ? 'bugfixes' : self + "s")) # oh yeah
16
+ b ? n.to_pretty_s + " " + s : s
17
+ end
18
+ def shortened_email; self =~ /<?(\S+?)@.+/ ? $1 : self end
19
+ def multistrip; strip.gsub(/\n\n+/, "\n\n") end
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
+ ## UI configuration
56
+ @use_editor_if_possible = true
57
+ attr_accessor :use_editor_if_possible
58
+
59
+ class Error < StandardError; end
60
+
61
+ def editor
62
+ @editor ||=
63
+ if ENV["EDITOR"] && !ENV["EDITOR"].empty?
64
+ ENV["EDITOR"]
65
+ else
66
+ %w(/usr/bin/sensible-editor /usr/bin/vi).find { |e| File.exist?(e) }
67
+ end
68
+ end
69
+
70
+ def run_editor
71
+ raise Error, "no editor" unless editor
72
+
73
+ f = Tempfile.new "ditz"
74
+ yield f
75
+ f.close
76
+
77
+ cmd = "#{editor} #{f.path.inspect}"
78
+
79
+ mtime = File.mtime f.path
80
+ system cmd or raise Error, "cannot execute command: #{cmd.inspect}"
81
+
82
+ File.mtime(f.path) == mtime ? nil : f.path
83
+ end
84
+
85
+ def ask q, opts={}
86
+ default_s = case opts[:default]
87
+ when nil; nil
88
+ when ""; " (enter for none)"
89
+ else; " (enter for #{opts[:default].to_s})"
90
+ end
91
+
92
+ tail = case q
93
+ when /[:?]$/; " "
94
+ when /[:?]\s+$/; ""
95
+ else; ": "
96
+ end
97
+
98
+ while true
99
+ prompt = [q, default_s, tail].compact.join
100
+ if Ditz::has_readline?
101
+ ans = Readline::readline(prompt)
102
+ else
103
+ print prompt
104
+ ans = STDIN.gets.strip
105
+ end
106
+ if opts[:default]
107
+ ans = opts[:default] if ans.blank?
108
+ else
109
+ next if ans.blank? && !opts[:empty_ok]
110
+ end
111
+ break ans unless (opts[:restrict] && ans !~ opts[:restrict])
112
+ end
113
+ end
114
+
115
+ def ask_via_editor q, opts={}
116
+ default = opts[:default]
117
+ comments = opts[:comments]
118
+ fn = run_editor do |f|
119
+ if default
120
+ f.puts default
121
+ end
122
+ f.puts
123
+ f.puts q.gsub(/^/, "## ")
124
+ f.puts "##"
125
+ f.puts "## Enter your text above. Lines starting with a '#' will be ignored."
126
+ if comments
127
+ f.puts "##"
128
+ f.puts comments.gsub(/^/, "## ")
129
+ end
130
+ end
131
+ return unless fn
132
+ IO.read(fn).gsub(/^#.*$/, "").multistrip
133
+ end
134
+
135
+ def ask_multiline q
136
+ puts "#{q} (ctrl-d, ., or /stop to stop, /edit to edit, /reset to reset):"
137
+ ans = ""
138
+ while true
139
+ if Ditz::has_readline?
140
+ line = Readline::readline('> ')
141
+ else
142
+ (line = STDIN.gets) && line.strip!
143
+ end
144
+ if line
145
+ if Ditz::has_readline?
146
+ Readline::HISTORY.push(line)
147
+ end
148
+ case line
149
+ when /^\.$/, "/stop"
150
+ break
151
+ when "/reset"
152
+ return ask_multiline(q)
153
+ when "/edit"
154
+ return ask_via_editor(q, :default => ans)
155
+ else
156
+ ans << line + "\n"
157
+ end
158
+ else
159
+ puts
160
+ break
161
+ end
162
+ end
163
+ ans.multistrip
164
+ end
165
+
166
+ def ask_multiline_or_editor q, opts={}
167
+ if Lowline.use_editor_if_possible && editor
168
+ ask_via_editor q, :comments => opts[:comments]
169
+ else
170
+ ask_multiline q
171
+ end
172
+ end
173
+
174
+ def ask_yon q
175
+ while true
176
+ print "#{q} (y/n): "
177
+ a = STDIN.gets.strip
178
+ break a if a =~ /^[yn]$/i
179
+ end =~ /y/i
180
+ end
181
+
182
+ def ask_for_many plural_name, name=nil
183
+ name ||= plural_name.gsub(/s$/, "")
184
+ stuff = []
185
+
186
+ while true
187
+ puts
188
+ puts "Current #{plural_name}:"
189
+ if stuff.empty?
190
+ puts "None!"
191
+ else
192
+ stuff.each_with_index { |c, i| puts " #{i + 1}) #{c}" }
193
+ end
194
+ puts
195
+ ans = ask "(A)dd #{name}, (r)emove #{name}, or (d)one"
196
+ case ans
197
+ when "a", "A"
198
+ ans = ask "#{name.capitalize} name", ""
199
+ stuff << ans unless ans =~ /^\s*$/
200
+ when "r", "R"
201
+ ans = ask "Remove which #{name}? (1--#{stuff.size})"
202
+ stuff.delete_at(ans.to_i - 1) if ans
203
+ when "d", "D"
204
+ break
205
+ end
206
+ end
207
+ stuff
208
+ end
209
+
210
+ def ask_for_selection stuff, name, to_string=:to_s, many=false
211
+ if many
212
+ return [] if stuff.empty?
213
+ name = name.pluralize(2, false)
214
+ puts "Choose one or more #{name} (comma separated list):"
215
+ else
216
+ return nil if stuff.empty?
217
+ puts "Choose a #{name}:"
218
+ end
219
+ stuff.each_with_index do |c, i|
220
+ pretty = case to_string
221
+ when block_given? && to_string # heh
222
+ yield c
223
+ when Symbol
224
+ c.send to_string
225
+ when Proc
226
+ to_string.call c
227
+ else
228
+ raise ArgumentError, "unknown to_string argument type; expecting Proc or Symbol"
229
+ end
230
+ puts " #{i + 1}) #{pretty}"
231
+ end
232
+
233
+ js = while true
234
+ is = ask "#{name.capitalize} (1--#{stuff.size})"
235
+ next unless is
236
+ is = is.strip.split(/\s*,\s*/).map { |i| i.to_i }
237
+ break is if is.all? { |i| (1 .. stuff.size).member?(i) }
238
+ end
239
+
240
+ ss = js.map { |j| stuff[j - 1] }
241
+ (many)? ss : ss.first
242
+ end
243
+ end
244
+
@@ -0,0 +1,379 @@
1
+ require 'ditz/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, :prompt => "Project name", :default_generator => lambda { File.basename(Dir.pwd) }
40
+ field :version, :default => Ditz::VERSION, :ask => false
41
+ field :components, :multi => true, :interactive_generator => :get_components
42
+ field :releases, :multi => true, :ask => false
43
+
44
+ attr_accessor :pathname
45
+
46
+ ## issues are not model fields proper, so we build up their interface here.
47
+ attr_reader :issues
48
+ def issues= issues
49
+ @issues = issues
50
+ @issues.each { |i| i.project = self }
51
+ assign_issue_names!
52
+ issues
53
+ end
54
+
55
+ def add_issue issue
56
+ added_issues << issue
57
+ issues << issue
58
+ issue.project = self
59
+ assign_issue_names!
60
+ issue
61
+ end
62
+
63
+ def drop_issue issue
64
+ if issues.delete issue
65
+ deleted_issues << issue
66
+ assign_issue_names!
67
+ end
68
+ end
69
+
70
+ alias :__old_drop_release :drop_release
71
+ def drop_release release
72
+ raise Error, "only can drop releases without issues" unless issues_for_release(release).empty?
73
+ __old_drop_release release
74
+ end
75
+
76
+ def added_issues; @added_issues ||= [] end
77
+ def deleted_issues; @deleted_issues ||= [] end
78
+
79
+ def get_components
80
+ puts <<EOS
81
+ Issues can be tracked across the project as a whole, or the project can be
82
+ split into components, and issues tracked separately for each component.
83
+ EOS
84
+ use_components = ask_yon "Track issues separately for different components?"
85
+ comp_names = use_components ? ask_for_many("components") : []
86
+
87
+ ([name] + comp_names).uniq.map { |n| Component.create_interactively :with => { :name => n } }
88
+ end
89
+
90
+ def issues_for ident
91
+ by_name = issues.find { |i| i.name == ident }
92
+ by_name ? [by_name] : issues.select { |i| i.id =~ /^#{Regexp::escape ident}/ }
93
+ end
94
+
95
+ def component_for component_name
96
+ components.find { |i| i.name == component_name }
97
+ end
98
+
99
+ def release_for release_name
100
+ releases.find { |i| i.name == release_name }
101
+ end
102
+
103
+ def unreleased_releases; releases.select { |r| r.unreleased? } end
104
+
105
+ def issues_for_release release
106
+ release == :unassigned ? unassigned_issues : issues.select { |i| i.release == release.name }
107
+ end
108
+
109
+ def issues_for_component component
110
+ issues.select { |i| i.component == component.name }
111
+ end
112
+
113
+ def unassigned_issues
114
+ issues.select { |i| i.release.nil? }
115
+ end
116
+
117
+ def group_issues these_issues=issues
118
+ these_issues.group_by { |i| i.type }.sort_by { |(t,g)| Issue::TYPE_ORDER[t] }
119
+ end
120
+
121
+ def assign_issue_names!
122
+ prefixes = components.map { |c| [c.name, c.name.gsub(/^\s+/, "-").downcase] }.to_h
123
+ ids = components.map { |c| [c.name, 0] }.to_h
124
+ issues.sort_by { |i| i.creation_time }.each do |i|
125
+ i.name = components.length > 1 ? "#{prefixes[i.component]}-#{ids[i.component] += 1}" : "##{ids[i.component] += 1}"
126
+ end
127
+ end
128
+
129
+ def validate! whence, context
130
+ config, project = context
131
+ if(dup = components.map { |c| c.name }.first_duplicate)
132
+ raise Error, "more than one component named #{dup.inspect}: #{components.inspect}"
133
+ elsif(dup = releases.map { |r| r.name }.first_duplicate)
134
+ raise Error, "more than one release named #{dup.inspect}"
135
+ end
136
+ end
137
+ end
138
+
139
+ class Issue < ModelObject
140
+ class Error < StandardError; end
141
+
142
+ field :title
143
+ field :desc, :prompt => "Description", :multiline => true
144
+ field :type, :interactive_generator => :get_type
145
+ field :component, :interactive_generator => :get_component
146
+ field :release, :interactive_generator => :get_release, :nil_ok => true
147
+ field :reporter, :prompt => "Issue creator", :default_generator => lambda { |config, proj| config.user }
148
+ field :status, :ask => false, :default => :unstarted
149
+ field :disposition, :ask => false
150
+ field :creation_time, :ask => false, :generator => Proc.new { Time.now }
151
+ field :references, :ask => false, :multi => true
152
+ field :id, :ask => false, :generator => :make_id
153
+ changes_are_logged
154
+
155
+ ## we only have to check beyond what ModelObject.create already checks
156
+ def validate! whence, context
157
+ config, project = context
158
+ if whence == :create
159
+ raise ModelError, "title is empty" unless title =~ /\S/
160
+ raise ModelError, "invalid type #{type.inspect}" unless TYPES.member?(type)
161
+ raise ModelError, "invalid status #{status.inspect}" unless STATUSES.member?(status)
162
+ raise ModelError, "#{type}s can't be added to already-released releases" if release && [:feature, :task].include?(type) && project.release_for(release).released?
163
+ end
164
+ end
165
+
166
+ attr_accessor :name, :pathname, :project
167
+
168
+ ## these are the fields we interpolate issue names on
169
+ #INTERPOLATED_FIELDS = [:title, :desc, :log_events]
170
+ INTERPOLATED_FIELDS = []
171
+
172
+ STATUS_SORT_ORDER = { :unstarted => 2, :paused => 1, :in_progress => 0, :closed => 3 }
173
+ STATUS_WIDGET = { :unstarted => "_", :in_progress => ">", :paused => "=", :closed => "x" }
174
+ DISPOSITIONS = [ :fixed, :wontfix, :reorg ]
175
+ TYPES = [ :bugfix, :feature, :task ]
176
+ TYPE_ORDER = { :bugfix => 0, :feature => 1, :task => 2 }
177
+ TYPE_LETTER = { 'b' => :bugfix, 'f' => :feature, 't' => :task }
178
+ STATUSES = STATUS_WIDGET.keys
179
+
180
+ STATUS_STRINGS = { :in_progress => "in progress", :wontfix => "won't fix" }
181
+ DISPOSITION_STRINGS = { :wontfix => "won't fix", :reorg => "reorganized" }
182
+
183
+ def serialized_form_of field, value
184
+ return super unless INTERPOLATED_FIELDS.member? field
185
+
186
+ if field == :log_events
187
+ value.map do |time, who, what, comment|
188
+ comment = @project.issues.inject(comment || '') do |s, i|
189
+ s.gsub(/\b#{i.name}\b/, "{issue #{i.id}}")
190
+ end
191
+ [time, who, what, comment]
192
+ end
193
+ else
194
+ @project.issues.inject(value || '') do |s, i|
195
+ s.gsub(/\b#{i.name}\b/, "{issue #{i.id}}")
196
+ end
197
+ end
198
+ end
199
+
200
+ def deserialized_form_of field, value
201
+ return super unless INTERPOLATED_FIELDS.member? field
202
+
203
+ if field == :log_events
204
+ value.map do |time, who, what, comment|
205
+ comment = @project.issues.inject(comment) do |s, i|
206
+ s.gsub(/\{issue #{i.id}\}/, i.name)
207
+ end.gsub(/\{issue \w+\}/, "[unknown issue]")
208
+ [time, who, what, comment]
209
+ end
210
+ else
211
+ @project.issues.inject(value) do |s, i|
212
+ s.gsub(/\{issue #{i.id}\}/, i.name)
213
+ end.gsub(/\{issue \w+\}/, "[unknown issue]")
214
+ end
215
+ end
216
+
217
+ ## make a unique id
218
+ def make_id config, project
219
+ if RUBY_VERSION >= '1.9.0'
220
+ Digest::SHA1.hexdigest [Time.now, rand, creation_time, reporter, title, desc].join("\n")
221
+ else
222
+ SHA1.hexdigest [Time.now, rand, creation_time, reporter, title, desc].join("\n")
223
+ end
224
+ end
225
+
226
+ def sort_order; [STATUS_SORT_ORDER[status], creation_time] end
227
+ def status_widget; STATUS_WIDGET[status] end
228
+
229
+ def status_string; STATUS_STRINGS[status] || status.to_s end
230
+ def disposition_string; DISPOSITION_STRINGS[disposition] || disposition.to_s end
231
+
232
+ def closed?; status == :closed end
233
+ def open?; !closed? end
234
+ def in_progress?; status == :in_progress end
235
+ def unstarted?; !in_progress? end
236
+ def bug?; type == :bugfix end
237
+ def feature?; type == :feature end
238
+ def unassigned?; release.nil? end
239
+ def assigned?; !unassigned? end
240
+ def paused?; status == :paused end
241
+
242
+ def start_work who, comment; change_status :in_progress, who, comment end
243
+ def stop_work who, comment
244
+ raise Error, "unstarted" unless self.status == :in_progress
245
+ change_status :paused, who, comment
246
+ end
247
+
248
+ def close disp, who, comment
249
+ raise Error, "unknown disposition #{disp}" unless DISPOSITIONS.member? disp
250
+ log "closed with disposition #{disp}", who, comment
251
+ self.status = :closed
252
+ self.disposition = disp
253
+ end
254
+
255
+ def change_status to, who, comment
256
+ raise Error, "unknown status #{to}" unless STATUSES.member? to
257
+ raise Error, "already marked as #{to}" if status == to
258
+ log "changed status from #{status} to #{to}", who, comment
259
+ self.status = to
260
+ end
261
+ private :change_status
262
+
263
+ def change hash, who, comment, silent
264
+ what = []
265
+ if title != hash[:title]
266
+ what << "title"
267
+ self.title = hash[:title]
268
+ end
269
+
270
+ if desc != hash[:description]
271
+ what << "description"
272
+ self.desc = hash[:description]
273
+ end
274
+
275
+ if reporter != hash[:reporter]
276
+ what << "reporter"
277
+ self.reporter = hash[:reporter]
278
+ end
279
+
280
+ unless what.empty? || silent
281
+ log "edited " + what.join(", "), who, comment
282
+ true
283
+ end
284
+
285
+ !what.empty?
286
+ end
287
+
288
+ def assign_to_release release, who, comment
289
+ log "assigned to release #{release.name} from #{self.release || 'unassigned'}", who, comment
290
+ self.release = release.name
291
+ end
292
+
293
+ def assign_to_component component, who, comment
294
+ log "assigned to component #{component.name} from #{self.component}", who, comment
295
+ self.component = component.name
296
+ end
297
+
298
+ def unassign who, comment
299
+ raise Error, "not assigned to a release" unless release
300
+ log "unassigned from release #{release}", who, comment
301
+ self.release = nil
302
+ end
303
+
304
+ def get_type config, project
305
+ type = ask "Is this a (b)ugfix, a (f)eature, or a (t)ask?", :restrict => /^[bft]$/
306
+ TYPE_LETTER[type]
307
+ end
308
+
309
+ def get_component config, project
310
+ if project.components.size == 1
311
+ project.components.first
312
+ else
313
+ ask_for_selection project.components, "component", :name
314
+ end.name
315
+ end
316
+
317
+ def get_release config, project
318
+ releases = project.releases.select { |r| r.unreleased? }
319
+ if !releases.empty? && ask_yon("Assign to a release now?")
320
+ if releases.size == 1
321
+ r = releases.first
322
+ puts "Assigning to release #{r.name}."
323
+ r
324
+ else
325
+ ask_for_selection releases, "release", :name
326
+ end.name
327
+ end
328
+ end
329
+
330
+ def get_reporter config, project
331
+ reporter = ask "Creator", :default => config.user
332
+ end
333
+ end
334
+
335
+ class Config < ModelObject
336
+ field :name, :prompt => "Your name", :default_generator => :get_default_name
337
+ field :email, :prompt => "Your email address", :default_generator => :get_default_email
338
+ field :issue_dir, :prompt => "Directory to store issues state in", :default => ".ditz"
339
+ field :use_editor_if_possible, :interactive_generator => :get_use_editor
340
+ field :paginate, :interactive_generator => :get_paginate
341
+
342
+ def user; "#{name} <#{email}>" end
343
+
344
+ def validate! whence, context
345
+ self.use_editor_if_possible = true if self.use_editor_if_possible.nil?
346
+ end
347
+
348
+ def get_paginate
349
+ page = ask "Paginate output (always/never/auto)?", :restrict => /^(always|never|auto)$/i
350
+ page.downcase
351
+ end
352
+
353
+ def get_use_editor
354
+ yon = ask "Use your text editor for multi-line input when possible (y/n)?", :restrict => /^(y|yes|n|no)$/i
355
+ yon =~ /y/ ? true : false
356
+ end
357
+
358
+ def get_default_name
359
+ require 'etc'
360
+
361
+ name = if ENV["USER"]
362
+ pwent = Etc.getpwnam ENV["USER"]
363
+ pwent ? pwent.gecos.split(/,/).first : nil
364
+ end
365
+ name || "Ditz User"
366
+ end
367
+
368
+ def get_default_email
369
+ require 'socket'
370
+ email = (ENV["USER"] || "") + "@" +
371
+ begin
372
+ Socket.gethostbyname(Socket.gethostname).first
373
+ rescue SocketError
374
+ Socket.gethostname
375
+ end
376
+ end
377
+ end
378
+
379
+ end