tpope-pickler 0.0.4 → 0.0.5

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc CHANGED
@@ -7,6 +7,7 @@ Synchronize user stories in Pivotal Tracker with Cucumber features.
7
7
  gem install tpope-pickler --source=http://gems.github.com
8
8
  echo "api_token: ..." > ~/.tracker.yml
9
9
  echo "project_id: ..." > ~/my/app/features/tracker.yml
10
+ pickler --help
10
11
 
11
12
  For details about the Pivotal Tracker API, including where to find your API
12
13
  token and project id, see http://www.pivotaltracker.com/help/api .
@@ -19,9 +20,7 @@ feature's body. Keep this in mind when entering stories into Pivotal Tracker.
19
20
 
20
21
  pickler pull
21
22
 
22
- Download all well formed stories to the features/ directory. Previously
23
- unseen stories will be given a numeric filename that you are encouraged to
24
- change.
23
+ Download all well formed stories to the features/ directory.
25
24
 
26
25
  pickler push
27
26
 
@@ -31,19 +30,21 @@ Upload all features with a tracker url in a comment on the first line.
31
30
 
32
31
  List all stories matching the given query.
33
32
 
34
- pickler show <id>
33
+ pickler start <story>
35
34
 
36
- Show details for the story referenced by the id.
35
+ Pull a given feature and change its state to started.
37
36
 
38
- pickler start <id> [basename]
37
+ pickler finish <story>
39
38
 
40
- Pull a given feature and change its state to started. If basename is given
41
- and no local file exists, features/basename.feature will be created in lieu
42
- of features/id.feature.
39
+ Push a given feature and change its state to finished.
43
40
 
44
- pickler finish <id>
41
+ pickler --help
45
42
 
46
- Push a given feature and change its state to finished.
43
+ Full list of commands.
44
+
45
+ pickler <command> --help
46
+
47
+ Further help for a given command.
47
48
 
48
49
  == Disclaimer
49
50
 
@@ -47,10 +47,8 @@ class Pickler
47
47
  end
48
48
 
49
49
  def pull(default = nil)
50
- body = "# http://www.pivotaltracker.com/story/show/#{id}\n" <<
51
- normalize_feature(story.to_s)
52
50
  filename = filename() || pickler.features_path("#{default||id}.feature")
53
- File.open(filename,'w') {|f| f.puts body}
51
+ File.open(filename,'w') {|f| f.puts story}
54
52
  @filename = filename
55
53
  end
56
54
 
@@ -84,24 +82,5 @@ class Pickler
84
82
  @story ||= @pickler.project.story(id) if id
85
83
  end
86
84
 
87
- protected
88
-
89
- def normalize_feature(body)
90
- return body unless ast = pickler.parser.parse(body)
91
- feature = ast.compile
92
- new = ''
93
- (feature.header.chomp << "\n").each_line do |l|
94
- new << ' ' unless new.empty?
95
- new << l.strip << "\n"
96
- end
97
- feature.scenarios.each do |scenario|
98
- new << "\n Scenario: #{scenario.name}\n"
99
- scenario.steps.each do |step|
100
- new << " #{step.keyword} #{step.name}\n"
101
- end
102
- end
103
- new
104
- end
105
-
106
85
  end
107
86
  end
@@ -1,15 +1,331 @@
1
+ require 'optparse'
2
+
1
3
  class Pickler
2
4
  class Runner
3
5
 
