ursm-ditz 0.4 → 0.5
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 +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."
|