taco_it 1.3.1 → 1.4.0
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/bin/taco +4 -743
- data/lib/taco/change.rb +75 -0
- data/lib/taco/cli.rb +107 -0
- data/lib/taco/defaults/tacorc +10 -0
- data/lib/taco/issue.rb +240 -0
- data/lib/taco/schema.rb +208 -0
- data/lib/taco/taco.rb +158 -0
- data/lib/taco/tacorc.rb +24 -0
- data/lib/taco.rb +4 -922
- metadata +10 -3
data/bin/taco
CHANGED
@@ -1,749 +1,10 @@
|
|
1
1
|
#!/bin/env ruby
|
2
2
|
|
3
|
-
require '
|
4
|
-
require 'digest'
|
5
|
-
require 'tempfile'
|
6
|
-
require 'fileutils'
|
7
|
-
require 'securerandom'
|
8
|
-
require 'time'
|
9
|
-
|
10
|
-
def timescrub(t)
|
11
|
-
# Time objects have sub-second precision. Unfortunately, this precision is lost when we serialize. What this means
|
12
|
-
# is that the following code will fail, most unexpectedly:
|
13
|
-
#
|
14
|
-
# i0 = Issue.new some_attributes
|
15
|
-
# i1 = Issue.from_json(i0.to_json)
|
16
|
-
# i0.created_at == i1.created_at # this will be false!
|
17
|
-
#
|
18
|
-
Time.new t.year, t.mon, t.day, t.hour, t.min, t.sec, t.utc_offset
|
19
|
-
end
|
20
|
-
|
21
|
-
# it's rude to pollute the global namespace, but here we go.
|
22
|
-
#
|
23
|
-
def date(t)
|
24
|
-
t.strftime "%Y/%m/%d %H:%M:%S"
|
25
|
-
end
|
26
|
-
|
27
|
-
class Change
|
28
|
-
class Invalid < Exception; end
|
29
|
-
|
30
|
-
attr_reader :created_at
|
31
|
-
attr_accessor :attribute
|
32
|
-
attr_accessor :old_value
|
33
|
-
attr_accessor :new_value
|
34
|
-
|
35
|
-
def initialize(args={})
|
36
|
-
args.each do |attr, value|
|
37
|
-
raise ArgumentError.new("Unknown attribute #{attr}") unless self.respond_to?(attr)
|
38
|
-
|
39
|
-
case attr.to_sym
|
40
|
-
when :created_at
|
41
|
-
value = Time.parse(value) unless value.is_a?(Time)
|
42
|
-
when :attribute
|
43
|
-
value = value.to_sym
|
44
|
-
end
|
45
|
-
|
46
|
-
instance_variable_set("@#{attr.to_s}", value)
|
47
|
-
end
|
48
|
-
|
49
|
-
@created_at = Time.parse(@created_at) if @created_at.is_a?(String)
|
50
|
-
@created_at = timescrub(@created_at || Time.now)
|
51
|
-
|
52
|
-
self
|
53
|
-
end
|
54
|
-
|
55
|
-
def self.from_json(the_json)
|
56
|
-
begin
|
57
|
-
hash = JSON.parse(the_json)
|
58
|
-
rescue JSON::ParserError => e
|
59
|
-
raise Change::Invalid.new(e.to_s)
|
60
|
-
end
|
61
|
-
|
62
|
-
Change.new(hash)
|
63
|
-
end
|
64
|
-
|
65
|
-
def valid?(opts={})
|
66
|
-
# old_value is optional!
|
67
|
-
#
|
68
|
-
valid = created_at && attribute && new_value
|
69
|
-
raise Invalid if opts[:raise] && !valid
|
70
|
-
valid
|
71
|
-
end
|
72
|
-
|
73
|
-
def to_json(state=nil)
|
74
|
-
valid? :raise => true
|
75
|
-
hash = { :created_at => created_at, :attribute => attribute, :old_value => old_value, :new_value => new_value }
|
76
|
-
JSON.pretty_generate(hash)
|
77
|
-
end
|
78
|
-
|
79
|
-
def to_s(opts={})
|
80
|
-
if opts[:simple]
|
81
|
-
"#{attribute} : #{old_value} => #{new_value}"
|
82
|
-
else
|
83
|
-
fields = [ date(created_at), attribute, old_value || '[nil]', new_value ]
|
84
|
-
"%10s : %12s : %s => %s" % fields
|
85
|
-
end
|
86
|
-
end
|
87
|
-
end
|
88
|
-
|
89
|
-
|
90
|
-
class Issue
|
91
|
-
include Comparable
|
92
|
-
|
93
|
-
attr_reader :changelog
|
94
|
-
|
95
|
-
SCHEMA_ATTRIBUTES = {
|
96
|
-
:id => { :class => String, :required => true, :settable => false },
|
97
|
-
:created_at => { :class => Time, :required => true, :settable => false },
|
98
|
-
:updated_at => { :class => Time, :required => true, :settable => false },
|
99
|
-
|
100
|
-
:summary => { :class => String, :required => true, :settable => true },
|
101
|
-
:kind => { :class => String, :required => true, :settable => true },
|
102
|
-
:status => { :class => String, :required => true, :settable => true },
|
103
|
-
:owner => { :class => String, :required => true, :settable => true },
|
104
|
-
|
105
|
-
:priority => { :class => Fixnum, :required => true, :settable => true },
|
106
|
-
|
107
|
-
:description => { :class => String, :required => true, :settable => true },
|
108
|
-
}
|
109
|
-
|
110
|
-
TEMPLATE =<<-EOT.strip
|
111
|
-
# Lines beginning with # will be ignored.
|
112
|
-
Summary : %{summary}
|
113
|
-
Kind : %{kind}
|
114
|
-
Status : %{status}
|
115
|
-
Priority : %{priority}
|
116
|
-
Owner : %{owner}
|
117
|
-
|
118
|
-
# Everything between the --- lines is Issue Description
|
119
|
-
---
|
120
|
-
%{description}
|
121
|
-
---
|
122
|
-
EOT
|
123
|
-
|
124
|
-
class Invalid < Exception; end
|
125
|
-
class NotFound < Exception; end
|
126
|
-
|
127
|
-
def initialize(issue={}, changelog=[])
|
128
|
-
issue = Hash[issue.map { |k, v| [ k.to_sym, v ] }]
|
129
|
-
|
130
|
-
@new = issue[:created_at].nil? && issue[:id].nil?
|
131
|
-
|
132
|
-
issue[:created_at] = Time.now unless issue.include?(:created_at) # intentionally not using ||=
|
133
|
-
issue[:updated_at] = Time.now unless issue.include?(:updated_at) # intentionally not using ||=
|
134
|
-
issue[:id] = SecureRandom.uuid.gsub('-', '') unless issue.include?(:id) # intentionally not using ||=
|
135
|
-
|
136
|
-
@changelog = []
|
137
|
-
@issue = {}
|
138
|
-
|
139
|
-
self.issue = Issue::format_attributes issue
|
140
|
-
|
141
|
-
if changelog.size > 0
|
142
|
-
@changelog = changelog.map do |thing|
|
143
|
-
if thing.is_a? Change
|
144
|
-
thing
|
145
|
-
else
|
146
|
-
Change.new thing
|
147
|
-
end
|
148
|
-
end
|
149
|
-
end
|
150
|
-
|
151
|
-
self
|
152
|
-
end
|
153
|
-
|
154
|
-
def new?
|
155
|
-
@new
|
156
|
-
end
|
157
|
-
|
158
|
-
def self.set_allowed_values!(attrs=nil)
|
159
|
-
if attrs.nil?
|
160
|
-
SCHEMA_ATTRIBUTES.each { |attr, data| data.delete(:allowed_values) }
|
161
|
-
else
|
162
|
-
attrs.each do |attr, values|
|
163
|
-
raise ArgumentError.new("Unknown Issue attributes: #{attr}") unless SCHEMA_ATTRIBUTES.include? attr
|
164
|
-
|
165
|
-
if SCHEMA_ATTRIBUTES[attr][:class] == Fixnum
|
166
|
-
values.map!(&:to_i)
|
167
|
-
end
|
168
|
-
|
169
|
-
SCHEMA_ATTRIBUTES[attr][:allowed_values] = values
|
170
|
-
end
|
171
|
-
end
|
172
|
-
end
|
173
|
-
|
174
|
-
def self.format_attributes(issue_attrs)
|
175
|
-
attrs = issue_attrs.dup
|
176
|
-
|
177
|
-
attrs.keys.each { |attr| raise ArgumentError.new("Unknown Issue attribute: #{attr}") unless SCHEMA_ATTRIBUTES.include? attr }
|
178
|
-
|
179
|
-
SCHEMA_ATTRIBUTES.each do |attr, cfg|
|
180
|
-
next unless attrs.include? attr
|
181
|
-
|
182
|
-
case cfg[:class].to_s # can't case on cfg[:class], because class of cfg[:class] is always Class :-)
|
183
|
-
when 'Time'
|
184
|
-
unless attrs[attr].is_a?(String) || attrs[attr].is_a?(Time)
|
185
|
-
raise TypeError.new("#{attr} : expected type #{cfg[:class]}, got type #{attrs[attr].class}")
|
186
|
-
end
|
187
|
-
|
188
|
-
t = if attrs[attr].is_a?(String)
|
189
|
-
begin
|
190
|
-
Time.parse(attrs[attr])
|
191
|
-
rescue ArgumentError => e
|
192
|
-
raise TypeError.new(e.to_s)
|
193
|
-
end
|
194
|
-
else
|
195
|
-
attrs[attr]
|
196
|
-
end
|
197
|
-
attrs[attr] = timescrub(t)
|
198
|
-
when 'String'
|
199
|
-
unless attrs[attr].is_a?(String)
|
200
|
-
raise TypeError.new("#{attr} : expected type #{cfg[:class]}, got type #{attrs[attr].class}")
|
201
|
-
end
|
202
|
-
|
203
|
-
attrs[attr] && attrs[attr].strip!
|
204
|
-
when 'Fixnum'
|
205
|
-
unless attrs[attr].is_a?(Fixnum) || attrs[attr].is_a?(String)
|
206
|
-
raise TypeError.new("#{attr} : expected type #{cfg[:class]}, got type #{attrs[attr].class}")
|
207
|
-
end
|
208
|
-
|
209
|
-
if attrs[attr].is_a?(String)
|
210
|
-
i = attrs[attr].to_i
|
211
|
-
raise TypeError.new("#{attr} : expected type #{cfg[:class]}, got type #{attrs[attr].class}") unless i.to_s == attrs[attr]
|
212
|
-
attrs[attr] = i
|
213
|
-
end
|
214
|
-
end
|
215
|
-
end
|
216
|
-
|
217
|
-
attrs
|
218
|
-
end
|
219
|
-
|
220
|
-
def <=>(other)
|
221
|
-
if SCHEMA_ATTRIBUTES.all? { |attr, cfg| self.send(attr) == other.send(attr) }
|
222
|
-
r = 0
|
223
|
-
else
|
224
|
-
if self.created_at == other.created_at
|
225
|
-
r = self.id <=> other.id
|
226
|
-
else
|
227
|
-
r = self.created_at <=> other.created_at
|
228
|
-
end
|
229
|
-
|
230
|
-
# this clause should not return 0, we've already established inequality
|
231
|
-
#
|
232
|
-
r = -1 if r == 0
|
233
|
-
end
|
234
|
-
|
235
|
-
r
|
236
|
-
end
|
237
|
-
|
238
|
-
def inspect
|
239
|
-
fields = SCHEMA_ATTRIBUTES.map do |attr, cfg|
|
240
|
-
"@#{attr}=#{self.send(attr).inspect}"
|
241
|
-
end.join ', '
|
242
|
-
|
243
|
-
"#<#{self.class}:0x%016x %s>" % [ object_id, fields ]
|
244
|
-
end
|
245
|
-
|
246
|
-
def method_missing(method, *args, &block)
|
247
|
-
method_str = method.to_s
|
248
|
-
attr = method_str.gsub(/=$/, '').to_sym
|
249
|
-
|
250
|
-
if data = SCHEMA_ATTRIBUTES[attr]
|
251
|
-
if method_str[-1] == '='
|
252
|
-
raise NoMethodError unless data[:settable]
|
253
|
-
self.issue = Issue::format_attributes(@issue.merge( { attr => args.first } ) )
|
254
|
-
@issue[:updated_at] = timescrub Time.now
|
255
|
-
else
|
256
|
-
@issue[attr]
|
257
|
-
end
|
258
|
-
else
|
259
|
-
super
|
260
|
-
end
|
261
|
-
end
|
262
|
-
|
263
|
-
def respond_to?(method)
|
264
|
-
method_str = method.to_s
|
265
|
-
attr = method_str.gsub(/=$/, '').to_sym
|
266
|
-
|
267
|
-
if data = SCHEMA_ATTRIBUTES[attr]
|
268
|
-
return method_str[-1] != '=' || data[:settable]
|
269
|
-
end
|
270
|
-
|
271
|
-
super
|
272
|
-
end
|
273
|
-
|
274
|
-
def to_s(opts={})
|
275
|
-
text = <<-EOT.strip
|
276
|
-
ID : #{id}
|
277
|
-
Created At : #{date(created_at)}
|
278
|
-
Updated At : #{date(updated_at)}
|
279
|
-
|
280
|
-
Summary : #{summary}
|
281
|
-
Kind : #{kind}
|
282
|
-
Status : #{status}
|
283
|
-
Priority : #{priority}
|
284
|
-
Owner : #{owner}
|
285
|
-
|
286
|
-
---
|
287
|
-
#{description}
|
288
|
-
EOT
|
289
|
-
|
290
|
-
if opts[:changelog]
|
291
|
-
changelog_str = changelog.map { |c| %Q|# #{c.to_s.strip.gsub(/\n/, "\n# ")}| }.join("\n")
|
292
|
-
text << %Q|\n---\n\n#{changelog_str}|
|
293
|
-
end
|
294
|
-
|
295
|
-
text
|
296
|
-
end
|
297
|
-
|
298
|
-
def to_json(state=nil)
|
299
|
-
valid? :raise => true
|
300
|
-
hash = { :issue => @issue, :changelog => changelog }
|
301
|
-
JSON.pretty_generate(hash)
|
302
|
-
end
|
303
|
-
|
304
|
-
def to_template
|
305
|
-
if new?
|
306
|
-
header = "# New Issue\n#"
|
307
|
-
body = TEMPLATE
|
308
|
-
footer = ""
|
309
|
-
else
|
310
|
-
header =<<-EOT
|
311
|
-
# Edit Issue
|
312
|
-
#
|
313
|
-
# ID : #{id}
|
314
|
-
# Created At : #{created_at}
|
315
|
-
# Updated At : #{updated_at}
|
316
|
-
#
|
317
|
-
EOT
|
318
|
-
body = TEMPLATE % @issue
|
319
|
-
|
320
|
-
footer =<<-EOT
|
321
|
-
# ChangeLog
|
322
|
-
#
|
323
|
-
#{changelog.map { |c| %Q|# #{c.to_s.strip.gsub(/\n/, "\n# ")}| }.join("\n")}
|
324
|
-
EOT
|
325
|
-
end
|
326
|
-
|
327
|
-
(header + "\n" + body + "\n\n" + footer).strip
|
328
|
-
end
|
329
|
-
|
330
|
-
def self.from_json(the_json)
|
331
|
-
begin
|
332
|
-
hash = JSON.parse(the_json)
|
333
|
-
rescue JSON::ParserError => e
|
334
|
-
raise Issue::Invalid.new(e.to_s)
|
335
|
-
end
|
336
|
-
|
337
|
-
Issue.new(hash['issue'], hash['changelog'])
|
338
|
-
end
|
339
|
-
|
340
|
-
def self.from_template(text)
|
341
|
-
issue = { :description => '' }
|
342
|
-
reading_description = false
|
343
|
-
|
344
|
-
text.lines.each_with_index do |line, index|
|
345
|
-
next if line =~ /^#/ || (!reading_description && line =~ /^\s*$/)
|
346
|
-
|
347
|
-
if line =~ /^---$/
|
348
|
-
# FIXME: this means that there can be multiple description blocks in the template!
|
349
|
-
#
|
350
|
-
reading_description = !reading_description
|
351
|
-
next
|
352
|
-
end
|
353
|
-
|
354
|
-
if !reading_description && line =~ /^(\w+)\s*:\s*(.*)$/
|
355
|
-
key, value = $1.downcase.to_sym, $2.strip
|
356
|
-
|
357
|
-
if SCHEMA_ATTRIBUTES.include?(key) && SCHEMA_ATTRIBUTES[key][:settable]
|
358
|
-
issue[key] = value
|
359
|
-
else
|
360
|
-
raise ArgumentError.new("Unknown Issue attribute: #{key} on line #{index+1}") unless SCHEMA_ATTRIBUTES.include?(key)
|
361
|
-
raise ArgumentError.new("Cannot set write-protected Issue attribute: #{key} on line #{index+1}")
|
362
|
-
end
|
363
|
-
elsif reading_description
|
364
|
-
issue[:description] += line
|
365
|
-
else
|
366
|
-
raise ArgumentError.new("Cannot parse line #{index+1}")
|
367
|
-
end
|
368
|
-
end
|
369
|
-
|
370
|
-
Issue.new(issue)
|
371
|
-
end
|
372
|
-
|
373
|
-
def update_from_template!(text)
|
374
|
-
new_issue = Issue.from_template(text)
|
375
|
-
|
376
|
-
attrs = SCHEMA_ATTRIBUTES.map do |attr, data|
|
377
|
-
if data[:settable]
|
378
|
-
[ attr, new_issue.send(attr) ]
|
379
|
-
else
|
380
|
-
[ attr, @issue[attr] ]
|
381
|
-
end
|
382
|
-
end
|
383
|
-
|
384
|
-
self.issue = Issue::format_attributes(Hash[attrs])
|
385
|
-
@issue[:updated_at] = timescrub Time.now
|
386
|
-
|
387
|
-
self
|
388
|
-
end
|
389
|
-
|
390
|
-
def valid?(opts={})
|
391
|
-
begin
|
392
|
-
raise Issue::Invalid.new("id is nil") unless id
|
393
|
-
|
394
|
-
SCHEMA_ATTRIBUTES.each do |attr, cfg|
|
395
|
-
raise Issue::Invalid.new("Missing required attribute: #{attr}") if cfg[:required] && @issue[attr].nil?
|
396
|
-
end
|
397
|
-
|
398
|
-
@issue.each do |attr, value|
|
399
|
-
unless @issue[attr].is_a?(SCHEMA_ATTRIBUTES[attr][:class])
|
400
|
-
raise Issue::Invalid.new("Wrong type: #{attr} (expected #{SCHEMA_ATTRIBUTES[attr][:class]}, got #{@issue[attr.class]})")
|
401
|
-
end
|
402
|
-
|
403
|
-
if allowed_values = SCHEMA_ATTRIBUTES[attr][:allowed_values]
|
404
|
-
unless allowed_values.include? @issue[attr]
|
405
|
-
raise Issue::Invalid.new("#{@issue[attr]} is not an allowed value for #{attr.capitalize}")
|
406
|
-
end
|
407
|
-
end
|
408
|
-
|
409
|
-
if SCHEMA_ATTRIBUTES[attr][:class] == String && @issue[attr] =~ /\A\s*\Z/
|
410
|
-
raise Issue::Invalid.new("Empty string is not allowed for #{attr}")
|
411
|
-
end
|
412
|
-
end
|
413
|
-
rescue Issue::Invalid => e
|
414
|
-
return false unless opts[:raise]
|
415
|
-
raise e
|
416
|
-
end
|
417
|
-
|
418
|
-
true
|
419
|
-
end
|
420
|
-
|
421
|
-
private
|
422
|
-
def issue=(new_issue)
|
423
|
-
new_issue.each do |attr, value|
|
424
|
-
if SCHEMA_ATTRIBUTES[attr][:settable] && @issue[attr] != new_issue[attr]
|
425
|
-
@changelog << Change.new(:attribute => attr, :old_value => @issue[attr], :new_value => new_issue[attr])
|
426
|
-
end
|
427
|
-
end
|
428
|
-
|
429
|
-
@issue = new_issue
|
430
|
-
end
|
431
|
-
end
|
432
|
-
|
433
|
-
class Taco
|
434
|
-
HOME_DIR = '.taco'
|
435
|
-
|
436
|
-
attr_accessor :home
|
437
|
-
|
438
|
-
class NotFound < Exception; end
|
439
|
-
class Ambiguous < Exception; end
|
440
|
-
|
441
|
-
def initialize(root_path=nil)
|
442
|
-
@home = File.join(root_path || Dir.getwd, HOME_DIR)
|
443
|
-
end
|
444
|
-
|
445
|
-
def init!
|
446
|
-
raise IOError.new("Could not create #{@home}\nDirectory already exists.") if File.exists?(@home)
|
447
|
-
|
448
|
-
FileUtils.mkdir_p(@home)
|
449
|
-
|
450
|
-
"Initialized #{@home}"
|
451
|
-
end
|
452
|
-
|
453
|
-
def write!(issue_or_issues)
|
454
|
-
issues = issue_or_issues.is_a?(Array) ? issue_or_issues : [ issue_or_issues ]
|
455
|
-
|
456
|
-
issues.each do |issue|
|
457
|
-
the_json = issue.to_json # do this first so we don't bother the filesystem if the issue is invalid
|
458
|
-
open(File.join(@home, issue.id), 'w') { |f| f.write(the_json) }
|
459
|
-
end
|
460
|
-
|
461
|
-
issue_or_issues
|
462
|
-
end
|
463
|
-
|
464
|
-
def read(issue_id)
|
465
|
-
issue_path = File.join(@home, issue_id)
|
466
|
-
|
467
|
-
unless File.exist? issue_path
|
468
|
-
entries = Dir[File.join(@home, "*#{issue_id}*")]
|
469
|
-
|
470
|
-
raise NotFound.new("Issue not found.") unless entries.size > 0
|
471
|
-
unless entries.size == 1
|
472
|
-
issue_list = entries.map do |entry|
|
473
|
-
issue = read(File.basename(entry))
|
474
|
-
"#{issue.id} : #{issue.summary}"
|
475
|
-
end
|
476
|
-
raise Ambiguous.new("Found several matching issues:\n%s" % issue_list.join("\n"))
|
477
|
-
end
|
478
|
-
|
479
|
-
issue_path = entries[0]
|
480
|
-
issue_id = File.basename entries[0]
|
481
|
-
end
|
482
|
-
|
483
|
-
the_json = open(issue_path) { |f| f.read }
|
484
|
-
|
485
|
-
issue = Issue.from_json the_json
|
486
|
-
|
487
|
-
raise Issue::Invalid.new("Issue ID does not match filename: #{issue.id} != #{issue_id}") unless issue.id == issue_id
|
488
|
-
|
489
|
-
issue
|
490
|
-
end
|
491
|
-
|
492
|
-
def list(opts={})
|
493
|
-
filter_match = if opts.fetch(:filters, []).size > 0
|
494
|
-
conditions = opts[:filters].map do |filter|
|
495
|
-
attr, val = filter.split(':')
|
496
|
-
%Q|i.send("#{attr}") == "#{val}"|
|
497
|
-
end.join ' && '
|
498
|
-
|
499
|
-
# FIXME: eval-ing user input? madness!
|
500
|
-
eval "Proc.new { |i| #{conditions} }"
|
501
|
-
else
|
502
|
-
nil
|
503
|
-
end
|
504
|
-
|
505
|
-
ids = Dir.glob("#{@home}/*")
|
506
|
-
|
507
|
-
ids.map do |name|
|
508
|
-
id = File.basename name
|
509
|
-
issue = Issue.from_json(open(name) { |f| f.read })
|
510
|
-
|
511
|
-
next unless filter_match.nil? || filter_match.call(issue)
|
512
|
-
|
513
|
-
raise Issue::Invalid.new("Issue ID does not match filename: #{issue.id} != #{id}") unless issue.id == id
|
514
|
-
|
515
|
-
short_id = 8.upto(id.size).each do |n|
|
516
|
-
short_id = id[0...n]
|
517
|
-
break short_id unless ids.count { |i| i.include? short_id } > 1
|
518
|
-
end
|
519
|
-
|
520
|
-
if opts[:short_ids]
|
521
|
-
[ issue, short_id ]
|
522
|
-
else
|
523
|
-
issue
|
524
|
-
end
|
525
|
-
end.reject(&:nil?).sort_by { |thing| opts[:short_ids] ? thing[0] : thing}
|
526
|
-
end
|
527
|
-
end
|
528
|
-
|
529
|
-
class IssueEditor
|
530
|
-
def initialize(taco, retry_path)
|
531
|
-
@taco, @retry_path = taco, retry_path
|
532
|
-
end
|
533
|
-
|
534
|
-
def new_issue!(opts={})
|
535
|
-
if opts[:from_file]
|
536
|
-
text = open(opts[:from_file]) { |f| f.read }
|
537
|
-
else
|
538
|
-
raise ArgumentError.new("Please define $EDITOR in your environment.") unless ENV['EDITOR']
|
539
|
-
text = invoke_editor(opts[:template])
|
540
|
-
end
|
541
|
-
|
542
|
-
write_issue!(Issue.from_template(text), text) if text
|
543
|
-
end
|
544
|
-
|
545
|
-
def edit_issue!(issue, opts={})
|
546
|
-
if text = invoke_editor(opts[:template] || issue.to_template)
|
547
|
-
write_issue!(issue.update_from_template!(text), text)
|
548
|
-
end
|
549
|
-
end
|
550
|
-
|
551
|
-
private
|
552
|
-
def write_issue!(issue, text)
|
553
|
-
begin
|
554
|
-
@taco.write! issue
|
555
|
-
rescue Exception => e
|
556
|
-
open(@retry_path, 'w') { |f| f.write(text) } if text
|
557
|
-
raise e
|
558
|
-
end
|
559
|
-
|
560
|
-
File.unlink @retry_path rescue nil
|
561
|
-
issue
|
562
|
-
end
|
563
|
-
|
564
|
-
def invoke_editor(template)
|
565
|
-
text = nil
|
566
|
-
file = Tempfile.new('taco')
|
567
|
-
|
568
|
-
begin
|
569
|
-
file.write(template)
|
570
|
-
file.close
|
571
|
-
|
572
|
-
cmd = "$EDITOR #{file.path}"
|
573
|
-
system(cmd)
|
574
|
-
|
575
|
-
open(file.path) do |f|
|
576
|
-
text = f.read
|
577
|
-
end
|
578
|
-
ensure
|
579
|
-
File.unlink(file.path) rescue nil
|
580
|
-
end
|
581
|
-
|
582
|
-
text == template ? nil : text
|
583
|
-
end
|
584
|
-
end
|
585
|
-
|
586
|
-
class TacoCLI
|
587
|
-
RC_NAME = '.tacorc'
|
588
|
-
RC_TEXT =<<-EOT.strip
|
589
|
-
# Empty lines and lines beginning with # will be ignored.
|
590
|
-
#
|
591
|
-
# comma separated list of valid values for Issue fields
|
592
|
-
#
|
593
|
-
Kind = Defect, Feature Request
|
594
|
-
Status = Open, Closed
|
595
|
-
Priority = 1, 2, 3, 4, 5
|
596
|
-
|
597
|
-
# Default values for Issue fields
|
598
|
-
#
|
599
|
-
DefaultKind = Defect
|
600
|
-
DefaultStatus = Open
|
601
|
-
DefaultPriority = 3
|
602
|
-
EOT
|
603
|
-
RETRY_NAME = '.taco_retry.txt'
|
604
|
-
INDEX_ERB_NAME = '.index.html.erb'
|
605
|
-
INDEX_ERB_SRC_PATH = File.realpath(File.join(File.dirname(__FILE__), '../lib/taco/defaults/index.html.erb'))
|
606
|
-
|
607
|
-
class ParseError < Exception; end
|
608
|
-
|
609
|
-
def initialize(taco=nil)
|
610
|
-
@taco = taco || Taco.new
|
611
|
-
|
612
|
-
@retry_path = File.join(@taco.home, RETRY_NAME)
|
613
|
-
|
614
|
-
@rc_path = File.join(@taco.home, RC_NAME)
|
615
|
-
@config = parse_rc
|
616
|
-
|
617
|
-
@index_erb_path = File.join(@taco.home, INDEX_ERB_NAME)
|
618
|
-
|
619
|
-
Issue.set_allowed_values! @config[:allowed]
|
620
|
-
end
|
621
|
-
|
622
|
-
def init!
|
623
|
-
out = @taco.init!
|
624
|
-
open(@rc_path, 'w') { |f| f.write(RC_TEXT) }
|
625
|
-
|
626
|
-
FileUtils.copy(INDEX_ERB_SRC_PATH, @index_erb_path)
|
627
|
-
|
628
|
-
out + "\nPlease edit the config file at #{@rc_path}"
|
629
|
-
end
|
630
|
-
|
631
|
-
def list(args)
|
632
|
-
the_list = @taco.list(:short_ids => true, :filters => args).map do |issue, short_id|
|
633
|
-
"#{short_id} : #{issue.priority} : #{issue.summary}"
|
634
|
-
end
|
635
|
-
return "Found no issues." unless the_list.size > 0
|
636
|
-
the_list.join("\n")
|
637
|
-
end
|
638
|
-
|
639
|
-
def new!(args, opts)
|
640
|
-
editor_opts = if opts[:retry]
|
641
|
-
raise ArgumentError.new("No previous Issue edit session was found.") unless File.exist?(@retry_path)
|
642
|
-
{ :template => open(@retry_path) { |f| f.read } }
|
643
|
-
elsif args.size == 0
|
644
|
-
{ :template => (Issue.new.to_template % @config[:defaults]) }
|
645
|
-
elsif args.size == 1
|
646
|
-
{ :from_file => args[0] }
|
647
|
-
end
|
648
|
-
|
649
|
-
if issue = IssueEditor.new(@taco, @retry_path).new_issue!(editor_opts)
|
650
|
-
"Created Issue #{issue.id}"
|
651
|
-
else
|
652
|
-
"Aborted."
|
653
|
-
end
|
654
|
-
end
|
655
|
-
|
656
|
-
def show(args, opts)
|
657
|
-
if opts[:all]
|
658
|
-
filters = args.select { |arg| arg.include? ':' }
|
659
|
-
args = @taco.list(:filters => filters).map(&:id)
|
660
|
-
end
|
661
|
-
|
662
|
-
args.map { |id| @taco.read(id).to_s(opts) }.join("\n\n")
|
663
|
-
end
|
664
|
-
|
665
|
-
def edit!(args, opts)
|
666
|
-
ie = IssueEditor.new @taco, @retry_path
|
667
|
-
|
668
|
-
if opts[:retry]
|
669
|
-
raise ArgumentError.new("No previous Issue edit session was found.") unless File.exist?(@retry_path)
|
670
|
-
template = open(@retry_path) { |f| f.read }
|
671
|
-
end
|
672
|
-
|
673
|
-
if issue = ie.edit_issue!(@taco.read(args[0]), :template => template)
|
674
|
-
"Updated Issue #{issue.id}"
|
675
|
-
else
|
676
|
-
"Aborted."
|
677
|
-
end
|
678
|
-
end
|
679
|
-
|
680
|
-
def template(opts)
|
681
|
-
if opts[:defaults]
|
682
|
-
(Issue::TEMPLATE % @config[:defaults]).strip
|
683
|
-
else
|
684
|
-
Issue::TEMPLATE.gsub(/%{.*?}/, '').strip
|
685
|
-
end
|
686
|
-
end
|
687
|
-
|
688
|
-
def html
|
689
|
-
require 'erb'
|
690
|
-
|
691
|
-
issues = @taco.list
|
692
|
-
ERB.new(open(@index_erb_path) { |f| f.read }).result(binding)
|
693
|
-
end
|
694
|
-
|
695
|
-
def push(opts)
|
696
|
-
opts[:message] ||= 'turn and face the strange'
|
697
|
-
cmd = "git add . && git commit -am '#{opts[:message]}' && git push"
|
698
|
-
system(cmd)
|
699
|
-
end
|
700
|
-
|
701
|
-
private
|
702
|
-
def parse_rc
|
703
|
-
defaults = Hash[Issue::SCHEMA_ATTRIBUTES.select { |attr, data| data[:settable] }.map { |attr, data| [ attr, nil ] } ]
|
704
|
-
config = { :defaults => defaults, :allowed => {} }
|
705
|
-
|
706
|
-
def set_attr(hash, what, attr, value, line)
|
707
|
-
if data = Issue::SCHEMA_ATTRIBUTES[attr]
|
708
|
-
if data[:settable]
|
709
|
-
hash[attr] = value
|
710
|
-
else
|
711
|
-
raise ParseError.new("Cannot set #{what} for write-protected Issue attribute '#{attr}' on line #{line}")
|
712
|
-
end
|
713
|
-
else
|
714
|
-
raise ParseError.new("Unknown Issue attribute '#{attr}' on line #{line}")
|
715
|
-
end
|
716
|
-
end
|
717
|
-
|
718
|
-
if File.exist? @rc_path
|
719
|
-
open(@rc_path) do |f|
|
720
|
-
f.readlines.each_with_index do |line, index|
|
721
|
-
next if line =~ /^#/ || line =~ /^\s*$/
|
722
|
-
|
723
|
-
if line =~ /^Default(\w+)\s+=\s+(\w+)/
|
724
|
-
attr, value = $1.strip.downcase.to_sym, $2.strip
|
725
|
-
set_attr(config[:defaults], 'default', attr, value, index+1)
|
726
|
-
elsif line =~ /^(\w+)\s*=\s*(.*)$/
|
727
|
-
attr, values = $1.strip.downcase.to_sym, $2.split(',').map(&:strip)
|
728
|
-
set_attr(config[:allowed], 'allowed values', attr, values, index+1)
|
729
|
-
else
|
730
|
-
raise ParseError.new("Unparseable stuff on line #{index+1}")
|
731
|
-
end
|
732
|
-
end
|
733
|
-
end
|
734
|
-
end
|
735
|
-
|
736
|
-
config
|
737
|
-
end
|
738
|
-
end
|
739
|
-
|
740
|
-
# ########
|
741
|
-
# main
|
742
|
-
# ########
|
3
|
+
require 'taco/cli'
|
743
4
|
|
744
5
|
begin
|
745
|
-
cli = TacoCLI.new
|
746
|
-
rescue
|
6
|
+
cli = TacoCLI.new
|
7
|
+
rescue TacoRc::ParseError => e
|
747
8
|
puts "Parse error while reading .tacorc: #{e}"
|
748
9
|
exit 1
|
749
10
|
end
|
@@ -751,7 +12,7 @@ end
|
|
751
12
|
require 'commander/import'
|
752
13
|
|
753
14
|
program :name, 'taco'
|
754
|
-
program :version, '1.
|
15
|
+
program :version, '1.4.0'
|
755
16
|
program :description, 'simple command line issue tracking'
|
756
17
|
|
757
18
|
command :init do |c|
|