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.
- data/Changelog +76 -0
- data/INSTALL +20 -0
- data/LICENSE +674 -0
- data/Manifest.txt +48 -0
- data/PLUGINS.txt +197 -0
- data/README.txt +146 -0
- data/Rakefile +66 -0
- data/ReleaseNotes +56 -0
- data/bin/ditz +230 -0
- data/contrib/completion/_ditz.zsh +29 -0
- data/contrib/completion/ditz.bash +38 -0
- data/lib/ditz/file-storage.rb +53 -0
- data/lib/ditz/hook.rb +67 -0
- data/lib/ditz/html.rb +107 -0
- data/lib/ditz/lowline.rb +244 -0
- data/lib/ditz/model-objects.rb +379 -0
- data/lib/ditz/model.rb +339 -0
- data/lib/ditz/operator.rb +655 -0
- data/lib/ditz/plugins/git-sync.rb +83 -0
- data/lib/ditz/plugins/git.rb +153 -0
- data/lib/ditz/plugins/issue-claiming.rb +193 -0
- data/lib/ditz/plugins/issue-labeling.rb +170 -0
- data/lib/ditz/util.rb +61 -0
- data/lib/ditz/view.rb +16 -0
- data/lib/ditz/views.rb +191 -0
- data/lib/ditz.rb +110 -0
- data/man/man1/ditz.1 +38 -0
- data/setup.rb +1585 -0
- data/share/ditz/blue-check.png +0 -0
- data/share/ditz/component.rhtml +24 -0
- data/share/ditz/green-bar.png +0 -0
- data/share/ditz/green-check.png +0 -0
- data/share/ditz/index.rhtml +130 -0
- data/share/ditz/issue.rhtml +119 -0
- data/share/ditz/issue_table.rhtml +28 -0
- data/share/ditz/red-check.png +0 -0
- data/share/ditz/release.rhtml +98 -0
- data/share/ditz/style.css +226 -0
- data/share/ditz/unassigned.rhtml +23 -0
- data/share/ditz/yellow-bar.png +0 -0
- metadata +116 -0
data/lib/ditz/lowline.rb
ADDED
@@ -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
|