4
- attr_reader :argv
6
+ class Base
7
+ attr_reader :argv
8
+
9
+ def initialize(argv)
10
+ @argv = argv
11
+ @tty = $stdout.tty?
12
+ @opts = OptionParser.new
13
+ @opts.version = "0.0"
14
+ @opts.banner = "Usage: pickler #{self.class.command_name} #{self.class.banner_arguments}"
15
+ @opts.base.long["help"] = OptionParser::Switch::NoArgument.new do
16
+ help = @opts.help.chomp.chomp + "\n"
17
+ help += "\n#{self.class.description}" if self.class.description
18
+ puts help
19
+ @exit = 0
20
+ end
21
+ @opts.separator("")
22
+ end
23
+
24
+ def self.options
25
+ @options ||= []
26
+ end
27
+
28
+ def self.on(*args, &block)
29
+ options << args
30
+ define_method("option_#{args.object_id}", &block)
31
+ end
32
+
33
+ def self.banner_arguments(value = nil)
34
+ if value
35
+ @banner_arguments = value
36
+ else
37
+ @banner_arguments || (arity.zero? ? "" : "...")
38
+ end
39
+ end
40
+
41
+ def self.summary(value = nil)
42
+ if value
43
+ @summary = value
44
+ else
45
+ @summary
46
+ end
47
+ end
48
+
49
+ def self.description(value = nil)
50
+ if value
51
+ @description = value
52
+ else
53
+ @description || "#@summary."
54
+ end
55
+ end
56
+
57
+ def self.command_name
58
+ name.split('::').last.gsub(/(.)([A-Z])/) {"#$1-#$2"}.downcase
59
+ end
60
+
61
+ def self.method_name
62
+ command_name.gsub('-','_')
63
+ end
64
+
65
+ def self.process(&block)
66
+ define_method(:process, &block)
67
+ end
68
+
69
+ def self.arity
70
+ instance_method(:process).arity
71
+ end
72
+
73
+ def arity
74
+ self.class.arity
75
+ end
76
+
77
+ def pickler
78
+ @pickler ||= Pickler.new(Dir.getwd)
79
+ end
80
+
81
+ def abort(message)
82
+ raise Error, message
83
+ end
84
+
85
+ def too_many
86
+ abort "too many arguments"
87
+ end
88
+
89
+ def run
90
+ self.class.options.each do |arguments|
91
+ @opts.on(*arguments, &method("option_#{arguments.object_id}"))
92
+ end
93
+ begin
94
+ @opts.parse!(@argv)
95
+ rescue OptionParser::InvalidOption
96
+ abort $!.message
97
+ end
98
+ return @exit if @exit
99
+ minimum = arity < 0 ? -1 - arity : arity
100
+ if arity >= 0 && arity < @argv.size
101
+ too_many
102
+ elsif minimum > @argv.size
103
+ abort "not enough arguments"
104
+ end
105
+ process(*@argv)
106
+ end
107
+
108
+ def process(*argv)
109
+ pickler.send(self.class.method_name,*argv)
110
+ end
111
+
112
+ def color?
113
+ case pickler.config["color"]
114
+ when "always" then true
115
+ when "never" then false
116
+ else
117
+ @tty && RUBY_PLATFORM !~ /mswin|mingw/
118
+ end
119
+ end
120
+
121
+ def puts_summary(story)
122
+ summary = "%6d " % story.id
123
+ type = story.estimate || TYPE_SYMBOLS[story.story_type]
124
+ state = STATE_SYMBOLS[story.current_state]
125
+ if color?
126
+ summary << "\e[3#{STATE_COLORS[story.current_state]}m#{state}\e[00m "
127
+ summary << "\e[01;3#{TYPE_COLORS[story.story_type]}m#{type}\e[00m "
128
+ else
129
+ summary << "#{state} #{type} "
130
+ end
131
+ summary << story.name
132
+ puts summary
133
+ end
134
+
135
+ def paginated_output
136
+ stdout = $stdout
137
+ if @tty && pager = pickler.config["pager"]
138
+ # Modeled after git
139
+ ENV["LESS"] ||= "FRSX"
140
+ IO.popen(pager,"w") do |io|
141
+ $stdout = io
142
+ yield
143
+ end
144
+ else
145
+ yield
146
+ end
147
+ ensure
148
+ $stdout = stdout
149
+ end
5
150
 
