ursm-ditz 0.4 → 0.5
Sign up to get free protection for your applications and to get access to all the features.
- data/Changelog +15 -0
- data/INSTALL +20 -0
- data/LICENSE +674 -0
- data/Manifest.txt +40 -0
- data/PLUGINS.txt +140 -0
- data/README.txt +43 -27
- data/Rakefile +36 -3
- data/ReleaseNotes +6 -0
- data/bin/ditz +49 -63
- data/contrib/completion/ditz.bash +25 -9
- data/lib/ditz.rb +52 -7
- data/lib/ditz/file-storage.rb +54 -0
- data/lib/{hook.rb → ditz/hook.rb} +1 -1
- data/lib/{html.rb → ditz/html.rb} +42 -4
- data/lib/{lowline.rb → ditz/lowline.rb} +31 -11
- data/lib/{model-objects.rb → ditz/model-objects.rb} +53 -21
- data/lib/ditz/model.rb +321 -0
- data/lib/{operator.rb → ditz/operator.rb} +122 -67
- data/lib/ditz/plugins/git-sync.rb +83 -0
- data/lib/{plugins → ditz/plugins}/git.rb +57 -18
- data/lib/ditz/plugins/issue-claiming.rb +174 -0
- data/lib/ditz/plugins/issue-labeling.rb +161 -0
- data/lib/{util.rb → ditz/util.rb} +4 -0
- data/lib/{view.rb → ditz/view.rb} +0 -0
- data/lib/{views.rb → ditz/views.rb} +7 -4
- data/man/{ditz.1 → man1/ditz.1} +1 -1
- data/setup.rb +1585 -0
- data/share/ditz/blue-check.png +0 -0
- data/{lib → share/ditz}/component.rhtml +7 -5
- 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 +50 -28
- data/lib/index.rhtml +0 -113
- data/lib/issue.rhtml +0 -111
- data/lib/issue_table.rhtml +0 -33
- data/lib/model.rb +0 -208
- data/lib/plugins/issue-claiming.rb +0 -92
- data/lib/release.rhtml +0 -69
- data/lib/style.css +0 -127
- data/lib/trollop.rb +0 -518
- data/lib/unassigned.rhtml +0 -31
- data/lib/vendor/yaml_waml.rb +0 -28
data/lib/ditz/model.rb
ADDED
@@ -0,0 +1,321 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require "yaml_waml"
|
3
|
+
require 'sha1'
|
4
|
+
require "ditz/lowline"; include Lowline
|
5
|
+
require "ditz/util"
|
6
|
+
|
7
|
+
class Time
|
8
|
+
alias :old_to_yaml :to_yaml
|
9
|
+
def to_yaml(opts = {})
|
10
|
+
self.utc.old_to_yaml(opts)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
module Ditz
|
15
|
+
|
16
|
+
class ModelObject
|
17
|
+
class ModelError < StandardError; end
|
18
|
+
|
19
|
+
def initialize
|
20
|
+
@values = {}
|
21
|
+
@serialized_values = {}
|
22
|
+
self.class.fields.map { |f, opts| @values[f] = [] if opts[:multi] }
|
23
|
+
end
|
24
|
+
|
25
|
+
## yamlability
|
26
|
+
def self.yaml_domain; "ditz.rubyforge.org,2008-03-06" end
|
27
|
+
def self.yaml_other_thing; name.split('::').last.dcfirst end
|
28
|
+
def to_yaml_type; "!#{self.class.yaml_domain}/#{self.class.yaml_other_thing}" end
|
29
|
+
def self.inherited subclass
|
30
|
+
YAML.add_domain_type(yaml_domain, subclass.yaml_other_thing) do |type, val|
|
31
|
+
o = subclass.new
|
32
|
+
val.each do |k, v|
|
33
|
+
m = "__serialized_#{k}="
|
34
|
+
if o.respond_to? m
|
35
|
+
o.send m, v
|
36
|
+
else
|
37
|
+
$stderr.puts "warning: unknown field #{k.inspect} in YAML for #{type}; ignoring"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
o.class.fields.each do |f, opts|
|
41
|
+
m = "__serialized_#{f}"
|
42
|
+
if opts[:multi] && o.send(m).nil?
|
43
|
+
o.send(m + '=', [])
|
44
|
+
end
|
45
|
+
end
|
46
|
+
o.unchanged!
|
47
|
+
o
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
## override these two to model per-field transformations between disk and
|
52
|
+
## memory.
|
53
|
+
##
|
54
|
+
## convert disk form => memory form
|
55
|
+
def deserialized_form_of field, value
|
56
|
+
value
|
57
|
+
end
|
58
|
+
|
59
|
+
## convert memory form => disk form
|
60
|
+
def serialized_form_of field, value
|
61
|
+
value
|
62
|
+
end
|
63
|
+
|
64
|
+
## Add a field to a model object
|
65
|
+
##
|
66
|
+
## The options you specify here determine how the field is populated when an
|
67
|
+
## instance of this object is created. Objects can be created interactively,
|
68
|
+
## with #create_interactively, or non-interactively, with #create, and the
|
69
|
+
## creation mode, combined with these options, determine how the field is
|
70
|
+
## populated on a new model object.
|
71
|
+
##
|
72
|
+
## The default behavior is to simply prompt the user with the field name when
|
73
|
+
## in interactive mode, and to raise an exception if the value is not passed
|
74
|
+
## to #create in non-interactive mode.
|
75
|
+
##
|
76
|
+
## Options:
|
77
|
+
## :interactive_generator => a method name or Proc that will be called to
|
78
|
+
## return the value of this field, if the model object is created
|
79
|
+
## interactively.
|
80
|
+
## :generator => a method name or Proc that will be called to return the
|
81
|
+
## value of this field. If the model object is created interactively, and
|
82
|
+
## a :interactive_generator option is specified, that will be used instead.
|
83
|
+
## :multi => a boolean determining whether the field has multiple values,
|
84
|
+
## i.e., is an array. If created with :ask => false, will be initialized
|
85
|
+
## to [] instead of to nil. Additionally, the model object will have
|
86
|
+
## #add_<field> and #drop_<field> methods.
|
87
|
+
## :ask => a boolean determining whether, if the model object is created
|
88
|
+
## interactively, the user will be prompted for the value of this field.
|
89
|
+
## TRUE BY DEFAULT. If :interactive_generator or :generator are specified,
|
90
|
+
## those will be called instead.
|
91
|
+
##
|
92
|
+
## If this is true, non-interactive creation
|
93
|
+
## will raise an exception unless the field value is passed as an argument.
|
94
|
+
## If this is false, non-interactive creation will initialize this to nil
|
95
|
+
## (or [] if this field is additionally marked :multi) unless the value is
|
96
|
+
## passed as an argument.
|
97
|
+
## :prompt => a string to display to the user when prompting for the field
|
98
|
+
## value during interactive creation. Not used if :generator or
|
99
|
+
## :interactive_generator is specified.
|
100
|
+
## :multiline => a boolean determining whether to prompt the user for a
|
101
|
+
## multiline answer during interactive creation. Default false. Not used
|
102
|
+
## if :generator or :interactive_generator is specified.
|
103
|
+
## :default => a default value when prompting for the field value during
|
104
|
+
## interactive creation. Not used if :generator, :interactive_generator,
|
105
|
+
## :multiline, or :default_generator is specified.
|
106
|
+
## :default_generator => a method name or Proc which will be called to
|
107
|
+
## generate the default value when prompting for the field value during
|
108
|
+
## interactive creation. Not used if :generator, :interactive_generator,
|
109
|
+
## or :multiline is specified.
|
110
|
+
## :nil_ok => a boolean determining whether, if created in non-interactive
|
111
|
+
## mode and the value for this field is not passed in, (or is passed in
|
112
|
+
## as nil), that's ok. Default is false. This is not necessary if :ask =>
|
113
|
+
## false is specified; it's only necessary for fields that you want an
|
114
|
+
## interactive prompt for, but a nil value is fine.
|
115
|
+
def self.field name, opts={}
|
116
|
+
@fields ||= [] # can't use a hash because we need to preserve field order
|
117
|
+
raise ModelError, "field with name #{name} already defined" if @fields.any? { |k, v| k == name }
|
118
|
+
@fields << [name, opts]
|
119
|
+
|
120
|
+
if opts[:multi]
|
121
|
+
single_name = name.to_s.sub(/s$/, "") # oh yeah
|
122
|
+
define_method "add_#{single_name}" do |obj|
|
123
|
+
array = send(name)
|
124
|
+
raise ModelError, "already has a #{single_name} with name #{obj.name.inspect}" if obj.respond_to?(:name) && array.any? { |o| o.name == obj.name }
|
125
|
+
changed!
|
126
|
+
@serialized_values.delete name
|
127
|
+
array << obj
|
128
|
+
end
|
129
|
+
|
130
|
+
define_method "drop_#{single_name}" do |obj|
|
131
|
+
return unless send(name).delete obj
|
132
|
+
@serialized_values.delete name
|
133
|
+
changed!
|
134
|
+
obj
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
define_method "#{name}=" do |o|
|
139
|
+
changed!
|
140
|
+
@serialized_values.delete name
|
141
|
+
@values[name] = o
|
142
|
+
end
|
143
|
+
|
144
|
+
define_method "__serialized_#{name}=" do |o|
|
145
|
+
changed!
|
146
|
+
@values.delete name
|
147
|
+
@serialized_values[name] = o
|
148
|
+
end
|
149
|
+
|
150
|
+
define_method "__serialized_#{name}" do
|
151
|
+
@serialized_values[name]
|
152
|
+
end
|
153
|
+
|
154
|
+
define_method name do
|
155
|
+
return @values[name] if @values.member?(name)
|
156
|
+
@values[name] = deserialized_form_of name, @serialized_values[name]
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def self.field_names; @fields.map { |name, opts| name } end
|
161
|
+
class << self
|
162
|
+
attr_reader :fields, :values, :serialized_values
|
163
|
+
end
|
164
|
+
|
165
|
+
def self.changes_are_logged
|
166
|
+
define_method(:changes_are_logged?) { true }
|
167
|
+
field :log_events, :multi => true, :ask => false
|
168
|
+
end
|
169
|
+
|
170
|
+
def self.from fn
|
171
|
+
returning YAML::load_file(fn) do |o|
|
172
|
+
raise ModelError, "error loading from yaml file #{fn.inspect}: expected a #{self}, got a #{o.class}" unless o.class == self
|
173
|
+
o.pathname = fn if o.respond_to? :pathname=
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
def to_s
|
178
|
+
"<#{self.class.name}: " + self.class.field_names.map { |f| "#{f}: " + send(f).inspect }.join(", ") + ">"
|
179
|
+
end
|
180
|
+
|
181
|
+
def inspect; to_s end
|
182
|
+
|
183
|
+
## depth-first search on all reachable ModelObjects. fuck yeah.
|
184
|
+
def each_modelobject
|
185
|
+
seen = {}
|
186
|
+
to_see = [self]
|
187
|
+
until to_see.empty?
|
188
|
+
cur = to_see.pop
|
189
|
+
seen[cur] = true
|
190
|
+
yield cur
|
191
|
+
cur.class.field_names.each do |f|
|
192
|
+
val = cur.send(f)
|
193
|
+
next if seen[val]
|
194
|
+
if val.is_a?(ModelObject)
|
195
|
+
to_see.push val
|
196
|
+
elsif val.is_a?(Array)
|
197
|
+
to_see += val.select { |v| v.is_a?(ModelObject) }
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
def save! fn
|
204
|
+
#FileUtils.mv fn, "#{fn}~", :force => true rescue nil
|
205
|
+
File.open(fn, "w") { |f| f.puts to_yaml }
|
206
|
+
self
|
207
|
+
end
|
208
|
+
|
209
|
+
def to_yaml opts={}
|
210
|
+
ret = YAML::quick_emit(object_id, opts) do |out|
|
211
|
+
out.map(taguri, nil) do |map|
|
212
|
+
self.class.fields.each do |f, fops|
|
213
|
+
v = if @serialized_values.member?(f)
|
214
|
+
@serialized_values[f]
|
215
|
+
else
|
216
|
+
@serialized_values[f] = serialized_form_of f, @values[f]
|
217
|
+
end
|
218
|
+
|
219
|
+
map.add f.to_s, v
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
YamlWaml.decode(ret)
|
224
|
+
end
|
225
|
+
|
226
|
+
def log what, who, comment
|
227
|
+
add_log_event([Time.now, who, what, comment || ""])
|
228
|
+
self
|
229
|
+
end
|
230
|
+
|
231
|
+
def changed?; @changed ||= false end
|
232
|
+
def changed!; @changed = true end
|
233
|
+
def unchanged!; @changed = false end
|
234
|
+
|
235
|
+
class << self
|
236
|
+
## creates the object, prompting the user when necessary. can take
|
237
|
+
## a :with => { hash } parameter for pre-filling model fields.
|
238
|
+
##
|
239
|
+
## can also take a :defaults_from => obj parameter for pre-filling model
|
240
|
+
## fields from another object with (some of) those fields. kinda like a
|
241
|
+
## bizarre interactive copy constructor.
|
242
|
+
def create_interactively opts={}
|
243
|
+
o = self.new
|
244
|
+
generator_args = opts[:args] || []
|
245
|
+
@fields.each do |name, field_opts|
|
246
|
+
val = if opts[:with] && opts[:with][name]
|
247
|
+
opts[:with][name]
|
248
|
+
elsif(found, v = generate_field_value(o, field_opts, generator_args, :interactive => true)) && found
|
249
|
+
v
|
250
|
+
else
|
251
|
+
q = field_opts[:prompt] || name.to_s.capitalize
|
252
|
+
if field_opts[:multiline]
|
253
|
+
## multiline options currently aren't allowed to have a default
|
254
|
+
## value, so just ask.
|
255
|
+
ask_multiline_smartly q
|
256
|
+
else
|
257
|
+
default = if opts[:defaults_from] && opts[:defaults_from].respond_to?(name) && (x = opts[:defaults_from].send(name))
|
258
|
+
x
|
259
|
+
else
|
260
|
+
default = generate_field_default o, field_opts, generator_args
|
261
|
+
end
|
262
|
+
ask q, :default => default
|
263
|
+
end
|
264
|
+
end
|
265
|
+
o.send "#{name}=", val
|
266
|
+
end
|
267
|
+
o
|
268
|
+
end
|
269
|
+
|
270
|
+
## creates the object, filling in fields from 'vals', and throwing a
|
271
|
+
## ModelError when it can't find all the requisite fields
|
272
|
+
def create vals={}, generator_args=[]
|
273
|
+
o = self.new
|
274
|
+
@fields.each do |fname, fopts|
|
275
|
+
val = if(x = vals[fname] || vals[fname.to_s])
|
276
|
+
x
|
277
|
+
elsif(found, x = generate_field_value(o, fopts, generator_args, :interactive => false)) && found
|
278
|
+
x
|
279
|
+
elsif !fopts[:nil_ok]
|
280
|
+
raise ModelError, "missing required field #{fname.inspect} on #{self.name} object (got #{vals.keys.inspect})"
|
281
|
+
end
|
282
|
+
o.send "#{fname}=", val if val
|
283
|
+
end
|
284
|
+
o
|
285
|
+
end
|
286
|
+
|
287
|
+
private
|
288
|
+
|
289
|
+
## get the value for a field if it can be automatically determined
|
290
|
+
## returns [success, value] (because a successful value can be nil)
|
291
|
+
def generate_field_value o, field_opts, args, opts={}
|
292
|
+
gen = if opts[:interactive]
|
293
|
+
field_opts[:interactive_generator] || field_opts[:generator]
|
294
|
+
else
|
295
|
+
field_opts[:generator]
|
296
|
+
end
|
297
|
+
|
298
|
+
if gen.is_a? Proc
|
299
|
+
[true, gen.call(*args)]
|
300
|
+
elsif gen
|
301
|
+
[true, o.send(gen, *args)]
|
302
|
+
elsif field_opts[:ask] == false # nil counts as true here
|
303
|
+
[true, field_opts[:default] || (field_opts[:multi] ? [] : nil)]
|
304
|
+
else
|
305
|
+
[false, nil]
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
def generate_field_default o, field_opts, args
|
310
|
+
if field_opts[:default_generator].is_a? Proc
|
311
|
+
field_opts[:default_generator].call(*args)
|
312
|
+
elsif field_opts[:default_generator]
|
313
|
+
o.send field_opts[:default_generator], *args
|
314
|
+
elsif field_opts[:default]
|
315
|
+
field_opts[:default]
|
316
|
+
end
|
317
|
+
end
|
318
|
+
end
|
319
|
+
end
|
320
|
+
|
321
|
+
end
|
@@ -134,8 +134,9 @@ class Operator
|
|
134
134
|
if opts[:cow]
|
135
135
|
puts "MOO!"
|
136
136
|
puts "All is well with the world now. A bit more methane though."
|
137
|
-
|
137
|
+
return
|
138
138
|
end
|
139
|
+
run_pager
|
139
140
|
return help_single(command) if command
|
140
141
|
puts <<EOS
|
141
142
|
Ditz commands:
|
@@ -170,14 +171,35 @@ Usage: ditz #{name} #{args}
|
|
170
171
|
EOS
|
171
172
|
end
|
172
173
|
|
173
|
-
|
174
|
-
def
|
174
|
+
## gets a comment from the user, assuming the standard argument setup
|
175
|
+
def get_comment opts
|
176
|
+
comment = if opts[:no_comment]
|
177
|
+
nil
|
178
|
+
elsif opts[:comment]
|
179
|
+
opts[:comment]
|
180
|
+
else
|
181
|
+
ask_multiline_smartly "Comments"
|
182
|
+
end
|
183
|
+
end
|
184
|
+
private :get_comment
|
185
|
+
|
186
|
+
operation :reconfigure, "Rerun configuration script"
|
187
|
+
def reconfigure project, config
|
188
|
+
new_config = Config.create_interactively :defaults_from => config
|
189
|
+
new_config.save! $opts[:config_file]
|
190
|
+
puts "Configuration written."
|
191
|
+
end
|
192
|
+
|
193
|
+
operation :add, "Add an issue" do
|
194
|
+
opt :comment, "Specify a comment", :short => 'm', :type => String
|
195
|
+
opt :no_comment, "Skip asking for a comment", :default => false
|
196
|
+
end
|
197
|
+
def add project, config, opts
|
175
198
|
issue = Issue.create_interactively(:args => [config, project]) or return
|
176
|
-
|
177
|
-
issue.log "created", config.user, comment
|
199
|
+
issue.log "created", config.user, get_comment(opts)
|
178
200
|
project.add_issue issue
|
179
201
|
project.assign_issue_names!
|
180
|
-
puts "Added issue #{issue.name}."
|
202
|
+
puts "Added issue #{issue.name} (#{issue.id})."
|
181
203
|
end
|
182
204
|
|
183
205
|
operation :drop, "Drop an issue", :issue
|
@@ -186,12 +208,14 @@ EOS
|
|
186
208
|
puts "Dropped #{issue.name}. Note that other issue names may have changed."
|
187
209
|
end
|
188
210
|
|
189
|
-
operation :add_release, "Add a release", :maybe_name
|
190
|
-
|
211
|
+
operation :add_release, "Add a release", :maybe_name do
|
212
|
+
opt :comment, "Specify a comment", :short => 'm', :type => String
|
213
|
+
opt :no_comment, "Skip asking for a comment", :default => false
|
214
|
+
end
|
215
|
+
def add_release project, config, opts, maybe_name
|
191
216
|
puts "Adding release #{maybe_name}." if maybe_name
|
192
217
|
release = Release.create_interactively(:args => [project, config], :with => { :name => maybe_name }) or return
|
193
|
-
|
194
|
-
release.log "created", config.user, comment
|
218
|
+
release.log "created", config.user, get_comment(opts)
|
195
219
|
project.add_release release
|
196
220
|
puts "Added release #{release.name}."
|
197
221
|
end
|
@@ -203,18 +227,21 @@ EOS
|
|
203
227
|
puts "Added component #{component.name}."
|
204
228
|
end
|
205
229
|
|
206
|
-
operation :add_reference, "Add a reference to an issue", :issue
|
207
|
-
|
230
|
+
operation :add_reference, "Add a reference to an issue", :issue do
|
231
|
+
opt :comment, "Specify a comment", :short => 'm', :type => String
|
232
|
+
opt :no_comment, "Skip asking for a comment", :default => false
|
233
|
+
end
|
234
|
+
def add_reference project, config, opts, issue
|
208
235
|
puts "Adding a reference to #{issue.name}: #{issue.title}."
|
209
236
|
reference = ask "Reference"
|
210
|
-
comment = ask_multiline "Comments" unless $opts[:no_comment]
|
211
237
|
issue.add_reference reference
|
212
|
-
issue.log "added reference #{issue.references.size}", config.user,
|
238
|
+
issue.log "added reference #{issue.references.size}", config.user, get_comment(opts)
|
213
239
|
puts "Added reference to #{issue.name}."
|
214
240
|
end
|
215
241
|
|
216
242
|
operation :status, "Show project status", :maybe_release
|
217
243
|
def status project, config, releases
|
244
|
+
run_pager
|
218
245
|
releases ||= project.unreleased_releases + [:unassigned]
|
219
246
|
|
220
247
|
if releases.empty?
|
@@ -233,7 +260,9 @@ EOS
|
|
233
260
|
"%2d/%2d %s" % [nc, num, type.to_s.pluralize(num, false)]
|
234
261
|
end
|
235
262
|
|
236
|
-
bar = if r
|
263
|
+
bar = if r == :unassigned
|
264
|
+
""
|
265
|
+
elsif r.released?
|
237
266
|
"(released)"
|
238
267
|
elsif issues.empty?
|
239
268
|
"(no issues)"
|
@@ -273,23 +302,25 @@ EOS
|
|
273
302
|
join
|
274
303
|
end
|
275
304
|
|
276
|
-
def todo_list_for issues
|
305
|
+
def todo_list_for issues, opts={}
|
277
306
|
return if issues.empty?
|
278
307
|
name_len = issues.max_of { |i| i.name.length }
|
279
308
|
issues.map do |i|
|
280
|
-
sprintf "%s %#{name_len}s: %s
|
309
|
+
s = sprintf "%s %#{name_len}s: %s", i.status_widget, i.name, i.title
|
310
|
+
s += " [#{i.release}]" if opts[:show_release] && i.release
|
311
|
+
s + "\n"
|
281
312
|
end.join
|
282
313
|
end
|
283
314
|
|
284
315
|
def print_todo_list_by_release_for project, issues
|
285
316
|
by_release = issues.inject({}) do |h, i|
|
286
|
-
r = project.release_for
|
317
|
+
r = project.release_for(i.release) || :unassigned
|
287
318
|
h[r] ||= []
|
288
319
|
h[r] << i
|
289
320
|
h
|
290
321
|
end
|
291
322
|
|
292
|
-
project.releases.each do |r|
|
323
|
+
(project.releases + [:unassigned]).each do |r|
|
293
324
|
next unless by_release.member? r
|
294
325
|
puts r == :unassigned ? "Unassigned:" : "#{r.name} (#{r.status}):"
|
295
326
|
print todo_list_for(by_release[r])
|
@@ -297,17 +328,15 @@ EOS
|
|
297
328
|
end
|
298
329
|
end
|
299
330
|
|
300
|
-
operation :todo, "Generate todo list", :maybe_release
|
301
|
-
|
302
|
-
actually_do_todo project, config, releases, false
|
331
|
+
operation :todo, "Generate todo list", :maybe_release do
|
332
|
+
opt :all, "Show all issues, included completed ones", :default => false
|
303
333
|
end
|
304
|
-
|
305
|
-
|
306
|
-
def todo_full project, config, releases
|
307
|
-
actually_do_todo project, config, releases, true
|
334
|
+
def todo project, config, opts, releases
|
335
|
+
actually_do_todo project, config, releases, opts[:all]
|
308
336
|
end
|
309
337
|
|
310
338
|
def actually_do_todo project, config, releases, full
|
339
|
+
run_pager
|
311
340
|
releases ||= project.unreleased_releases + [:unassigned]
|
312
341
|
releases = [*releases]
|
313
342
|
releases.each do |r|
|
@@ -324,33 +353,42 @@ EOS
|
|
324
353
|
ScreenView.new(project, config).render_issue issue
|
325
354
|
end
|
326
355
|
|
327
|
-
operation :start, "Start work on an issue", :unstarted_issue
|
328
|
-
|
356
|
+
operation :start, "Start work on an issue", :unstarted_issue do
|
357
|
+
opt :comment, "Specify a comment", :short => 'm', :type => String
|
358
|
+
opt :no_comment, "Skip asking for a comment", :default => false
|
359
|
+
end
|
360
|
+
def start project, config, opts, issue
|
329
361
|
puts "Starting work on issue #{issue.name}: #{issue.title}."
|
330
|
-
|
331
|
-
issue.start_work config.user, comment
|
362
|
+
issue.start_work config.user, get_comment(opts)
|
332
363
|
puts "Recorded start of work for #{issue.name}."
|
333
364
|
end
|
334
365
|
|
335
|
-
operation :stop, "Stop work on an issue", :started_issue
|
336
|
-
|
366
|
+
operation :stop, "Stop work on an issue", :started_issue do
|
367
|
+
opt :comment, "Specify a comment", :short => 'm', :type => String
|
368
|
+
opt :no_comment, "Skip asking for a comment", :default => false
|
369
|
+
end
|
370
|
+
def stop project, config, opts, issue
|
337
371
|
puts "Stopping work on issue #{issue.name}: #{issue.title}."
|
338
|
-
|
339
|
-
issue.stop_work config.user, comment
|
372
|
+
issue.stop_work config.user, get_comment(opts)
|
340
373
|
puts "Recorded work stop for #{issue.name}."
|
341
374
|
end
|
342
375
|
|
343
|
-
operation :close, "Close an issue", :open_issue
|
344
|
-
|
376
|
+
operation :close, "Close an issue", :open_issue do
|
377
|
+
opt :comment, "Specify a comment", :short => 'm', :type => String
|
378
|
+
opt :no_comment, "Skip asking for a comment", :default => false
|
379
|
+
end
|
380
|
+
def close project, config, opts, issue
|
345
381
|
puts "Closing issue #{issue.name}: #{issue.title}."
|
346
382
|
disp = ask_for_selection Issue::DISPOSITIONS, "disposition", lambda { |x| Issue::DISPOSITION_STRINGS[x] || x.to_s }
|
347
|
-
|
348
|
-
issue.close disp, config.user, comment
|
383
|
+
issue.close disp, config.user, get_comment(opts)
|
349
384
|
puts "Closed issue #{issue.name} with disposition #{issue.disposition_string}."
|
350
385
|
end
|
351
386
|
|
352
|
-
operation :assign, "Assign an issue to a release", :issue, :maybe_release
|
353
|
-
|
387
|
+
operation :assign, "Assign an issue to a release", :issue, :maybe_release do
|
388
|
+
opt :comment, "Specify a comment", :short => 'm', :type => String
|
389
|
+
opt :no_comment, "Skip asking for a comment", :default => false
|
390
|
+
end
|
391
|
+
def assign project, config, opts, issue, maybe_release
|
354
392
|
if maybe_release && maybe_release.name == issue.release
|
355
393
|
raise Error, "issue #{issue.name} already assigned to release #{issue.release}"
|
356
394
|
end
|
@@ -361,8 +399,6 @@ EOS
|
|
361
399
|
"not assigned to any release."
|
362
400
|
end
|
363
401
|
|
364
|
-
puts "Assigning to release #{maybe_release.name}." if maybe_release
|
365
|
-
|
366
402
|
release = maybe_release || begin
|
367
403
|
releases = project.releases.sort_by { |r| (r.release_time || 0).to_i }
|
368
404
|
releases -= [releases.find { |r| r.name == issue.release }] if issue.release
|
@@ -374,13 +410,15 @@ EOS
|
|
374
410
|
end
|
375
411
|
end
|
376
412
|
end
|
377
|
-
|
378
|
-
issue.assign_to_release release, config.user, comment
|
413
|
+
issue.assign_to_release release, config.user, get_comment(opts)
|
379
414
|
puts "Assigned #{issue.name} to #{release.name}."
|
380
415
|
end
|
381
416
|
|
382
|
-
operation :set_component, "Set an issue's component", :issue, :maybe_component
|
383
|
-
|
417
|
+
operation :set_component, "Set an issue's component", :issue, :maybe_component do
|
418
|
+
opt :comment, "Specify a comment", :short => 'm', :type => String
|
419
|
+
opt :no_comment, "Skip asking for a comment", :default => false
|
420
|
+
end
|
421
|
+
def set_component project, config, opts, issue, maybe_component
|
384
422
|
puts "Changing the component of issue #{issue.name}: #{issue.title}."
|
385
423
|
|
386
424
|
if project.components.size == 1
|
@@ -396,8 +434,7 @@ EOS
|
|
396
434
|
components -= [components.find { |r| r.name == issue.component }] if issue.component
|
397
435
|
ask_for_selection(components, "component") { |r| r.name }
|
398
436
|
end
|
399
|
-
|
400
|
-
issue.assign_to_component component, config.user, comment
|
437
|
+
issue.assign_to_component component, config.user, get_comment(opts)
|
401
438
|
oldname = issue.name
|
402
439
|
project.assign_issue_names!
|
403
440
|
puts <<EOS
|
@@ -406,18 +443,23 @@ have changed as well.
|
|
406
443
|
EOS
|
407
444
|
end
|
408
445
|
|
409
|
-
operation :unassign, "Unassign an issue from any releases", :assigned_issue
|
410
|
-
|
446
|
+
operation :unassign, "Unassign an issue from any releases", :assigned_issue do
|
447
|
+
opt :comment, "Specify a comment", :short => 'm', :type => String
|
448
|
+
opt :no_comment, "Skip asking for a comment", :default => false
|
449
|
+
end
|
450
|
+
def unassign project, config, opts, issue
|
411
451
|
puts "Unassigning issue #{issue.name}: #{issue.title}."
|
412
|
-
|
413
|
-
issue.unassign config.user, comment
|
452
|
+
issue.unassign config.user, get_comment(opts)
|
414
453
|
puts "Unassigned #{issue.name}."
|
415
454
|
end
|
416
455
|
|
417
|
-
operation :comment, "Comment on an issue", :issue
|
418
|
-
|
456
|
+
operation :comment, "Comment on an issue", :issue do
|
457
|
+
opt :comment, "Specify a comment", :short => 'm', :type => String
|
458
|
+
opt :no_comment, "Skip asking for a comment", :default => false
|
459
|
+
end
|
460
|
+
def comment project, config, opts, issue
|
419
461
|
puts "Commenting on issue #{issue.name}: #{issue.title}."
|
420
|
-
comment =
|
462
|
+
comment = get_comment opts
|
421
463
|
if comment.blank?
|
422
464
|
puts "Empty comment, aborted."
|
423
465
|
else
|
@@ -428,6 +470,7 @@ EOS
|
|
428
470
|
|
429
471
|
operation :releases, "Show releases"
|
430
472
|
def releases project, config
|
473
|
+
run_pager
|
431
474
|
a, b = project.releases.partition { |r| r.released? }
|
432
475
|
(b + a.sort_by { |r| r.release_time }).each do |r|
|
433
476
|
status = r.released? ? "released #{r.release_time.pretty_date}" : r.status
|
@@ -435,15 +478,18 @@ EOS
|
|
435
478
|
end
|
436
479
|
end
|
437
480
|
|
438
|
-
operation :release, "Release a release", :unreleased_release
|
439
|
-
|
440
|
-
|
441
|
-
|
481
|
+
operation :release, "Release a release", :unreleased_release do
|
482
|
+
opt :comment, "Specify a comment", :short => 'm', :type => String
|
483
|
+
opt :no_comment, "Skip asking for a comment", :default => false
|
484
|
+
end
|
485
|
+
def release project, config, opts, release
|
486
|
+
release.release! project, config.user, get_comment(opts)
|
442
487
|
puts "Release #{release.name} released!"
|
443
488
|
end
|
444
489
|
|
445
490
|
operation :changelog, "Generate a changelog for a release", :release
|
446
491
|
def changelog project, config, r
|
492
|
+
run_pager
|
447
493
|
puts "== #{r.name} / #{r.released? ? r.release_time.pretty_date : 'unreleased'}"
|
448
494
|
project.group_issues(project.issues_for_release(r)).each do |type, issues|
|
449
495
|
issues.select { |i| i.closed? }.each do |i|
|
@@ -467,9 +513,13 @@ EOS
|
|
467
513
|
## a no-op
|
468
514
|
end
|
469
515
|
|
470
|
-
operation :grep, "Show issues matching a string or regular expression", :string
|
471
|
-
|
472
|
-
|
516
|
+
operation :grep, "Show issues matching a string or regular expression", :string do
|
517
|
+
opt :ignore_case, "Ignore case distinctions in both the expression and in the issue data", :default => false
|
518
|
+
end
|
519
|
+
|
520
|
+
def grep project, config, opts, match
|
521
|
+
run_pager
|
522
|
+
re = Regexp.new match, opts[:ignore_case]
|
473
523
|
issues = project.issues.select do |i|
|
474
524
|
i.title =~ re || i.desc =~ re ||
|
475
525
|
i.log_events.map { |time, who, what, comments| comments }.join(" ") =~ re
|
@@ -479,6 +529,7 @@ EOS
|
|
479
529
|
|
480
530
|
operation :log, "Show recent activity"
|
481
531
|
def log project, config
|
532
|
+
run_pager
|
482
533
|
project.issues.map { |i| i.log_events.map { |e| [e, i] } }.
|
483
534
|
flatten_one_level.sort_by { |e| e.first.first }.reverse.
|
484
535
|
each do |(date, author, what, comment), i|
|
@@ -496,6 +547,7 @@ EOS
|
|
496
547
|
|
497
548
|
operation :shortlog, "Show recent activity (short form)"
|
498
549
|
def shortlog project, config
|
550
|
+
run_pager
|
499
551
|
project.issues.map { |i| i.log_events.map { |e| [e, i] } }.
|
500
552
|
flatten_one_level.sort_by { |e| e.first.first }.reverse.
|
501
553
|
each do |(date, author, what, comment), i|
|
@@ -521,8 +573,12 @@ EOS
|
|
521
573
|
puts "Archived to #{dir}."
|
522
574
|
end
|
523
575
|
|
524
|
-
operation :edit, "Edit an issue", :issue
|
525
|
-
|
576
|
+
operation :edit, "Edit an issue", :issue do
|
577
|
+
opt :comment, "Specify a comment", :short => 'm', :type => String
|
578
|
+
opt :no_comment, "Skip asking for a comment", :default => false
|
579
|
+
opt :silent, "Don't add a log message detailing the change", :default => false
|
580
|
+
end
|
581
|
+
def edit project, config, opts, issue
|
526
582
|
data = { :title => issue.title, :description => issue.desc,
|
527
583
|
:reporter => issue.reporter }
|
528
584
|
|
@@ -533,11 +589,10 @@ EOS
|
|
533
589
|
return
|
534
590
|
end
|
535
591
|
|
536
|
-
comment = ask_multiline "Comments" unless $opts[:no_comment]
|
537
|
-
|
538
592
|
begin
|
539
593
|
edits = YAML.load_file fn
|
540
|
-
|
594
|
+
comment = opts[:silent] ? nil : get_comment(opts)
|
595
|
+
if issue.change edits, config.user, comment, opts[:silent]
|
541
596
|
puts "Change recorded."
|
542
597
|
else
|
543
598
|
puts "No changes."
|