taco_it 1.3.1 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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