ditz 0.2 → 0.3
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.
- data/Changelog +13 -0
- data/README.txt +6 -6
- data/Rakefile +2 -2
- data/ReleaseNotes +13 -0
- data/bin/ditz +116 -44
- data/bin/ditz-convert-from-monolith +0 -0
- data/lib/ditz.rb +17 -1
- data/lib/hook.rb +60 -0
- data/lib/html.rb +6 -0
- data/lib/index.rhtml +13 -10
- data/lib/issue.rhtml +3 -7
- data/lib/lowline.rb +40 -18
- data/lib/model-objects.rb +58 -18
- data/lib/model.rb +75 -14
- data/lib/operator.rb +182 -69
- data/lib/release.rhtml +1 -1
- data/lib/util.rb +8 -0
- metadata +51 -43
data/lib/issue.rhtml
CHANGED
@@ -9,13 +9,9 @@
|
|
9
9
|
|
10
10
|
<%= link_to "index", "« #{project.name} project page" %>
|
11
11
|
|
12
|
-
<h1><%= issue.title %></h1>
|
12
|
+
<h1><%= link_issue_names project, issue.title %></h1>
|
13
13
|
|
14
|
-
<%=
|
15
|
-
project.issues.inject(p(issue.desc)) do |s, i|
|
16
|
-
s.gsub(/\{issue #{i.id}\}/, link_to(i, i.title))
|
17
|
-
end.gsub(/\{issue \w+\}/, "[unknown issue]")
|
18
|
-
%>
|
14
|
+
<%= link_issue_names project, p(issue.desc) %>
|
19
15
|
|
20
16
|
<table>
|
21
17
|
<tr>
|
@@ -95,7 +91,7 @@
|
|
95
91
|
<tr><td colspan="3" class="logcomment">
|
96
92
|
<% if comment.empty? %>
|
97
93
|
<% else %>
|
98
|
-
<%=p
|
94
|
+
<%= link_issue_names project, p(comment) %>
|
99
95
|
<% end %>
|
100
96
|
</td></tr>
|
101
97
|
<% end %>
|
data/lib/lowline.rb
CHANGED
@@ -3,7 +3,7 @@ require "util"
|
|
3
3
|
|
4
4
|
class Numeric
|
5
5
|
def to_pretty_s
|
6
|
-
%w(
|
6
|
+
%w(zero one two three four five six seven eight nine ten)[self] || to_s
|
7
7
|
end
|
8
8
|
end
|
9
9
|
|
@@ -12,12 +12,17 @@ class NilClass
|
|
12
12
|
end
|
13
13
|
|
14
14
|
class String
|
15
|
-
def ucfirst; self[0..0].upcase + self[1..-1] end
|
16
15
|
def dcfirst; self[0..0].downcase + self[1..-1] end
|
17
16
|
def blank?; self =~ /\A\s*\z/ end
|
18
17
|
def underline; self + "\n" + ("-" * self.length) end
|
19
|
-
def multiline prefix=""
|
20
|
-
|
18
|
+
def multiline prefix="", cleanstart=true
|
19
|
+
return "" if blank?
|
20
|
+
(cleanstart ? "\n" : "") + gsub(/^/, prefix)
|
21
|
+
end
|
22
|
+
def pluralize n, b=true
|
23
|
+
s = (n == 1 ? self : (self == 'bugfix' ? 'bugfixes' : self + "s")) # oh yeah
|
24
|
+
b ? n.to_pretty_s + " " + s : s
|
25
|
+
end
|
21
26
|
def multistrip; strip.gsub(/\n\n+/, "\n\n") end
|
22
27
|
end
|
23
28
|
|
@@ -82,8 +87,13 @@ module Lowline
|
|
82
87
|
end
|
83
88
|
|
84
89
|
while true
|
85
|
-
|
86
|
-
|
90
|
+
prompt = [q, default_s, tail].compact.join
|
91
|
+
if Ditz::has_readline?
|
92
|
+
ans = Readline::readline(prompt)
|
93
|
+
else
|
94
|
+
print prompt
|
95
|
+
ans = gets.strip
|
96
|
+
end
|
87
97
|
if opts[:default]
|
88
98
|
ans = opts[:default] if ans.blank?
|
89
99
|
else
|
@@ -109,16 +119,28 @@ module Lowline
|
|
109
119
|
puts "#{q} (ctrl-d, ., or /stop to stop, /edit to edit, /reset to reset):"
|
110
120
|
ans = ""
|
111
121
|
while true
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
122
|
+
if Ditz::has_readline?
|
123
|
+
line = Readline::readline('> ')
|
124
|
+
else
|
125
|
+
(line = gets) && line.strip!
|
126
|
+
end
|
127
|
+
if line
|
128
|
+
if Ditz::has_readline?
|
129
|
+
Readline::HISTORY.push(line)
|
130
|
+
end
|
131
|
+
case line
|
132
|
+
when /^\.$/, "/stop"
|
133
|
+
break
|
134
|
+
when "/reset"
|
135
|
+
return ask_multiline(q)
|
136
|
+
when "/edit"
|
137
|
+
return ask_via_editor(q, ans)
|
138
|
+
else
|
139
|
+
ans << line + "\n"
|
140
|
+
end
|
120
141
|
else
|
121
|
-
|
142
|
+
puts
|
143
|
+
break
|
122
144
|
end
|
123
145
|
end
|
124
146
|
ans.multistrip
|
@@ -148,10 +170,10 @@ module Lowline
|
|
148
170
|
ans = ask "(A)dd #{name}, (r)emove #{name}, or (d)one"
|
149
171
|
case ans
|
150
172
|
when "a", "A"
|
151
|
-
ans = ask "#{name.
|
173
|
+
ans = ask "#{name.capitalize} name", ""
|
152
174
|
stuff << ans unless ans =~ /^\s*$/
|
153
175
|
when "r", "R"
|
154
|
-
ans = ask "Remove which
|
176
|
+
ans = ask "Remove which #{name}? (1--#{stuff.size})"
|
155
177
|
stuff.delete_at(ans.to_i - 1) if ans
|
156
178
|
when "d", "D"
|
157
179
|
break
|
@@ -177,7 +199,7 @@ module Lowline
|
|
177
199
|
end
|
178
200
|
|
179
201
|
j = while true
|
180
|
-
i = ask "#{name.
|
202
|
+
i = ask "#{name.capitalize} (1--#{stuff.size})"
|
181
203
|
break i.to_i if i && (1 .. stuff.size).member?(i.to_i)
|
182
204
|
end
|
183
205
|
|
data/lib/model-objects.rb
CHANGED
@@ -4,7 +4,7 @@ module Ditz
|
|
4
4
|
|
5
5
|
class Component < ModelObject
|
6
6
|
field :name
|
7
|
-
def name_prefix;
|
7
|
+
def name_prefix; name.gsub(/\s+/, "-").downcase end
|
8
8
|
end
|
9
9
|
|
10
10
|
class Release < ModelObject
|
@@ -36,6 +36,8 @@ end
|
|
36
36
|
class Project < ModelObject
|
37
37
|
class Error < StandardError; end
|
38
38
|
|
39
|
+
attr_accessor :pathname
|
40
|
+
|
39
41
|
field :name, :default_generator => lambda { File.basename(Dir.pwd) }
|
40
42
|
field :version, :default => Ditz::VERSION, :ask => false
|
41
43
|
field :components, :multi => true, :generator => :get_components
|
@@ -83,6 +85,10 @@ EOS
|
|
83
85
|
issues.select { |i| i.release.nil? }
|
84
86
|
end
|
85
87
|
|
88
|
+
def group_issues these_issues=issues
|
89
|
+
these_issues.group_by { |i| i.type }.sort_by { |(t,g)| Issue::TYPE_ORDER[t] }
|
90
|
+
end
|
91
|
+
|
86
92
|
def assign_issue_names!
|
87
93
|
prefixes = components.map { |c| [c.name, c.name.gsub(/^\s+/, "-").downcase] }.to_h
|
88
94
|
ids = components.map { |c| [c.name, 0] }.to_h
|
@@ -93,7 +99,7 @@ EOS
|
|
93
99
|
|
94
100
|
def validate!
|
95
101
|
if(dup = components.map { |c| c.name }.first_duplicate)
|
96
|
-
raise Error, "more than one component named #{dup.inspect}"
|
102
|
+
raise Error, "more than one component named #{dup.inspect}: #{components.inspect}"
|
97
103
|
elsif(dup = releases.map { |r| r.name }.first_duplicate)
|
98
104
|
raise Error, "more than one release named #{dup.inspect}"
|
99
105
|
end
|
@@ -116,27 +122,54 @@ class Issue < ModelObject
|
|
116
122
|
field :id, :ask => false, :generator => :make_id
|
117
123
|
changes_are_logged
|
118
124
|
|
119
|
-
attr_accessor :name
|
125
|
+
attr_accessor :name, :pathname, :project
|
126
|
+
|
127
|
+
## these are the fields we interpolate issue names on
|
128
|
+
INTERPOLATED_FIELDS = [:title, :desc, :log_events]
|
120
129
|
|
121
130
|
STATUS_SORT_ORDER = { :unstarted => 2, :paused => 1, :in_progress => 0, :closed => 3 }
|
122
131
|
STATUS_WIDGET = { :unstarted => "_", :in_progress => ">", :paused => "=", :closed => "x" }
|
123
132
|
DISPOSITIONS = [ :fixed, :wontfix, :reorg ]
|
124
|
-
TYPES = [ :bugfix, :feature ]
|
133
|
+
TYPES = [ :bugfix, :feature, :task ]
|
134
|
+
TYPE_ORDER = { :bugfix => 0, :feature => 1, :task => 2 }
|
135
|
+
TYPE_LETTER = { 'b' => :bugfix, 'f' => :feature, 't' => :task }
|
125
136
|
STATUSES = STATUS_WIDGET.keys
|
126
137
|
|
127
138
|
STATUS_STRINGS = { :in_progress => "in progress", :wontfix => "won't fix" }
|
128
139
|
DISPOSITION_STRINGS = { :wontfix => "won't fix", :reorg => "reorganized" }
|
129
140
|
|
130
|
-
def
|
131
|
-
|
132
|
-
|
141
|
+
def serialized_form_of field, value
|
142
|
+
return super unless INTERPOLATED_FIELDS.member? field
|
143
|
+
|
144
|
+
if field == :log_events
|
145
|
+
value.map do |time, who, what, comment|
|
146
|
+
comment = @project.issues.inject(comment) do |s, i|
|
147
|
+
s.gsub(/\b#{i.name}\b/, "{issue #{i.id}}")
|
148
|
+
end
|
149
|
+
[time, who, what, comment]
|
150
|
+
end
|
151
|
+
else
|
152
|
+
@project.issues.inject(value) do |s, i|
|
153
|
+
s.gsub(/\b#{i.name}\b/, "{issue #{i.id}}")
|
154
|
+
end
|
133
155
|
end
|
134
156
|
end
|
135
157
|
|
136
|
-
def
|
137
|
-
|
138
|
-
|
139
|
-
|
158
|
+
def deserialized_form_of field, value
|
159
|
+
return super unless INTERPOLATED_FIELDS.member? field
|
160
|
+
|
161
|
+
if field == :log_events
|
162
|
+
value.map do |time, who, what, comment|
|
163
|
+
comment = @project.issues.inject(comment) do |s, i|
|
164
|
+
s.gsub(/\{issue #{i.id}\}/, i.name)
|
165
|
+
end.gsub(/\{issue \w+\}/, "[unknown issue]")
|
166
|
+
[time, who, what, comment]
|
167
|
+
end
|
168
|
+
else
|
169
|
+
@project.issues.inject(value) do |s, i|
|
170
|
+
s.gsub(/\{issue #{i.id}\}/, i.name)
|
171
|
+
end.gsub(/\{issue \w+\}/, "[unknown issue]")
|
172
|
+
end
|
140
173
|
end
|
141
174
|
|
142
175
|
## make a unique id
|
@@ -144,8 +177,8 @@ class Issue < ModelObject
|
|
144
177
|
SHA1.hexdigest [Time.now, rand, creation_time, reporter, title, desc].join("\n")
|
145
178
|
end
|
146
179
|
|
147
|
-
def sort_order; [STATUS_SORT_ORDER[
|
148
|
-
def status_widget; STATUS_WIDGET[
|
180
|
+
def sort_order; [STATUS_SORT_ORDER[status], creation_time] end
|
181
|
+
def status_widget; STATUS_WIDGET[status] end
|
149
182
|
|
150
183
|
def status_string; STATUS_STRINGS[status] || status.to_s end
|
151
184
|
def disposition_string; DISPOSITION_STRINGS[disposition] || disposition.to_s end
|
@@ -172,7 +205,7 @@ class Issue < ModelObject
|
|
172
205
|
def change_status to, who, comment
|
173
206
|
raise Error, "unknown status #{to}" unless STATUSES.member? to
|
174
207
|
raise Error, "already marked as #{to}" if status == to
|
175
|
-
log "changed status from #{
|
208
|
+
log "changed status from #{status} to #{to}", who, comment
|
176
209
|
self.status = to
|
177
210
|
end
|
178
211
|
private :change_status
|
@@ -205,6 +238,11 @@ class Issue < ModelObject
|
|
205
238
|
self.release = release.name
|
206
239
|
end
|
207
240
|
|
241
|
+
def assign_to_component component, who, comment
|
242
|
+
log "assigned to component #{component.name} from #{self.component}", who, comment
|
243
|
+
self.component = component.name
|
244
|
+
end
|
245
|
+
|
208
246
|
def unassign who, comment
|
209
247
|
raise Error, "not assigned to a release" unless release
|
210
248
|
log "unassigned from release #{release}", who, comment
|
@@ -212,8 +250,8 @@ class Issue < ModelObject
|
|
212
250
|
end
|
213
251
|
|
214
252
|
def get_type config, project
|
215
|
-
type = ask "Is this a (b)ugfix or a (
|
216
|
-
type
|
253
|
+
type = ask "Is this a (b)ugfix, a (f)eature, or a (t)ask?", :restrict => /^[bft]$/
|
254
|
+
TYPE_LETTER[type]
|
217
255
|
end
|
218
256
|
|
219
257
|
def get_component config, project
|
@@ -245,18 +283,20 @@ end
|
|
245
283
|
class Config < ModelObject
|
246
284
|
field :name, :prompt => "Your name", :default_generator => :get_default_name
|
247
285
|
field :email, :prompt => "Your email address", :default_generator => :get_default_email
|
286
|
+
field :issue_dir, :ask => false, :default => "bugs"
|
248
287
|
|
249
288
|
def user; "#{name} <#{email}>" end
|
250
289
|
|
251
290
|
def get_default_name
|
252
291
|
require 'etc'
|
253
292
|
|
254
|
-
name = Etc.getpwnam(ENV["USER"])
|
293
|
+
name = Etc.getpwnam(ENV["USER"])
|
294
|
+
name = name ? name.gecos.split(/,/).first : ""
|
255
295
|
end
|
256
296
|
|
257
297
|
def get_default_email
|
258
298
|
require 'socket'
|
259
|
-
email = ENV["USER"] + "@" +
|
299
|
+
email = (ENV["USER"] || "") + "@" +
|
260
300
|
begin
|
261
301
|
Socket.gethostbyname(Socket.gethostname).first
|
262
302
|
rescue SocketError
|
data/lib/model.rb
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
require 'yaml'
|
2
|
+
require 'sha1'
|
2
3
|
require "lowline"; include Lowline
|
3
4
|
require "util"
|
4
|
-
require 'sha1'
|
5
5
|
|
6
6
|
class Time
|
7
7
|
alias :old_to_yaml :to_yaml
|
@@ -15,47 +15,83 @@ module Ditz
|
|
15
15
|
class ModelObject
|
16
16
|
class ModelError < StandardError; end
|
17
17
|
|
18
|
+
def initialize
|
19
|
+
@values = {}
|
20
|
+
@serialized_values = {}
|
21
|
+
end
|
22
|
+
|
18
23
|
## yamlability
|
19
24
|
def self.yaml_domain; "ditz.rubyforge.org,2008-03-06" end
|
20
25
|
def self.yaml_other_thing; name.split('::').last.dcfirst end
|
21
26
|
def to_yaml_type; "!#{self.class.yaml_domain}/#{self.class.yaml_other_thing}" end
|
22
|
-
def to_yaml_properties; self.class.fields.map { |f| "@#{f.to_s}" } end
|
23
27
|
def self.inherited subclass
|
24
28
|
YAML.add_domain_type(yaml_domain, subclass.yaml_other_thing) do |type, val|
|
25
|
-
|
29
|
+
o = subclass.new
|
30
|
+
val.each { |k, v| o.send "__serialized_#{k}=", v }
|
31
|
+
o.unchanged!
|
32
|
+
o
|
26
33
|
end
|
27
34
|
end
|
28
|
-
def before_serialize(*a); end
|
29
|
-
def after_deserialize(*a); end
|
30
35
|
|
36
|
+
## override these two to model per-field transformations between disk and
|
37
|
+
## memory.
|
38
|
+
##
|
39
|
+
## convert disk form => memory form
|
40
|
+
def deserialized_form_of field, value
|
41
|
+
@serialized_values[field]
|
42
|
+
end
|
43
|
+
|
44
|
+
## convert memory form => disk form
|
45
|
+
def serialized_form_of field, value
|
46
|
+
@values[field]
|
47
|
+
end
|
48
|
+
|
49
|
+
## add a new field to a model object
|
31
50
|
def self.field name, opts={}
|
32
|
-
@fields ||= [] # can't use a hash because
|
51
|
+
@fields ||= [] # can't use a hash because we need to preserve field order
|
33
52
|
raise ModelError, "field with name #{name} already defined" if @fields.any? { |k, v| k == name }
|
34
53
|
@fields << [name, opts]
|
35
54
|
|
36
|
-
attr_reader name
|
37
55
|
if opts[:multi]
|
38
56
|
single_name = name.to_s.sub(/s$/, "") # oh yeah
|
39
57
|
define_method "add_#{single_name}" do |obj|
|
40
|
-
array =
|
58
|
+
array = send(name)
|
41
59
|
raise ModelError, "already has a #{single_name} with name #{obj.name.inspect}" if obj.respond_to?(:name) && array.any? { |o| o.name == obj.name }
|
42
60
|
changed!
|
61
|
+
@serialized_values.delete name
|
43
62
|
array << obj
|
44
63
|
end
|
45
64
|
|
46
65
|
define_method "drop_#{single_name}" do |obj|
|
47
|
-
return unless
|
66
|
+
return unless @values[name].delete obj
|
67
|
+
@serialized_values.delete name
|
48
68
|
changed!
|
49
69
|
obj
|
50
70
|
end
|
51
71
|
end
|
72
|
+
|
52
73
|
define_method "#{name}=" do |o|
|
53
74
|
changed!
|
54
|
-
|
75
|
+
@serialized_values.delete name
|
76
|
+
@values[name] = o
|
77
|
+
end
|
78
|
+
|
79
|
+
define_method "__serialized_#{name}=" do |o|
|
80
|
+
changed!
|
81
|
+
@values.delete name
|
82
|
+
@serialized_values[name] = o
|
83
|
+
end
|
84
|
+
|
85
|
+
define_method name do
|
86
|
+
return @values[name] if @values.member?(name)
|
87
|
+
@values[name] = deserialized_form_of name, @serialized_values[name]
|
55
88
|
end
|
56
89
|
end
|
57
90
|
|
58
|
-
def self.
|
91
|
+
def self.field_names; @fields.map { |name, opts| name } end
|
92
|
+
class << self
|
93
|
+
attr_reader :fields, :values, :serialized_values
|
94
|
+
end
|
59
95
|
|
60
96
|
def self.changes_are_logged
|
61
97
|
define_method(:changes_are_logged?) { true }
|
@@ -65,9 +101,16 @@ class ModelObject
|
|
65
101
|
def self.from fn
|
66
102
|
returning YAML::load_file(fn) do |o|
|
67
103
|
raise ModelError, "error loading from yaml file #{fn.inspect}: expected a #{self}, got a #{o.class}" unless o.class == self
|
104
|
+
o.pathname = fn if o.respond_to? :pathname=
|
68
105
|
end
|
69
106
|
end
|
70
107
|
|
108
|
+
def to_s
|
109
|
+
"<#{self.class.name}: " + self.class.field_names.map { |f| "#{f}: " + (@values[f].to_s || @serialized_values[f]).inspect }.join(", ") + ">"
|
110
|
+
end
|
111
|
+
|
112
|
+
def inspect; to_s end
|
113
|
+
|
71
114
|
## depth-first search on all reachable ModelObjects. fuck yeah.
|
72
115
|
def each_modelobject
|
73
116
|
seen = {}
|
@@ -76,7 +119,7 @@ class ModelObject
|
|
76
119
|
cur = to_see.pop
|
77
120
|
seen[cur] = true
|
78
121
|
yield cur
|
79
|
-
cur.class.
|
122
|
+
cur.class.field_names.each do |f|
|
80
123
|
val = cur.send(f)
|
81
124
|
next if seen[val]
|
82
125
|
if val.is_a?(ModelObject)
|
@@ -91,15 +134,33 @@ class ModelObject
|
|
91
134
|
def save! fn
|
92
135
|
#FileUtils.mv fn, "#{fn}~", :force => true rescue nil
|
93
136
|
File.open(fn, "w") { |f| f.puts to_yaml }
|
137
|
+
self
|
94
138
|
end
|
95
139
|
|
140
|
+
def to_yaml opts={}
|
141
|
+
YAML::quick_emit(object_id, opts) do |out|
|
142
|
+
out.map(taguri, nil) do |map|
|
143
|
+
self.class.fields.each do |f, fops|
|
144
|
+
v = if @serialized_values.member?(f)
|
145
|
+
@serialized_values[f]
|
146
|
+
else
|
147
|
+
@serialized_values[f] = serialized_form_of f, @values[f]
|
148
|
+
end
|
149
|
+
|
150
|
+
map.add f.to_s, v
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
96
156
|
def log what, who, comment
|
97
|
-
add_log_event([Time.now, who, what, comment])
|
157
|
+
add_log_event([Time.now, who, what, comment || ""])
|
98
158
|
self
|
99
159
|
end
|
100
160
|
|
101
161
|
def changed?; @changed ||= false end
|
102
162
|
def changed!; @changed = true end
|
163
|
+
def unchanged!; @changed = false end
|
103
164
|
|
104
165
|
def self.create_interactively opts={}
|
105
166
|
o = self.new
|
@@ -114,7 +175,7 @@ class ModelObject
|
|
114
175
|
elsif field_opts[:ask] == false # nil counts as true here
|
115
176
|
field_opts[:default] || (field_opts[:multi] ? [] : nil)
|
116
177
|
else
|
117
|
-
q = field_opts[:prompt] || name.to_s.
|
178
|
+
q = field_opts[:prompt] || name.to_s.capitalize
|
118
179
|
if field_opts[:multiline]
|
119
180
|
ask_multiline q
|
120
181
|
else
|