ditz 0.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,29 @@
1
+ <table>
2
+ <% issues.sort_by { |i| i.sort_order }.each do |i| %>
3
+ <tr>
4
+ <td class="issuestatus_<%= i.status %>">
5
+ <%= i.status_string %><% if i.closed? %>: <%= i.disposition_string %><% end %>
6
+ </td>
7
+ <td class="issuename">
8
+ <%= link_to i, i.title %>
9
+ <%= i.bug? ? '(bug)' : '' %>
10
+ </td>
11
+ <% if show_release %>
12
+ <td class="issuerelease">
13
+ <% if i.release %>
14
+ <% r = project.release_for i.release %>
15
+ in <%= link_to r, "release #{i.release}" %>
16
+ (<%= r.status %>)
17
+ <% else %>
18
+ <% end %>
19
+ </td>
20
+ <% end %>
21
+ <% if show_component %>
22
+ <td class="issuecomponent">
23
+ component <%= link_to project.component_for(i.component), i.component %>
24
+ </td>
25
+ <% end %>
26
+ </tr>
27
+ <% end %>
28
+ </table>
29
+
@@ -0,0 +1,150 @@
1
+ require 'util'
2
+
3
+ class Numeric
4
+ def to_pretty_s
5
+ if self < 20
6
+ %w(no one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen)[self]
7
+ else
8
+ to_s
9
+ end
10
+ end
11
+ end
12
+
13
+ class String
14
+ def ucfirst; self[0..0].upcase + self[1..-1] end
15
+ def dcfirst; self[0..0].downcase + self[1..-1] end
16
+ def blank?; self =~ /\A\s*\z/ end
17
+ def underline; self + "\n" + ("-" * self.length) end
18
+ def multiline prefix=""; blank? ? "" : "\n" + self.gsub(/^/, prefix) end
19
+ def pluralize n; n.to_pretty_s + " " + (n == 1 ? self : self + "s") end # oh yeah
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
+ def ask q, opts={}
56
+ default_s = case opts[:default]
57
+ when nil; nil
58
+ when ""; " (enter for none)"
59
+ else; " (enter for #{opts[:default].inspect})"
60
+ end
61
+
62
+ tail = case q
63
+ when /[:?]$/; " "
64
+ when /[:?]\s+$/; ""
65
+ else; ": "
66
+ end
67
+
68
+ while true
69
+ print [q, default_s, tail].compact.join
70
+ ans = gets.strip
71
+ if opts[:default]
72
+ ans = opts[:default] if ans.blank?
73
+ else
74
+ next if ans.blank? && !opts[:empty_ok]
75
+ end
76
+ break ans unless (opts[:restrict] && ans !~ opts[:restrict])
77
+ end
78
+ end
79
+
80
+ def ask_multiline q
81
+ puts "#{q} (ctrl-d or . by itself to stop):"
82
+ ans = ""
83
+ while true
84
+ print "> "
85
+ line = gets
86
+ break if line =~ /^\.$/ || line.nil?
87
+ ans << line.strip + "\n"
88
+ end
89
+ ans.sub(/\n+$/, "")
90
+ end
91
+
92
+ def ask_yon q
93
+ while true
94
+ print "#{q} (y/n): "
95
+ a = gets.strip
96
+ break a if a =~ /^[yn]$/i
97
+ end =~ /y/i
98
+ end
99
+
100
+ def ask_for_many plural_name, name=nil
101
+ name ||= plural_name.gsub(/s$/, "")
102
+ stuff = []
103
+
104
+ while true
105
+ puts
106
+ puts "Current #{plural_name}:"
107
+ if stuff.empty?
108
+ puts "None!"
109
+ else
110
+ stuff.each_with_index { |c, i| puts " #{i + 1}) #{c}" }
111
+ end
112
+ puts
113
+ ans = ask "(A)dd #{name}, (r)emove #{name}, or (d)one"
114
+ case ans
115
+ when "a", "A"
116
+ ans = ask "#{name.ucfirst} name", ""
117
+ stuff << ans unless ans =~ /^\s*$/
118
+ when "r", "R"
119
+ ans = ask "Remove which component? (1--#{stuff.size})"
120
+ stuff.delete_at(ans.to_i - 1) if ans
121
+ when "d", "D"
122
+ break
123
+ end
124
+ end
125
+ stuff
126
+ end
127
+
128
+ def ask_for_selection stuff, name, to_string=:to_s
129
+ puts "Choose a #{name}:"
130
+ stuff.each_with_index do |c, i|
131
+ pretty = case to_string
132
+ when Symbol
133
+ c.send to_string
134
+ when Proc
135
+ to_string.call c
136
+ else
137
+ raise ArgumentError, "unknown to_string argument type; expecting Proc or Symbol"
138
+ end
139
+ puts " #{i + 1}) #{pretty}"
140
+ end
141
+
142
+ j = while true
143
+ i = ask "#{name.ucfirst} (1--#{stuff.size})"
144
+ break i.to_i if i && (1 .. stuff.size).member?(i.to_i)
145
+ end
146
+
147
+ stuff[j - 1]
148
+ end
149
+ end
150
+
@@ -0,0 +1,265 @@
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
+ field :name, :default_generator => lambda { File.basename(Dir.pwd) }
40
+ field :version, :default => Ditz::VERSION, :ask => false
41
+ field :issues, :multi => true, :ask => false
42
+ field :components, :multi => true, :generator => :get_components
43
+ field :releases, :multi => true, :ask => false
44
+
45
+ def get_components
46
+ puts <<EOS
47
+ Issues can be tracked across the project as a whole, or the project can be
48
+ split into components, and issues tracked separately for each component.
49
+ EOS
50
+ use_components = ask_yon "Track issues separately for different components?"
51
+ comp_names = use_components ? ask_for_many("components") : []
52
+
53
+ ([name] + comp_names).uniq.map { |n| Component.create_interactively :with => { :name => n } }
54
+ end
55
+
56
+ def issue_for issue_name
57
+ issues.find { |i| i.name == issue_name } or
58
+ raise Error, "has no issue with name #{issue_name.inspect}"
59
+ end
60
+
61
+ def component_for component_name
62
+ components.find { |i| i.name == component_name } or
63
+ raise Error, "has no component with name #{component_name.inspect}"
64
+ end
65
+
66
+ def release_for release_name
67
+ releases.find { |i| i.name == release_name } or
68
+ raise Error, "has no release with name #{release_name.inspect}"
69
+ end
70
+
71
+ def issues_for_release release
72
+ issues.select { |i| i.release == release.name }
73
+ end
74
+
75
+ def issues_for_component component
76
+ issues.select { |i| i.component == component.name }
77
+ end
78
+
79
+ def unassigned_issues
80
+ issues.select { |i| i.release.nil? }
81
+ end
82
+
83
+ def assign_issue_names!
84
+ prefixes = components.map { |c| [c.name, c.name.gsub(/^\s+/, "-").downcase] }.to_h
85
+ ids = components.map { |c| [c.name, 0] }.to_h
86
+ issues.each do |i|
87
+ i.name = "#{prefixes[i.component]}-#{ids[i.component] += 1}"
88
+ end
89
+ end
90
+
91
+ def validate!
92
+ if(dup = components.map { |c| c.name }.first_duplicate)
93
+ raise Error, "more than one component named #{dup.inspect}"
94
+ elsif(dup = releases.map { |r| r.name }.first_duplicate)
95
+ raise Error, "more than one release named #{dup.inspect}"
96
+ end
97
+ end
98
+ end
99
+
100
+ class Issue < ModelObject
101
+ class Error < StandardError; end
102
+
103
+ field :title
104
+ field :desc, :prompt => "Description", :multiline => true
105
+ field :type, :generator => :get_type
106
+ field :component, :generator => :get_component
107
+ field :release, :generator => :get_release
108
+ field :reporter, :prompt => "Issue creator", :default_generator => lambda { |config, proj| config.user }
109
+ field :status, :ask => false, :default => :unstarted
110
+ field :disposition, :ask => false
111
+ field :creation_time, :ask => false, :generator => lambda { Time.now }
112
+ field :references, :ask => false, :multi => true
113
+ field :id, :ask => false, :generator => :make_id
114
+ changes_are_logged
115
+
116
+ attr_accessor :name
117
+
118
+ STATUS_SORT_ORDER = { :unstarted => 2, :paused => 1, :in_progress => 0, :closed => 3 }
119
+ STATUS_WIDGET = { :unstarted => "_", :in_progress => ">", :paused => "=", :closed => "x" }
120
+ DISPOSITIONS = [ :fixed, :wontfix, :reorg ]
121
+ TYPES = [ :bugfix, :feature ]
122
+ STATUSES = STATUS_WIDGET.keys
123
+
124
+ STATUS_STRINGS = { :in_progress => "in progress", :wontfix => "won't fix" }
125
+ DISPOSITION_STRINGS = { :wontfix => "won't fix", :reorg => "reorganized" }
126
+
127
+ def before_serialize project
128
+ self.desc = project.issues.inject(desc) do |s, i|
129
+ s.gsub(/\b#{i.name}\b/, "{issue #{i.id}}")
130
+ end
131
+ end
132
+
133
+ def interpolated_desc issues
134
+ issues.inject(desc) do |s, i|
135
+ s.gsub(/\{issue #{i.id}\}/, block_given? ? yield(i) : i.name)
136
+ end.gsub(/\{issue \w+\}/, "[unknown issue]")
137
+ end
138
+
139
+ ## make a unique id
140
+ def make_id config, project
141
+ SHA1.hexdigest [Time.now, rand, creation_time, reporter, title, desc].join("\n")
142
+ end
143
+
144
+ def sort_order; [STATUS_SORT_ORDER[@status], creation_time] end
145
+ def status_widget; STATUS_WIDGET[@status] end
146
+
147
+ def status_string; STATUS_STRINGS[status] || status.to_s end
148
+ def disposition_string; DISPOSITION_STRINGS[disposition] || disposition.to_s end
149
+
150
+ def closed?; status == :closed end
151
+ def open?; !closed? end
152
+ def in_progress?; status == :in_progress end
153
+ def bug?; type == :bugfix end
154
+ def feature?; type == :feature end
155
+
156
+ def start_work who, comment; change_status :in_progress, who, comment end
157
+ def stop_work who, comment
158
+ raise Error, "unstarted" unless self.status == :in_progress
159
+ change_status :paused, who, comment
160
+ end
161
+
162
+ def close disp, who, comment
163
+ raise Error, "unknown disposition #{disp}" unless DISPOSITIONS.member? disp
164
+ log "closed issue with disposition #{disp}", who, comment
165
+ self.status = :closed
166
+ self.disposition = disp
167
+ end
168
+
169
+ def change_status to, who, comment
170
+ raise Error, "unknown status #{to}" unless STATUSES.member? to
171
+ raise Error, "already marked as #{to}" if status == to
172
+ log "changed status from #{@status} to #{to}", who, comment
173
+ self.status = to
174
+ end
175
+ private :change_status
176
+
177
+ def change hash, who, comment
178
+ what = []
179
+ if title != hash[:title]
180
+ what << "changed title"
181
+ self.title = hash[:title]
182
+ end
183
+
184
+ if desc != hash[:description]
185
+ what << "changed description"
186
+ self.desc = hash[:description]
187
+ end
188
+
189
+ if reporter != hash[:reporter]
190
+ what << "changed reporter"
191
+ self.reporter = hash[:reporter]
192
+ end
193
+
194
+ unless what.empty?
195
+ log what.join(", "), who, comment
196
+ true
197
+ end
198
+ end
199
+
200
+ def assign_to_release release, who, comment
201
+ log "assigned to release #{release.name} from #{self.release || 'unassigned'}", who, comment
202
+ self.release = release.name
203
+ end
204
+
205
+ def unassign who, comment
206
+ raise Error, "not assigned to a release" unless release
207
+ log "unassigned from release #{release}", who, comment
208
+ self.release = nil
209
+ end
210
+
211
+ def get_type config, project
212
+ ask "Is this a (b)ugfix or a (f)eature?", :restrict => /^[bf]$/
213
+ type == "b" ? :bugfix : :feature
214
+ end
215
+
216
+ def get_component config, project
217
+ if project.components.size == 1
218
+ project.components.first
219
+ else
220
+ ask_for_selection project.components, "component", :name
221
+ end.name
222
+ end
223
+
224
+ def get_release config, project
225
+ releases = project.releases.select { |r| r.unreleased? }
226
+ if !releases.empty? && ask_yon("Assign to a release now?")
227
+ if releases.size == 1
228
+ r = releases.first
229
+ puts "Assigning to release #{r.name}."
230
+ r
231
+ else
232
+ ask_for_selection releases, "release", :name
233
+ end.name
234
+ end
235
+ end
236
+
237
+ def get_reporter config, project
238
+ reporter = ask "Creator", :default => config.user
239
+ end
240
+ end
241
+
242
+ class Config < ModelObject
243
+ field :name, :prompt => "Your name", :default_generator => :get_default_name
244
+ field :email, :prompt => "Your email address", :default_generator => :get_default_email
245
+
246
+ def user; "#{name} <#{email}>" end
247
+
248
+ def get_default_name
249
+ require 'etc'
250
+
251
+ name = Etc.getpwnam(ENV["USER"]).gecos.split(/,/).first
252
+ end
253
+
254
+ def get_default_email
255
+ require 'socket'
256
+ email = ENV["USER"] + "@" +
257
+ begin
258
+ Socket.gethostbyname(Socket.gethostname).first
259
+ rescue SocketError
260
+ Socket.gethostname
261
+ end
262
+ end
263
+ end
264
+
265
+ end
@@ -0,0 +1,137 @@
1
+ require 'yaml'
2
+ require "lowline"; include Lowline
3
+ require "util"
4
+ require 'sha1'
5
+
6
+ module Ditz
7
+
8
+ class ModelObject
9
+ class ModelError < StandardError; end
10
+
11
+ ## yamlability
12
+ def self.yaml_domain; "ditz.rubyforge.org,2008-03-06" end
13
+ def self.yaml_other_thing; name.split('::').last.dcfirst end
14
+ def to_yaml_type; "!#{self.class.yaml_domain}/#{self.class.yaml_other_thing}" end
15
+ def to_yaml_properties; self.class.fields.map { |f| "@#{f.to_s}" } end
16
+ def self.inherited subclass
17
+ YAML.add_domain_type(yaml_domain, subclass.yaml_other_thing) do |type, val|
18
+ YAML.object_maker(subclass, val)
19
+ end
20
+ end
21
+ def before_serialize(*a); end
22
+ def after_deserialize(*a); end
23
+
24
+ def self.field name, opts={}
25
+ @fields ||= [] # can't use a hash because want to preserve field order when serialized
26
+ raise ModelError, "field with name #{name} already defined" if @fields.any? { |k, v| k == name }
27
+ @fields << [name, opts]
28
+
29
+ attr_reader name
30
+ if opts[:multi]
31
+ single_name = name.to_s.sub(/s$/, "") # oh yeah
32
+ define_method "add_#{single_name}" do |obj|
33
+ array = self.instance_variable_get("@#{name}")
34
+ raise ModelError, "already has a #{single_name} with name #{obj.name.inspect}" if obj.respond_to?(:name) && array.any? { |o| o.name == obj.name }
35
+ changed!
36
+ array << obj
37
+ end
38
+
39
+ define_method "drop_#{single_name}" do |obj|
40
+ return unless self.instance_variable_get("@#{name}").delete obj
41
+ changed!
42
+ obj
43
+ end
44
+ end
45
+ define_method "#{name}=" do |o|
46
+ changed!
47
+ instance_variable_set "@#{name}", o
48
+ end
49
+ end
50
+
51
+ def self.fields; @fields.map { |name, opts| name } end
52
+
53
+ def self.changes_are_logged
54
+ define_method(:changes_are_logged?) { true }
55
+ field :log_events, :multi => true, :ask => false
56
+ end
57
+
58
+ def self.from fn
59
+ returning YAML::load_file(fn) do |o|
60
+ raise ModelError, "error loading from yaml file #{fn.inspect}: expected a #{self}, got a #{o.class}" unless o.class == self
61
+ end
62
+ end
63
+
64
+ ## depth-first search on all reachable ModelObjects. fuck yeah.
65
+ def each_modelobject
66
+ seen = {}
67
+ to_see = [self]
68
+ until to_see.empty?
69
+ cur = to_see.pop
70
+ seen[cur] = true
71
+ yield cur
72
+ cur.class.fields.each do |f|
73
+ val = cur.send(f)
74
+ next if seen[val]
75
+ if val.is_a?(ModelObject)
76
+ to_see.push val
77
+ elsif val.is_a?(Array)
78
+ to_see += val.select { |v| v.is_a?(ModelObject) }
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ def save! fn
85
+ Ditz::debug "saving configuration to #{fn}"
86
+ FileUtils.mv fn, "#{fn}~", :force => true rescue nil
87
+ File.open(fn, "w") { |f| f.puts to_yaml }
88
+ end
89
+
90
+ def log what, who, comment
91
+ add_log_event([Time.now, who, what, comment])
92
+ self
93
+ end
94
+
95
+ def initialize
96
+ @changed = false
97
+ @log_events = []
98
+ end
99
+
100
+ def changed?; @changed end
101
+ def changed!; @changed = true end
102
+
103
+ def self.create_interactively opts={}
104
+ o = self.new
105
+ args = opts[:args] || []
106
+ @fields.each do |name, field_opts|
107
+ val = if opts[:with] && opts[:with][name]
108
+ opts[:with][name]
109
+ elsif field_opts[:generator].is_a? Proc
110
+ field_opts[:generator].call(*args)
111
+ elsif field_opts[:generator]
112
+ o.send field_opts[:generator], *args
113
+ elsif field_opts[:ask] == false # nil counts as true here
114
+ field_opts[:default] || (field_opts[:multi] ? [] : nil)
115
+ else
116
+ q = field_opts[:prompt] || name.to_s.ucfirst
117
+ if field_opts[:multiline]
118
+ ask_multiline q
119
+ else
120
+ default = if field_opts[:default_generator].is_a? Proc
121
+ field_opts[:default_generator].call(*args)
122
+ elsif field_opts[:default_generator]
123
+ o.send field_opts[:default_generator], *args
124
+ elsif field_opts[:default]
125
+ field_opts[:default]
126
+ end
127
+
128
+ ask q, :default => default
129
+ end
130
+ end
131
+ o.send("#{name}=", val)
132
+ end
133
+ o
134
+ end
135
+ end
136
+
137
+ end