6
- def pickler
7
- @pickler ||= Pickler.new(Dir.getwd)
151
+ end
152
+
153
+ def self.[](command)
154
+ klass_name = command.to_s.capitalize.gsub(/[-_](.)/) { $1.upcase }
155
+ if klass_name =~ /^[A-Z]\w*$/ && const_defined?(klass_name)
156
+ klass = const_get(klass_name)
157
+ if Class === klass && klass < Base
158
+ return klass
159
+ end
160
+ end
161
+ end
162
+
163
+ def self.commands
164
+ constants.map {|c| Runner.const_get(c)}.select {|c| Class === c && c < Runner::Base}.sort_by {|r| r.command_name}.uniq
165
+ end
166
+
167
+ def self.command(name, &block)
168
+ const_set(name.to_s.capitalize.gsub(/[-_](.)/) { $1.upcase },Class.new(Base,&block))
169
+ end
170
+
171
+ command :show do
172
+ banner_arguments "<story>"
173
+ summary "Show details for a story"
174
+
175
+ process do |*args|
176
+ case args.size
177
+ when 0
178
+ puts "#{pickler.project_id} #{pickler.project.name}"
179
+ when 1
180
+ story = pickler.project.story(args.first)
181
+ paginated_output do
182
+ puts story
183
+ end
184
+ else
185
+ too_many
186
+ end
187
+ end
188
+ end
189
+
190
+ command :search do
191
+ banner_arguments "[query]"
192
+ summary "List all stories matching a query"
193
+
194
+ def modifications
195
+ @modifications ||= {}
196
+ end
197
+ [:label, :type, :state].each do |o|
198
+ on "--#{o} #{o.to_s.upcase}" do |value|
199
+ modifications[o] = value
200
+ end
201
+ end
202
+ [:requester, :owner, :mywork].each do |o|
203
+ on "--#{o} USERNAME" do |value|
204
+ modifications[o] = value
205
+ end
206
+ end
207
+ on "--[no-]includedone", "include accepted stories" do |value|
208
+ modifications[:includedone] = value
209
+ end
210
+
211
+ attr_writer :current
212
+ on "-c", "--current", "filter results to current iteration" do |b|
213
+ self.current = b
214
+ end
215
+
216
+ process do |*argv|
217
+ argv << modifications unless modifications.empty?
218
+ if argv == [{:includedone => true}]
219
+ # Bypass the 200 search results limitation
220
+ stories = pickler.project.stories
221
+ else
222
+ stories = pickler.project.stories(*argv)
223
+ end
224
+ stories.reject! {|s| !s.current?} if argv.empty? || @current
225
+ paginated_output do
226
+ stories.each do |story|
227
+ puts_summary story
228
+ end
229
+ end
230
+ end
231
+ end
232
+
233
+ command :push do
234
+ banner_arguments "[story] ..."
235
+ summary "Upload stories"
236
+ description <<-EOF
237
+ Upload the given story or all features with a tracker url in a comment on the
238
+ first line.
239
+ EOF
240
+ end
241
+
242
+ command :pull do
243
+ banner_arguments "[story] ..."
244
+ summary "Download stories"
245
+ description <<-EOF
246
+ Download the given story or all well formed stories to the features/ directory.
247
+ Previously unseen stories will be given a numeric filename that you are
248
+ encouraged to change.
249
+ EOF
250
+ end
251
+
252
+ command :start do
253
+ banner_arguments "<story> [basename]"
254
+ summary "Pull a story and mark it started"
255
+ description <<-EOF
256
+ Pull a given story and change its state to started. If basename is given
257
+ and no local file exists, features/basename.feature will be created in lieu
258
+ of features/id.feature.
259
+ EOF
260
+
261
+ process do |story_id, *args|
262
+ pickler.start(story_id, args.first)
263
+ end
264
+ end
265
+
266
+ command :finish do
267
+ banner_arguments "<story>"
268
+ summary "Push a story and mark it finished"
269
+
270
+ process do |story_id|
271
+ super
272
+ end
273
+ end
274
+
275
+ command :deliver do
276
+ banner_arguments "[story] ..."
277
+ summary "Mark stories delivered"
278
+ on "--all-finished", "deliver all finished stories" do
279
+ @all = true
280
+ end
281
+ process do |*args|
282
+ if @all
283
+ pickler.deliver_all_finished_stories
284
+ end
285
+ args.each do |arg|
286
+ pickler.story(arg).transition!('delivered')
287
+ end
288
+ end
289
+ end
290
+
291
+ command :browse do
292
+ banner_arguments "[story]"
293
+ summary "Open a story in the web browser"
294
+ description <<-EOF
295
+ Open project or a story in the web browser.
296
+
297
+ Requires launchy (gem install launchy).
298
+ EOF
299
+
300
+ on "--dashboard" do
301
+ @special = "dashboard"
302
+ end
303
+ on "--faq" do
304
+ @special = "help"
305
+ end
306
+ on "--profile", "get your API Token here" do
307
+ @special = "profile"
308
+ end
309
+ on "--time", "not publicly available" do
310
+ @special = "time_shifts?project=#{pickler.project_id}"
311
+ end
312
+
313
+ process do |*args|
314
+ too_many if args.size > 1 || @special && args.first
315
+ if args.first
316
+ url = pickler.story(args.first).url
317
+ elsif @special
318
+ url = "http://www.pivotaltracker.com/#@special"
319
+ else
320
+ url = "http://www.pivotaltracker.com/projects/#{pickler.project_id}/stories"
321
+ end
322
+ require 'launchy'
323
+ Launchy.open(url)
324
+ end
8
325
  end
