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.
@@ -0,0 +1,75 @@
1
+ require 'json'
2
+ require 'time'
3
+
4
+ # FIXME: put this in a namespace
5
+ class Change
6
+ class Invalid < Exception; end
7
+
8
+ attr_reader :created_at
9
+ attr_accessor :attribute
10
+ attr_accessor :old_value
11
+ attr_accessor :new_value
12
+
13
+ def initialize(args={})
14
+ args.each do |attr, value|
15
+ raise ArgumentError.new("Unknown attribute #{attr}") unless self.respond_to?(attr)
16
+
17
+ case attr.to_sym
18
+ when :created_at
19
+ value = Time.parse(value) unless value.is_a?(Time)
20
+ when :attribute
21
+ value = value.to_sym
22
+ end
23
+
24
+ instance_variable_set("@#{attr.to_s}", value)
25
+ end
26
+
27
+ @created_at = Time.parse(@created_at) if @created_at.is_a?(String)
28
+ @created_at = timescrub(@created_at || Time.now)
29
+
30
+ self
31
+ end
32
+
33
+ def self.from_json(the_json)
34
+ begin
35
+ hash = JSON.parse(the_json)
36
+ rescue JSON::ParserError => e
37
+ raise Change::Invalid.new(e.to_s)
38
+ end
39
+
40
+ Change.new(hash)
41
+ end
42
+
43
+ def valid?(opts={})
44
+ # old_value is optional!
45
+ #
46
+ valid = created_at && attribute && new_value
47
+ raise Invalid if opts[:raise] && !valid
48
+ valid
49
+ end
50
+
51
+ def to_json(state=nil)
52
+ valid? :raise => true
53
+ hash = { :created_at => created_at, :attribute => attribute, :old_value => old_value, :new_value => new_value }
54
+ JSON.pretty_generate(hash)
55
+ end
56
+
57
+ def to_s(opts={})
58
+ if opts[:simple]
59
+ "#{attribute} : #{old_value} => #{new_value}"
60
+ else
61
+ fields = [ date(created_at), attribute, old_value || '[nil]', new_value ]
62
+ "%10s : %12s : %s => %s" % fields
63
+ end
64
+ end
65
+
66
+ private
67
+ def date(t)
68
+ t.strftime "%Y/%m/%d %H:%M:%S"
69
+ end
70
+
71
+ def timescrub(t)
72
+ Time.new t.year, t.mon, t.day, t.hour, t.min, t.sec, t.utc_offset
73
+ end
74
+
75
+ end
data/lib/taco/cli.rb ADDED
@@ -0,0 +1,107 @@
1
+ require 'taco/issue'
2
+ require 'taco/taco'
3
+ require 'taco/tacorc'
4
+
5
+ class TacoCLI
6
+ RETRY_NAME = '.taco_retry.txt'
7
+
8
+ TACORC_NAME = '.tacorc'
9
+ INDEX_ERB_NAME = '.index.html.erb'
10
+
11
+ DEFAULT_TACORC_NAME = 'tacorc'
12
+ DEFAULT_INDEX_ERB_NAME = 'index.html.erb'
13
+ DEFAULTS_HOME = File.realpath(File.join(File.dirname(__FILE__), 'defaults/'))
14
+
15
+ def initialize
16
+ @taco = Taco.new
17
+
18
+ @retry_path = File.join(@taco.home, RETRY_NAME)
19
+
20
+ @tacorc_path = File.join(@taco.home, TACORC_NAME)
21
+ @index_erb_path = File.join(@taco.home, INDEX_ERB_NAME)
22
+
23
+ if File.exist? @tacorc_path
24
+ rc = TacoRc.new @tacorc_path
25
+ rc.update_schema! Issue
26
+ end
27
+ end
28
+
29
+ def init!
30
+ out = @taco.init!
31
+
32
+ FileUtils.copy(File.join(DEFAULTS_HOME, DEFAULT_TACORC_NAME), @tacorc_path)
33
+ FileUtils.copy(File.join(DEFAULTS_HOME, DEFAULT_INDEX_ERB_NAME), @index_erb_path)
34
+
35
+ out + "\nPlease edit the config file at #{@tacorc_path}"
36
+ end
37
+
38
+ def list(args)
39
+ the_list = @taco.list(:short_ids => true, :filters => args).map do |issue, short_id|
40
+ "#{short_id} : #{issue.priority} : #{issue.summary}"
41
+ end
42
+ return "Found no issues." unless the_list.size > 0
43
+ the_list.join("\n")
44
+ end
45
+
46
+ def new!(args, opts)
47
+ editor_opts = if opts[:retry]
48
+ raise ArgumentError.new("No previous Issue edit session was found.") unless File.exist?(@retry_path)
49
+ { :template => open(@retry_path) { |f| f.read } }
50
+ elsif args.size == 0
51
+ { :template => Issue.new.to_template }
52
+ elsif args.size == 1
53
+ { :from_file => args[0] }
54
+ end
55
+
56
+ if issue = IssueEditor.new(@taco, @retry_path).new_issue!(editor_opts)
57
+ "Created Issue #{issue.id}"
58
+ else
59
+ "Aborted."
60
+ end
61
+ end
62
+
63
+ def show(args, opts)
64
+ if opts[:all]
65
+ filters = args.select { |arg| arg.include? ':' }
66
+ args = @taco.list(:filters => filters).map(&:id)
67
+ end
68
+
69
+ args.map { |id| @taco.read(id).to_s(opts) }.join("\n\n")
70
+ end
71
+
72
+ def edit!(args, opts)
73
+ ie = IssueEditor.new @taco, @retry_path
74
+
75
+ if opts[:retry]
76
+ raise ArgumentError.new("No previous Issue edit session was found.") unless File.exist?(@retry_path)
77
+ template = open(@retry_path) { |f| f.read }
78
+ end
79
+
80
+ if issue = ie.edit_issue!(@taco.read(args[0]), :template => template)
81
+ "Updated Issue #{issue.id}"
82
+ else
83
+ "Aborted."
84
+ end
85
+ end
86
+
87
+ def template(opts)
88
+ if opts[:defaults]
89
+ (Issue::TEMPLATE % Issue.new.to_hash).strip
90
+ else
91
+ Issue::TEMPLATE.gsub(/%{.*?}/, '').strip
92
+ end
93
+ end
94
+
95
+ def html
96
+ require 'erb'
97
+
98
+ issues = @taco.list
99
+ ERB.new(open(@index_erb_path) { |f| f.read }).result(binding)
100
+ end
101
+
102
+ def push(opts)
103
+ opts[:message] ||= 'turn and face the strange'
104
+ cmd = "git add . && git commit -am '#{opts[:message]}' && git push"
105
+ system(cmd)
106
+ end
107
+ end
@@ -0,0 +1,10 @@
1
+ # Empty lines and lines beginning with # will be ignored.
2
+ #
3
+
4
+ #
5
+ # schema_attr_update <attribute name>, [ default: <default attribute value> ], [ validate: <list of type-correct valid values, or lambda validator> ]
6
+ #
7
+
8
+ schema_attr_update :kind, default: 'Defect', validate: [ 'Defect', 'FeatReq' ]
9
+ schema_attr_update :status, default: 'Open', validate: [ 'Open', 'Closed' ]
10
+ schema_attr_update :priority, default: 3, validate: [ 1, 2, 3, 4, 5 ]
data/lib/taco/issue.rb ADDED
@@ -0,0 +1,240 @@
1
+ require 'taco/schema'
2
+ require 'taco/change'
3
+ require 'securerandom'
4
+
5
+ class Issue
6
+ include Comparable
7
+ include Schema
8
+
9
+ alias_method :schema_valid?, :valid?
10
+
11
+ attr_reader :changelog
12
+
13
+ schema_attr :id, class: String, settable: false
14
+ schema_attr :created_at, class: Time, settable: false
15
+ schema_attr :updated_at, class: Time, settable: false
16
+
17
+ schema_attr :summary, class: String, settable: true
18
+ schema_attr :kind, class: String, settable: true
19
+ schema_attr :status, class: String, settable: true
20
+ schema_attr :owner, class: String, settable: true
21
+ schema_attr :priority, class: Fixnum, settable: true
22
+ schema_attr :description, class: String, settable: true
23
+
24
+ TEMPLATE =<<-EOT.strip
25
+ # Lines beginning with # will be ignored.
26
+ Summary : %{summary}
27
+ Kind : %{kind}
28
+ Status : %{status}
29
+ Priority : %{priority}
30
+ Owner : %{owner}
31
+
32
+ # Everything between the --- lines is Issue Description
33
+ ---
34
+ %{description}
35
+ ---
36
+ EOT
37
+
38
+ class Invalid < Exception; end
39
+ class NotFound < Exception; end
40
+
41
+ def initialize(attributes={}, changelog=[])
42
+ attributes = Hash[attributes.map { |k, v| [ k.to_sym, v ] }]
43
+
44
+ @new = attributes[:created_at].nil? && attributes[:id].nil?
45
+
46
+ @changelog = []
47
+
48
+ attributes.each do |attr, value|
49
+ schema_attr = self.class.schema_attributes[attr]
50
+ raise ArgumentError.new("unknown attribute: #{attr}") unless schema_attr
51
+ if schema_attr[:settable]
52
+ self.send "#{attr}=", attributes[attr]
53
+ end
54
+ end
55
+
56
+ self.id = attributes[:id] || SecureRandom.uuid.gsub('-', '')
57
+ self.created_at = attributes[:created_at] || Time.now
58
+ self.updated_at = attributes[:updated_at] || Time.now
59
+
60
+ if changelog.size > 0
61
+ @changelog = changelog.map do |thing|
62
+ if thing.is_a? Change
63
+ thing
64
+ else
65
+ Change.new thing
66
+ end
67
+ end
68
+ end
69
+
70
+ self
71
+ end
72
+
73
+ def schema_attribute_change(attribute, old_value, new_value)
74
+ if self.class.schema_attributes[attribute][:settable]
75
+ self.updated_at = Time.now
76
+ @changelog << Change.new(:attribute => attribute, :old_value => old_value, :new_value => new_value)
77
+ end
78
+ end
79
+
80
+ def new?
81
+ @new
82
+ end
83
+
84
+ def <=>(other)
85
+ if self.class.schema_attributes.all? { |attr, opts| self.send(attr) == other.send(attr) }
86
+ r = 0
87
+ else
88
+ if self.created_at == other.created_at
89
+ r = self.id <=> other.id
90
+ else
91
+ r = self.created_at <=> other.created_at
92
+ end
93
+
94
+ # this clause should not return 0, we've already established inequality
95
+ #
96
+ r = -1 if r == 0
97
+ end
98
+
99
+ r
100
+ end
101
+
102
+ def inspect
103
+ fields = self.class.schema_attributes.map do |attr, opts|
104
+ "@#{attr}=#{self.send(attr).inspect}"
105
+ end.join ', '
106
+
107
+ "#<#{self.class}:0x%016x %s>" % [ object_id, fields ]
108
+ end
109
+
110
+ def to_s(opts={})
111
+ text = <<-EOT.strip
112
+ ID : #{id}
113
+ Created At : #{date(created_at)}
114
+ Updated At : #{date(updated_at)}
115
+
116
+ Summary : #{summary}
117
+ Kind : #{kind}
118
+ Status : #{status}
119
+ Priority : #{priority}
120
+ Owner : #{owner}
121
+
122
+ ---
123
+ #{description}
124
+ EOT
125
+
126
+ if opts[:changelog]
127
+ changelog_str = changelog.map { |c| %Q|# #{c.to_s.strip.gsub(/\n/, "\n# ")}| }.join("\n")
128
+ text << %Q|\n---\n\n#{changelog_str}|
129
+ end
130
+
131
+ text
132
+ end
133
+
134
+ def valid?(opts={})
135
+ valid = schema_valid?
136
+ error = schema_errors.first
137
+ raise Invalid.new("attribute #{error.first}: #{error[1].inspect} is not a valid value") if !valid && opts[:raise]
138
+ valid
139
+ end
140
+
141
+ def to_json(state=nil)
142
+ valid? :raise => true
143
+ hash = { :issue => self.to_hash, :changelog => changelog }
144
+ JSON.pretty_generate(hash)
145
+ end
146
+
147
+ def to_template
148
+ if new?
149
+ header = "# New Issue\n#"
150
+ body = TEMPLATE % self.to_hash
151
+ footer = ""
152
+ else
153
+ header =<<-EOT
154
+ # Edit Issue
155
+ #
156
+ # ID : #{id}
157
+ # Created At : #{created_at}
158
+ # Updated At : #{updated_at}
159
+ #
160
+ EOT
161
+ body = TEMPLATE % self.to_hash
162
+
163
+ footer =<<-EOT
164
+ # ChangeLog
165
+ #
166
+ #{changelog.map { |c| %Q|# #{c.to_s.strip.gsub(/\n/, "\n# ")}| }.join("\n")}
167
+ EOT
168
+ end
169
+
170
+ (header + "\n" + body + "\n\n" + footer).strip
171
+ end
172
+
173
+ def self.from_json(the_json)
174
+ begin
175
+ hash = JSON.parse(the_json)
176
+ rescue JSON::ParserError => e
177
+ raise Issue::Invalid.new(e.to_s)
178
+ end
179
+
180
+ Issue.new(hash['issue'], hash['changelog'])
181
+ end
182
+
183
+ def self.from_template(text)
184
+ issue = { :description => '' }
185
+ reading_description = false
186
+
187
+ text.lines.each_with_index do |line, index|
188
+ next if line =~ /^#/ || (!reading_description && line =~ /^\s*$/)
189
+
190
+ if line =~ /^---$/
191
+ # FIXME: this means that there can be multiple description blocks in the template!
192
+ #
193
+ reading_description = !reading_description
194
+ next
195
+ end
196
+
197
+ if !reading_description && line =~ /^(\w+)\s*:\s*(.*)$/
198
+ key, value = $1.downcase.to_sym, $2.strip
199
+
200
+ if schema_attributes.include?(key) && schema_attributes[key][:settable]
201
+ issue[key] = value
202
+ else
203
+ raise ArgumentError.new("Unknown Issue attribute: #{key} on line #{index+1}") unless schema_attributes.include?(key)
204
+ raise ArgumentError.new("Cannot set write-protected Issue attribute: #{key} on line #{index+1}")
205
+ end
206
+ elsif reading_description
207
+ issue[:description] += line
208
+ else
209
+ raise ArgumentError.new("Cannot parse line #{index+1}")
210
+ end
211
+ end
212
+
213
+ Issue.new(issue)
214
+ end
215
+
216
+ def update_from_template!(text)
217
+ new_issue = Issue.from_template(text)
218
+
219
+ attrs = self.class.schema_attributes.map do |attr, opts|
220
+ if opts[:settable] && self.send(attr) != (new_value = new_issue.send(attr))
221
+ self.send("#{attr}=", new_value)
222
+ end
223
+ end
224
+
225
+ self
226
+ end
227
+
228
+ private
229
+ def date(t)
230
+ t.strftime "%Y/%m/%d %H:%M:%S"
231
+ end
232
+
233
+ def dup
234
+ raise NoMethodError.new
235
+ end
236
+
237
+ def clone
238
+ raise NoMethodError.new
239
+ end
240
+ end
@@ -0,0 +1,208 @@
1
+ require 'time'
2
+
3
+ # Schema characteristics
4
+ #
5
+ # attributes have default validations, coercions, and transformations
6
+ #
7
+ # FIXME: document
8
+
9
+ module Schema
10
+ def self.included(base)
11
+ base.extend(ClassMethods)
12
+ end
13
+
14
+ def to_hash
15
+ Hash[self.class.schema_attributes.map do |attr, opts|
16
+ [ attr, send(attr) ]
17
+ end]
18
+ end
19
+
20
+ def schema_errors
21
+ @errors || []
22
+ end
23
+
24
+ def valid?
25
+ @errors = nil
26
+
27
+ self.class.schema_attributes.each do |attr, opts|
28
+ if opts[:validate].nil?
29
+ case opts[:class].to_s # can't case on opts[:class], because class of opts[:class] is always Class :-)
30
+ when 'String'
31
+ opts[:validate] = lambda { |v| v !~ /\A\s*\Z/ }
32
+ end
33
+ end
34
+
35
+ if opts[:validate]
36
+ value = eval(attr.to_s)
37
+
38
+ valid = if opts[:validate].is_a?(Array)
39
+ opts[:validate].include? value
40
+ elsif opts[:validate].is_a?(Proc)
41
+ opts[:validate].call(value)
42
+ end
43
+
44
+ unless valid
45
+ @errors = [ [ attr, value ] ]
46
+ return false
47
+ end
48
+ end
49
+ end
50
+
51
+ true
52
+ end
53
+
54
+ module ClassMethods
55
+ def schema_attributes
56
+ @schema_attrs
57
+ end
58
+
59
+ def schema_attr_remove(name)
60
+ raise KeyError.new("attribute #{name}: does not exist in class #{self.name}") unless @schema_attrs.include? name
61
+ @schema_attrs.delete(name)
62
+ self.send(:remove_method, name)
63
+ self.send(:remove_method, "#{name}=".to_s)
64
+ end
65
+
66
+ def schema_attr_replace(name, opts)
67
+ raise KeyError.new("attribute #{name}: does not exist in class #{self.name}") unless @schema_attrs.include? name
68
+ schema_attr_remove(name)
69
+ schema_attr(name, opts)
70
+ end
71
+
72
+ def schema_attr_update(name, opts)
73
+ raise KeyError.new("attribute #{name}: does not exist in class #{self.name}") unless @schema_attrs.include? name
74
+ raise KeyError.new("attribute #{name}: cannot update non-settable attribute") unless @schema_attrs[name][:settable]
75
+ schema_attr_replace(name, @schema_attrs[name].merge(opts))
76
+ end
77
+
78
+ def schema_attr(name, opts)
79
+ @schema_attrs ||= {}
80
+
81
+ raise TypeError.new("attribute #{name}: missing or invalid :class") unless opts[:class].is_a?(Class)
82
+
83
+ if opts[:default].nil?
84
+ opts[:default] = case opts[:class].to_s # can't case on opts[:class], because class of opts[:class] is always Class :-)
85
+ when 'String'
86
+ ''
87
+ when 'Fixnum'
88
+ 0
89
+ when 'Time'
90
+ lambda { Time.new }
91
+ else
92
+ raise ArgumentError.new("Sorry, no default default exists for #{opts[:class]}")
93
+ end
94
+ end
95
+
96
+ unless opts[:default].is_a?(opts[:class]) || opts[:default].is_a?(Proc)
97
+ raise TypeError.new("attribute #{name}: invalid :default")
98
+ end
99
+
100
+ if opts[:validate]
101
+ unless opts[:validate].is_a?(Array) || opts[:validate].is_a?(Proc)
102
+ raise ArgumentError.new("attribute #{name}: expecting Array or Proc for :validate")
103
+ end
104
+
105
+ if opts[:validate].is_a?(Array)
106
+ raise TypeError.new("attribute #{name}: wrong type in :validate Array") unless opts[:validate].all? { |v| v.is_a?(opts[:class]) }
107
+ end
108
+ end
109
+
110
+ raise ArgumentError.new("attribute #{name}: already exists") if @schema_attrs[name]
111
+
112
+ @schema_attrs[name] = opts
113
+
114
+ value_getter = if opts[:default].is_a?(Proc)
115
+ %Q(
116
+ opts = self.class.schema_attributes[:#{name}]
117
+ value = opts[:default].call
118
+ raise TypeError.new("attribute #{name}: expected type #{opts[:class]}, received \#{value.class}") unless opts[:class] == value.class
119
+ )
120
+ else
121
+ %Q(value = #{opts[:default].inspect})
122
+ end
123
+ module_eval %Q(
124
+ def #{name}
125
+ if @#{name}.nil?
126
+ #{value_getter}
127
+ self.#{name}= value
128
+ end
129
+ @#{name}
130
+ end
131
+ )
132
+
133
+ unless opts[:coerce] == false # possible values are false=no-coerce, nil=default-coerce, Proc=custom-coerce
134
+ case opts[:class].to_s # can't case on opts[:class], because class of opts[:class] is always Class :-)
135
+ when 'Fixnum'
136
+ unless opts[:coerce].is_a? Proc
137
+ # the default coercion for Fixnum
138
+ opts[:coerce] = lambda do |value|
139
+ unless value.is_a?(Fixnum) # FIXME: this "unless value.is_a?(same class as 'when')" is copy-pasta. fix it.
140
+ raise TypeError.new("attribute #{name}: cannot coerce from \#{value.class}") unless value.is_a?(String)
141
+ i = value.to_i
142
+ raise TypeError.new("attribute #{name}: failed to coerce from \#{value}") unless i.to_s == value
143
+ value = i
144
+ end
145
+ value
146
+ end
147
+ end
148
+ when 'Time'
149
+ unless opts[:coerce].is_a? Proc
150
+ # the default coercion for Time
151
+ opts[:coerce] = lambda do |value|
152
+ unless value.is_a?(Time) # FIXME: this "unless value.is_a?(same class as 'when')" is copy-pasta. fix it.
153
+ raise TypeError.new("attribute #{name}: cannot coerce from \#{value.class}") unless value.is_a?(String)
154
+ begin
155
+ value = Time.parse(value)
156
+ rescue ArgumentError
157
+ raise TypeError.new("attribute #{name}: cannot coerce from \#{value.class}")
158
+ end
159
+ end
160
+ value
161
+ end
162
+ end
163
+ end
164
+
165
+ coerce = 'value = opts[:coerce].call(value)' if opts[:coerce]
166
+ end
167
+
168
+ unless opts[:transform] == false # possible values are false=no-transform, nil=default-transform, Proc=custom-transform
169
+ case opts[:class].to_s # can't case on opts[:class], because class of opts[:class] is always Class :-)
170
+ when 'String'
171
+ unless opts[:transform].is_a? Proc
172
+ # the default transform for String: remove excess whitespace
173
+ opts[:transform] = lambda { |s| s.strip }
174
+ end
175
+ when 'Time'
176
+ unless opts[:transform].is_a? Proc
177
+ # the default transform for Time: remove subsecond precision. subsec precision is not recorded in to_s, so unless
178
+ # we scrub it out, the following happens:
179
+ # foo.a_time = Time.new
180
+ # Time.parse(foo.a_time.to_s) == foo.a_time # returns false most of the time!
181
+ opts[:transform] = lambda { |t| Time.new t.year, t.mon, t.day, t.hour, t.min, t.sec, t.utc_offset }
182
+ end
183
+ end
184
+
185
+ transform = 'value = opts[:transform].call(value)' if opts[:transform]
186
+ end
187
+
188
+ callback = %Q(
189
+ if self.respond_to? :schema_attribute_change
190
+ self.schema_attribute_change(:#{name}, @#{name}, value)
191
+ end
192
+ )
193
+
194
+ setter_method = %Q(
195
+ def #{name}=(value)
196
+ opts = self.class.schema_attributes[:#{name}]
197
+ #{coerce}
198
+ raise TypeError.new("attribute #{name}: expected type #{opts[:class]}, received \#{value.class}") unless opts[:class] == value.class
199
+ #{transform}
200
+ #{callback}
201
+ @#{name} = value
202
+ end
203
+ )
204
+ module_eval setter_method
205
+ module_eval "private :#{name}=" unless opts[:settable]
206
+ end
207
+ end
208
+ end