9
326
 
10
327
  def initialize(argv)
11
328
  @argv = argv
12
- @tty = $stdout.tty?
13
329
  end
14
330
 
15
331
  COLORS = {
@@ -58,73 +374,22 @@ class Pickler
58
374
  "bug" => "/"
59
375
  }
60
376
 
61
- def color?
62
- case pickler.config["color"]
63
- when "always" then true
64
- when "never" then false
65
- else
66
- @tty && RUBY_PLATFORM !~ /mswin|mingw/
67
- end
68
- end
69
-
70
- def puts_summary(story)
71
- summary = "%6d " % story.id
72
- type = story.estimate || TYPE_SYMBOLS[story.story_type]
73
- state = STATE_SYMBOLS[story.current_state]
74
- if color?
75
- summary << "\e[3#{STATE_COLORS[story.current_state]}m#{state}\e[00m "
76
- summary << "\e[01;3#{TYPE_COLORS[story.story_type]}m#{type}\e[00m "
77
- else
78
- summary << "#{state} #{type} "
79
- end
80
- summary << story.name
81
- puts summary
82
- end
83
-
84
- def paginated_output
85
- stdout = $stdout
86
- if @tty && pager = pickler.config["pager"]
87
- # Modeled after git
88
- ENV["LESS"] ||= "FRSX"
89
- IO.popen(pager,"w") do |io|
90
- $stdout = io
91
- yield
92
- end
93
- else
94
- yield
95
- end
96
- ensure
97
- $stdout = stdout
98
- end
99
-
100
377
  def run
101
- case first = argv.shift
102
- when 'show', /^\d+$/
103
- story = pickler.project.story(first == 'show' ? argv.shift : first)
104
- paginated_output do
105
- puts story
106
- end
107
- when 'search'
108
- stories = pickler.project.stories(*argv)
109
- stories.reject! {|s| %w(unscheduled unstarted accepted).include?(s.current_state)} if argv.empty?
110
- paginated_output do
111
- stories.each do |story|
112
- puts_summary story
113
- end
378
+ command = @argv.shift
379
+ if klass = self.class[command]
380
+ result = klass.new(@argv).run
381
+ exit result.respond_to?(:to_int) ? result.to_int : 0
382
+ elsif ['help', '--help', '-h', '', nil].include?(command)
383
+ puts "usage: pickler <command> [options] [arguments]"
384
+ puts
385
+ puts "Commands:"
386
+ self.class.commands.each do |command|
387
+ puts " %-19s %s" % [command.command_name, command.summary]
114
388
  end
115
- when 'push'
116
- pickler.push(*argv)
117
- when 'pull'
118
- pickler.pull(*argv)
119
- when 'start'
120
- pickler.start(argv.first,argv[1])
121
- when 'finish'
122
- pickler.finish(argv.first)
123
- when 'help', '--help', '-h', '', nil
124
- puts 'pickler commands: [show|start|finish] <id>, search <query>, push, pull'
389
+ puts
390
+ puts "Run pickler <command> --help for help with a given command"
125
391
  else
126
- $stderr.puts "pickler: unknown command #{first}"
127
- exit 1
392
+ raise Error, "Unknown pickler command #{command}"
128
393
  end
129
394
  rescue Pickler::Error
130
395
  $stderr.puts "#$!"
@@ -18,6 +18,10 @@ class Pickler
18
18
  start...finish
19
19
  end
20
20
 
21
+ def include?(date)
22
+ range.include?(date)
23
+ end
24
+
21
25
  def succ
22
26
  self.class.new(project, 'number' => number.succ.to_s, 'start' => @attributes['finish'], 'finish' => (finish + (finish - start)).strftime("%b %d, %Y"))
23
27
  end
@@ -11,7 +11,7 @@ class Pickler
11
11
  end
12
12
 
13
13
  def to_xml
14
- @attributes.to_xml(:root => 'note')
14
+ @attributes.to_xml(:dasherize => false, :root => 'note')
15
15
  end
16
16
 
17
17
  def inspect
@@ -28,6 +28,10 @@ class Pickler
28
28
  raise Pickler::Tracker::Error, Array(error).join("\n"), caller unless error == true
29
29
  end
30
30
 
31
+ def current?(as_of = Date.today)
32
+ iteration && iteration.include?(as_of)
33
+ end
34
+
31
35
  def complete?
32
36
  %w(finished delivered accepted).include?(current_state)
33
37
  end
@@ -84,14 +88,16 @@ class Pickler
84
88
 
85
89
  def comment!(body)
86
90
  raise ArgumentError if body.strip.empty? || body.size > 5000
87
- response = tracker.request_xml(:post, "#{resource_url}/notes",{:text => body}.to_xml(:root => 'note'))
91
+ response = tracker.request_xml(:post, "#{resource_url}/notes",{:text => body}.to_xml(:dasherize => false, :root => 'note'))
88
92
  Note.new(self, response["note"])
89
93
  end
90
94
 
91
95
  def to_xml
92
- hash = @attributes.except("id","url","iteration","notes","labels")
96
+ hash = @attributes.reject do |k,v|
97
+ !%w(current_state deadline description estimate name owned_by requested_by story_type).include?(k)
98
+ end
93
99
  hash["labels"] = Array(@attributes["labels"]).join(", ")
94
- hash.to_xml(:root => "story")
100
+ hash.to_xml(:dasherize => false, :root => "story")
95
101
  end
96
102
 
97
103
  def destroy
@@ -12,7 +12,8 @@ class Pickler
12
12
  attr_reader :token
13
13
 
14
14
  def initialize(token)
15
- require 'active_support'
15
+ require 'active_support/core_ext/blank'
16
+ require 'active_support/core_ext/hash'
16
17
  @token = token
17
18
  end
18
19
 
@@ -52,8 +53,11 @@ class Pickler
52
53
  end
53
54
 
54
55
  class Abstract
55
- def initialize(attributes)
56
- @attributes = (attributes || {}).stringify_keys
56
+ def initialize(attributes = {})
57
+ @attributes = {}
58
+ (attributes || {}).each do |k,v|
59
+ @attributes[k.to_s] = v
60
+ end
57
61
  yield self if block_given?
58
62
  end
59
63
 
@@ -78,7 +82,7 @@ class Pickler
78
82
  reader :id
79
83
 
80
84
  def to_xml(options = nil)
81
- @attributes.to_xml({:root => self.class.name.split('::').last.downcase}.merge(options||{}))
85
+ @attributes.to_xml({:dasherize => false, :root => self.class.name.split('::').last.downcase}.merge(options||{}))
82
86
  end
83
87
 
84
88
  end
data/lib/pickler.rb CHANGED
@@ -5,6 +5,10 @@ class Pickler
5
5
  class Error < RuntimeError
6
6
  end
7
7
 
8
+ autoload :Runner, 'pickler/runner'
9
+ autoload :Feature, 'pickler/feature'
10
+ autoload :Tracker, 'pickler/tracker'
11
+
8
12
  def self.config
9
13
  @config ||= {'api_token' => ENV["TRACKER_API_TOKEN"]}.merge(
10
14
  if File.exist?(path = File.expand_path('~/.tracker.yml'))
@@ -14,7 +18,6 @@ class Pickler
14
18
  end
15
19
 
16
20
  def self.run(argv)
17
- require 'pickler/runner'
18
21
  Runner.new(argv).run
19
22
  end
20
23
 
@@ -91,7 +94,7 @@ class Pickler
91
94
  def pull(*args)
92
95
  if args.empty?
93
96
  args = project.stories(scenario_word, :includedone => true).reject do |s|
94
- s.current_state == 'unstarted'
97
+ %(unscheduled unstarted).include?(s.current_state)
95
98
  end.select do |s|
96
99
  s.to_s =~ /^\s*#{Regexp.escape(scenario_word)}:/ && parser.parse(s.to_s)
97
100
  end
@@ -120,6 +123,3 @@ class Pickler
120
123
  protected
121
124
 
122
125
  end
123
-
124
- require 'pickler/feature'
125
- require 'pickler/tracker'
data/pickler.gemspec CHANGED
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = "pickler"
3
- s.version = "0.0.4"
3
+ s.version = "0.0.5"
4
4
 
5
5
  s.summary = "PIvotal traCKer Liaison to cucumbER"
6
6
  s.description = "Synchronize between Cucumber and Pivotal Tracker"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tpope-pickler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.4
4
+ version: 0.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tim Pope