schlick-pickler 0.0.6.1

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,20 @@
1
+ Copyright (c) 2008 Tim Pope
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,62 @@
1
+ = Pickler
2
+
3
+ Synchronize user stories in Pivotal Tracker with Cucumber features.
4
+
5
+ If you aren't using Cucumber, you can still use pickler as a Pivotal Tracker
6
+ command line client, provided you humor it with a features/ directory
7
+ containing a tracker.yml file.
8
+
9
+ == Getting started
10
+
11
+ gem install tpope-pickler --source=http://gems.github.com
12
+ echo "api_token: ..." > ~/.tracker.yml
13
+ echo "project_id: ..." > ~/my/app/features/tracker.yml
14
+ echo "ssl: [true|false]" >> ~/my/app/features/tracker.yml
15
+ pickler --help
16
+
17
+ "ssl" defaults to false if not configured in the yml file.
18
+
19
+ For details about the Pivotal Tracker API, including where to find your API
20
+ token and project id, see http://www.pivotaltracker.com/help/api .
21
+
22
+ The pull and push commands map the story's name into the "Feature: ..." line
23
+ and the story's description with an additional two space indent into the
24
+ feature's body. Keep this in mind when entering stories into Pivotal Tracker.
25
+
26
+ == Usage
27
+
28
+ pickler pull
29
+
30
+ Download all well formed stories to the features/ directory.
31
+
32
+ pickler push
33
+
34
+ Upload all features with a tracker url in a comment on the first line.
35
+
36
+ pickler search <query>
37
+
38
+ List all stories matching the given query.
39
+
40
+ pickler start <story>
41
+
42
+ Pull a given feature and change its state to started.
43
+
44
+ pickler finish <story>
45
+
46
+ Push a given feature and change its state to finished.
47
+
48
+ pickler --help
49
+
50
+ Full list of commands.
51
+
52
+ pickler <command> --help
53
+
54
+ Further help for a given command.
55
+
56
+ == Disclaimer
57
+
58
+ No warranties, expressed or implied.
59
+
60
+ Notably, the push and pull commands are quite happy to blindly clobber
61
+ features if so instructed. Pivotal Tracker has a history to recover things
62
+ server side.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $:.unshift(File.join(File.dirname(File.dirname(__FILE__)),'lib'))
4
+ begin; require 'rubygems'; rescue LoadError; end
5
+ require 'pickler'
6
+
7
+ Pickler.run(ARGV)
@@ -0,0 +1,136 @@
1
+ require 'yaml'
2
+
3
+ class Pickler
4
+
5
+ class Error < RuntimeError
6
+ end
7
+
8
+ autoload :Runner, 'pickler/runner'
9
+ autoload :Feature, 'pickler/feature'
10
+ autoload :Tracker, 'pickler/tracker'
11
+
12
+ def self.config
13
+ @config ||= {'api_token' => ENV["TRACKER_API_TOKEN"]}.merge(
14
+ if File.exist?(path = File.expand_path('~/.tracker.yml'))
15
+ YAML.load_file(path)
16
+ end || {}
17
+ )
18
+ end
19
+
20
+ def self.run(argv)
21
+ Runner.new(argv).run
22
+ end
23
+
24
+ attr_reader :directory
25
+
26
+ def initialize(path = '.')
27
+ @lang = 'en'
28
+ @directory = File.expand_path(path)
29
+ until File.directory?(File.join(@directory,'features'))
30
+ if @directory == File.dirname(@directory)
31
+ raise Error, 'Project not found. Make sure you have a features/ directory.', caller
32
+ end
33
+ @directory = File.dirname(@directory)
34
+ end
35
+ end
36
+
37
+ def features_path(*subdirs)
38
+ File.join(@directory,'features',*subdirs)
39
+ end
40
+
41
+ def config_file
42
+ features_path('tracker.yml')
43
+ end
44
+
45
+ def config
46
+ @config ||= File.exist?(config_file) && YAML.load_file(config_file) || {}
47
+ self.class.config.merge(@config)
48
+ end
49
+
50
+ def real_name
51
+ config["real_name"] || (require 'etc'; Etc.getpwuid.gecos.split(',').first)
52
+ end
53
+
54
+ def new_story(attributes = {}, &block)
55
+ attributes = attributes.inject('requested_by' => real_name) do |h,(k,v)|
56
+ h.update(k.to_s => v)
57
+ end
58
+ project.new_story(attributes, &block)
59
+ end
60
+
61
+ def stories(*args)
62
+ project.stories(*args)
63
+ end
64
+
65
+ def name
66
+ project.name
67
+ end
68
+
69
+ def iteration_length
70
+ project.iteration_length
71
+ end
72
+
73
+ def point_scale
74
+ project.point_scale
75
+ end
76
+
77
+ def week_start_day
78
+ project.week_start_day
79
+ end
80
+
81
+ def deliver_all_finished_stories
82
+ project.deliver_all_finished_stories
83
+ end
84
+
85
+ def parser
86
+ require 'cucumber'
87
+ require "cucumber/treetop_parser/feature_#@lang"
88
+ Cucumber.load_language(@lang)
89
+ @parser ||= Cucumber::TreetopParser::FeatureParser.new
90
+ end
91
+
92
+ def project_id
93
+ config["project_id"] || (self.class.config["projects"]||{})[File.basename(@directory)]
94
+ end
95
+
96
+ def project
97
+ @project ||= Dir.chdir(@directory) do
98
+ unless token = config['api_token']
99
+ raise Error, 'echo api_token: ... > ~/.tracker.yml'
100
+ end
101
+ unless id = project_id
102
+ raise Error, 'echo project_id: ... > features/tracker.yml'
103
+ end
104
+ ssl = config['ssl']
105
+ Tracker.new(token, ssl).project(id)
106
+ end
107
+ end
108
+
109
+ def scenario_word
110
+ parser
111
+ Cucumber.language['scenario']
112
+ end
113
+
114
+ def local_features
115
+ Dir[features_path('**','*.feature')].map {|f|feature(f)}.select {|f|f.id}
116
+ end
117
+
118
+ def scenario_features
119
+ project.stories(scenario_word, :includedone => true).reject do |s|
120
+ %(unscheduled unstarted).include?(s.current_state)
121
+ end.select do |s|
122
+ s.to_s =~ /^\s*#{Regexp.escape(scenario_word)}:/ && parser.parse(s.to_s)
123
+ end
124
+ end
125
+
126
+ def feature(string)
127
+ string.kind_of?(Feature) ? string : Feature.new(self,string)
128
+ end
129
+
130
+ def story(string)
131
+ feature(string).story
132
+ end
133
+
134
+ protected
135
+
136
+ end
@@ -0,0 +1,95 @@
1
+ class Pickler
2
+ class Feature
3
+ URL_REGEX = %r{\bhttp://www\.pivotaltracker\.com/\S*/(\d+)\b}
4
+ attr_reader :pickler
5
+
6
+ def initialize(pickler, identifier)
7
+ @pickler = pickler
8
+ case identifier
9
+ when nil, /^\s+$/
10
+ raise Error, "No feature given"
11
+
12
+ when Pickler::Tracker::Story
13
+ @story = identifier
14
+ @id = @story.id
15
+
16
+ when Integer
17
+ @id = identifier
18
+
19
+ when /^#{URL_REGEX}$/, /^(\d+)$/
20
+ @id = $1.to_i
21
+
22
+ when /\.feature$/
23
+ if File.exist?(identifier)
24
+ @filename = identifier
25
+ end
26
+
27
+ else
28
+ if File.exist?(path = pickler.features_path("#{identifier}.feature"))
29
+ @filename = path
30
+ end
31
+
32
+ end or raise Error, "Unrecogizable feature #{identifier}"
33
+ end
34
+
35
+ def local_body
36
+ File.read(filename) if filename
37
+ end
38
+
39
+ def filename
40
+ unless defined?(@filename)
41
+ @filename = Dir[pickler.features_path("**","*.feature")].detect do |f|
42
+ File.read(f)[/#\s*#{URL_REGEX}/,1].to_i == @id
43
+ end
44
+ end
45
+ @filename
46
+ end
47
+
48
+ def to_s
49
+ local_body || story.to_s
50
+ end
51
+
52
+ def pull(default = nil)
53
+ filename = filename() || pickler.features_path("#{default||id}.feature")
54
+ File.open(filename,'w') {|f| f.puts story}
55
+ @filename = filename
56
+ end
57
+
58
+ def start(default = nil)
59
+ story.transition!("started") if story.startable?
60
+ if filename || default
61
+ pull(default)
62
+ end
63
+ end
64
+
65
+ def push
66
+ return if story.to_s == local_body.to_s
67
+ story.to_s = local_body
68
+ story.save
69
+ end
70
+
71
+ def finish
72
+ if filename
73
+ story.finish
74
+ story.to_s = local_body
75
+ story.save
76
+ else
77
+ story.finish!
78
+ end
79
+ end
80
+
81
+ def id
82
+ unless defined?(@id)
83
+ @id = if id = local_body.to_s[/#\s*#{URL_REGEX}/,1]
84
+ id.to_i
85
+ end
86
+ end
87
+ @id
88
+ end
89
+
90
+ def story
91
+ @story ||= @pickler.project.story(id) if id
92
+ end
93
+
94
+ end
95
+ end
@@ -0,0 +1,502 @@
1
+ require 'optparse'
2
+
3
+ class Pickler
4
+ class Runner
5
+
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 colorize(code, string)
122
+ if color?
123
+ "\e[#{code}m#{string}\e[00m"
124
+ else
125
+ string.to_s
126
+ end
127
+ end
128
+
129
+ def puts_summary(story)
130
+ summary = "%6d " % story.id
131
+ type = story.estimate || TYPE_SYMBOLS[story.story_type]
132
+ state = STATE_SYMBOLS[story.current_state]
133
+ summary << colorize("3#{STATE_COLORS[story.current_state]}", state) << ' '
134
+ summary << colorize("01;3#{TYPE_COLORS[story.story_type]}", type) << ' '
135
+ summary << story.name
136
+ puts summary
137
+ end
138
+
139
+ def puts_full(story)
140
+ puts colorize("01;3#{TYPE_COLORS[story.story_type]}", story.name)
141
+ puts "Type: #{story.story_type}".rstrip
142
+ if story.story_type == "release"
143
+ puts "Deadline: #{story.deadline}".rstrip
144
+ else
145
+ puts "Estimate: #{story.estimate}".rstrip
146
+ end
147
+ puts "State: #{story.current_state}".rstrip
148
+ puts "Labels: #{story.labels.join(', ')}".rstrip
149
+ puts "Requester: #{story.requested_by}".rstrip
150
+ puts "Owner: #{story.owned_by}".rstrip
151
+ puts "URL: #{story.url}".rstrip
152
+ puts unless story.description.blank?
153
+ story.description_lines.each do |line|
154
+ puts " #{line}".rstrip
155
+ end
156
+ story.notes.each do |note|
157
+ puts
158
+ puts " #{colorize('01', note.author)} (#{note.date})"
159
+ puts *note.lines(72).map {|l| " #{l}".rstrip}
160
+ end
161
+ end
162
+
163
+ def paginated_output
164
+ stdout = $stdout
165
+ if @tty && pager = pickler.config["pager"]
166
+ # Modeled after git
167
+ ENV["LESS"] ||= "FRSX"
168
+ IO.popen(pager,"w") do |io|
169
+ $stdout = io
170
+ yield
171
+ end
172
+ else
173
+ yield
174
+ end
175
+ rescue Errno::EPIPE
176
+ ensure
177
+ $stdout = stdout
178
+ end
179
+
180
+ end
181
+
182
+ def self.[](command)
183
+ klass_name = command.to_s.capitalize.gsub(/[-_](.)/) { $1.upcase }
184
+ if klass_name =~ /^[A-Z]\w*$/ && const_defined?(klass_name)
185
+ klass = const_get(klass_name)
186
+ if Class === klass && klass < Base
187
+ return klass
188
+ end
189
+ end
190
+ end
191
+
192
+ def self.commands
193
+ constants.map {|c| Runner.const_get(c)}.select {|c| Class === c && c < Runner::Base}.sort_by {|r| r.command_name}.uniq
194
+ end
195
+
196
+ def self.command(name, &block)
197
+ const_set(name.to_s.capitalize.gsub(/[-_](.)/) { $1.upcase },Class.new(Base,&block))
198
+ end
199
+
200
+ command :show do
201
+ banner_arguments "<story>"
202
+ summary "Show details for a story"
203
+
204
+ process do |*args|
205
+ case args.size
206
+ when 0
207
+ puts "#{pickler.project_id} #{pickler.project.name}"
208
+ when 1
209
+ story = pickler.story(args.first)
210
+ paginated_output do
211
+ puts_full story
212
+ end
213
+ else
214
+ too_many
215
+ end
216
+ end
217
+ end
218
+
219
+ command :search do
220
+ banner_arguments "[query]"
221
+ summary "List all stories matching a query"
222
+
223
+ def modifications
224
+ @modifications ||= {}
225
+ end
226
+ [:label, :type, :state].each do |o|
227
+ on "--#{o} #{o.to_s.upcase}" do |value|
228
+ modifications[o] = value
229
+ end
230
+ end
231
+ [:requester, :owner, :mywork].each do |o|
232
+ on "--#{o}[=USERNAME]" do |value|
233
+ modifications[o] = value || pickler.real_name
234
+ end
235
+ end
236
+ on "--[no-]includedone", "include accepted stories" do |value|
237
+ modifications[:includedone] = value
238
+ @iterations ||= []
239
+ @iterations << :done?
240
+ end
241
+
242
+ on "-b", "--backlog", "filter results to future iterations" do |c|
243
+ @iterations ||= []
244
+ @iterations << :backlog?
245
+ end
246
+
247
+ on "-c", "--current", "filter results to current iteration" do |b|
248
+ @iterations ||= []
249
+ @iterations << :current?
250
+ end
251
+
252
+ on "--[no-]full", "show full story, not a summary line" do |b|
253
+ @full = b
254
+ end
255
+
256
+ process do |*argv|
257
+ argv << modifications unless modifications.empty?
258
+ if argv == [{:includedone => true}]
259
+ # Bypass the 200 search results limitation
260
+ stories = pickler.project.stories
261
+ else
262
+ stories = pickler.project.stories(*argv)
263
+ end
264
+ if @iterations && @iterations != [:done?]
265
+ stories.reject! {|s| !@iterations.any? {|i| s.send(i)}}
266
+ end
267
+ paginated_output do
268
+ first = true
269
+ stories.each do |story|
270
+ if @full
271
+ puts unless first
272
+ puts_full story
273
+ else
274
+ puts_summary story
275
+ end
276
+ first = false
277
+ end
278
+ end
279
+ end
280
+ end
281
+
282
+ command :push do
283
+ banner_arguments "[story] ..."
284
+ summary "Upload stories"
285
+ description <<-EOF
286
+ Upload the given story or all features with a tracker url in a comment on the
287
+ first line.
288
+ EOF
289
+
290
+ process do |*args|
291
+ args.replace(pickler.local_features) if args.empty?
292
+ args.each do |arg|
293
+ pickler.feature(arg).push
294
+ end
295
+ end
296
+ end
297
+
298
+ command :pull do
299
+ banner_arguments "[story] ..."
300
+ summary "Download stories"
301
+ description <<-EOF
302
+ Download the given story or all well formed stories to the features/ directory.
303
+ Previously unseen stories will be given a numeric filename that you are
304
+ encouraged to change.
305
+ EOF
306
+
307
+ process do |*args|
308
+ args.replace(pickler.scenario_features) if args.empty?
309
+ args.each do |arg|
310
+ pickler.feature(arg).pull
311
+ end
312
+ end
313
+ end
314
+
315
+ command :start do
316
+ banner_arguments "<story> [basename]"
317
+ summary "Pull a story and mark it started"
318
+ description <<-EOF
319
+ Pull a given story and change its state to started. If basename is given
320
+ and no local file exists, features/basename.feature will be created.
321
+ EOF
322
+
323
+ process do |story, *args|
324
+ pickler.feature(story).start(args.first)
325
+ end
326
+ end
327
+
328
+ command :finish do
329
+ banner_arguments "<story>"
330
+ summary "Push a story and mark it finished"
331
+
332
+ process do |story|
333
+ pickler.feature(story).finish
334
+ end
335
+ end
336
+
337
+ command :deliver do
338
+ banner_arguments "[story] ..."
339
+ summary "Mark stories delivered"
340
+ on "--all-finished", "deliver all finished stories" do
341
+ @all = true
342
+ end
343
+ process do |*args|
344
+ if @all
345
+ pickler.deliver_all_finished_stories
346
+ end
347
+ args.each do |arg|
348
+ pickler.story(arg).transition!('delivered')
349
+ end
350
+ end
351
+ end
352
+
353
+ command :unstart do
354
+ banner_arguments "[story] ..."
355
+ summary "Mark stories unstarted"
356
+ on "--all-started", "unstart all started stories" do
357
+ @all = true
358
+ end
359
+ process do |*args|
360
+ if @all
361
+ pickler.project.stories(:state => "started").each do |story|
362
+ story.transition!('unstarted')
363
+ end
364
+ end
365
+ args.each do |arg|
366
+ pickler.story(arg).transition!('unstarted')
367
+ end
368
+ end
369
+ end
370
+
371
+ command :unschedule do
372
+ banner_arguments "[story] ..."
373
+ summary "Move stories to icebox"
374
+ process do |*args|
375
+ args.each do |arg|
376
+ pickler.story(arg).transition!('unscheduled')
377
+ end
378
+ end
379
+ end
380
+
381
+ command :browse do
382
+ banner_arguments "[story]"
383
+ summary "Open a story in the web browser"
384
+ description <<-EOF
385
+ Open project or a story in the web browser.
386
+
387
+ Requires launchy (gem install launchy).
388
+ EOF
389
+
390
+ on "--dashboard" do
391
+ @special = "dashboard"
392
+ end
393
+ on "--faq" do
394
+ @special = "help"
395
+ end
396
+ on "--profile", "get your API Token here" do
397
+ @special = "profile"
398
+ end
399
+ on "--time", "not publicly available" do
400
+ @special = "time_shifts?project=#{pickler.project_id}"
401
+ end
402
+
403
+ process do |*args|
404
+ too_many if args.size > 1 || @special && args.first
405
+ if args.first
406
+ url = pickler.story(args.first).url
407
+ elsif @special
408
+ url = "http://www.pivotaltracker.com/#@special"
409
+ else
410
+ url = "http://www.pivotaltracker.com/projects/#{pickler.project_id}/stories"
411
+ end
412
+ require 'launchy'
413
+ Launchy.open(url)
414
+ end
415
+ end
416
+
417
+ command :comment do
418
+ banner_arguments "<story> <paragraph> ..."
419
+ summary "Post a comment to a story"
420
+
421
+ process do |story, *paragraphs|
422
+ pickler.story(story).comment!(paragraphs.join("\n\n"))
423
+ end
424
+ end
425
+
426
+ def initialize(argv)
427
+ @argv = argv
428
+ end
429
+
430
+ COLORS = {
431
+ :black => 0,
432
+ :red => 1,
433
+ :green => 2,
434
+ :yellow => 3,
435
+ :blue => 4,
436
+ :magenta => 5,
437
+ :cyan => 6,
438
+ :white => 7
439
+ }
440
+
441
+ STATE_COLORS = {
442
+ nil => COLORS[:black],
443
+ "rejected" => COLORS[:red],
444
+ "accepted" => COLORS[:green],
445
+ "delivered" => COLORS[:yellow],
446
+ "unscheduled" => COLORS[:white],
447
+ "started" => COLORS[:magenta],
448
+ "finished" => COLORS[:cyan],
449
+ "unstarted" => COLORS[:blue]
450
+ }
451
+
452
+ STATE_SYMBOLS = {
453
+ "unscheduled" => " ",
454
+ "unstarted" => ":|",
455
+ "started" => ":/",
456
+ "finished" => ":)",
457
+ "delivered" => ";)",
458
+ "rejected" => ":(",
459
+ "accepted" => ":D"
460
+ }
461
+
462
+ TYPE_COLORS = {
463
+ 'chore' => COLORS[:blue],
464
+ 'feature' => COLORS[:magenta],
465
+ 'bug' => COLORS[:red],
466
+ 'release' => COLORS[:cyan]
467
+ }
468
+
469
+ TYPE_SYMBOLS = {
470
+ "feature" => "*",
471
+ "chore" => "%",
472
+ "release" => "!",
473
+ "bug" => "/"
474
+ }
475
+
476
+ def run
477
+ command = @argv.shift
478
+ if klass = self.class[command]
479
+ result = klass.new(@argv).run
480
+ exit result.respond_to?(:to_int) ? result.to_int : 0
481
+ elsif ['help', '--help', '-h', '', nil].include?(command)
482
+ puts "usage: pickler <command> [options] [arguments]"
483
+ puts
484
+ puts "Commands:"
485
+ self.class.commands.each do |command|
486
+ puts " %-19s %s" % [command.command_name, command.summary]
487
+ end
488
+ puts
489
+ puts "Run pickler <command> --help for help with a given command"
490
+ else
491
+ raise Error, "Unknown pickler command #{command}"
492
+ end
493
+ rescue Pickler::Error
494
+ $stderr.puts "#$!"
495
+ exit 1
496
+ rescue Interrupt
497
+ $stderr.puts "Interrupted!"
498
+ exit 130
499
+ end
500
+
501
+ end
502
+ end
@@ -0,0 +1,113 @@
1
+ require 'date'
2
+
3
+ class Pickler
4
+ class Tracker
5
+
6
+ ADDRESS = 'www.pivotaltracker.com'
7
+ BASE_PATH = '/services/v1'
8
+ SEARCH_KEYS = %w(label type state requester owner mywork id includedone)
9
+
10
+ class Error < Pickler::Error; end
11
+
12
+ attr_reader :token
13
+
14
+ def initialize(token, ssl = false)
15
+ require 'active_support/core_ext/blank'
16
+ require 'active_support/core_ext/hash'
17
+ @token = token
18
+ @ssl = ssl
19
+ end
20
+
21
+ def ssl?
22
+ @ssl
23
+ end
24
+
25
+ def http
26
+ unless @http
27
+ if ssl?
28
+ require 'net/https'
29
+ @http = Net::HTTP.new(ADDRESS, Net::HTTP.https_default_port)
30
+ @http.verify_mode = OpenSSL::SSL::VERIFY_NONE
31
+ @http.use_ssl = true
32
+ else
33
+ require 'net/http'
34
+ @http = Net::HTTP.new(ADDRESS)
35
+ end
36
+ end
37
+ @http
38
+ end
39
+
40
+ def request(method, path, *args)
41
+ headers = {
42
+ "X-TrackerToken" => @token,
43
+ "Accept" => "application/xml",
44
+ "Content-type" => "application/xml"
45
+ }
46
+ klass = Net::HTTP.const_get(method.to_s.capitalize)
47
+ http.request(klass.new("#{BASE_PATH}#{path}", headers), *args)
48
+ end
49
+
50
+ def request_xml(method, path, *args)
51
+ response = request(method,path,*args)
52
+ raise response.inspect if response["Content-type"].split(/; */).first != "application/xml"
53
+ Hash.from_xml(response.body)["response"]
54
+ end
55
+
56
+ def get_xml(path)
57
+ response = request_xml(:get, path)
58
+ unless response["success"] == "true"
59
+ if response["message"]
60
+ raise Error, response["message"], caller
61
+ else
62
+ raise "#{path}: #{response.inspect}"
63
+ end
64
+ end
65
+ response
66
+ end
67
+
68
+ def project(id)
69
+ Project.new(self,get_xml("/projects/#{id}")["project"].merge("id" => id.to_i))
70
+ end
71
+
72
+ class Abstract
73
+ def initialize(attributes = {})
74
+ @attributes = {}
75
+ (attributes || {}).each do |k,v|
76
+ @attributes[k.to_s] = v
77
+ end
78
+ yield self if block_given?
79
+ end
80
+
81
+ def self.reader(*methods)
82
+ methods.each do |method|
83
+ define_method(method) { @attributes[method.to_s] }
84
+ end
85
+ end
86
+
87
+ def self.date_reader(*methods)
88
+ methods.each do |method|
89
+ define_method(method) { value = @attributes[method.to_s] and Date.parse(value) }
90
+ end
91
+ end
92
+
93
+ def self.accessor(*methods)
94
+ reader(*methods)
95
+ methods.each do |method|
96
+ define_method("#{method}=") { |v| @attributes[method.to_s] = v }
97
+ end
98
+ end
99
+ reader :id
100
+
101
+ def to_xml(options = nil)
102
+ @attributes.to_xml({:dasherize => false, :root => self.class.name.split('::').last.downcase}.merge(options||{}))
103
+ end
104
+
105
+ end
106
+
107
+ end
108
+ end
109
+
110
+ require 'pickler/tracker/project'
111
+ require 'pickler/tracker/story'
112
+ require 'pickler/tracker/iteration'
113
+ require 'pickler/tracker/note'
@@ -0,0 +1,38 @@
1
+ class Pickler
2
+ class Tracker
3
+ class Iteration < Abstract
4
+ attr_reader :project
5
+ date_reader :start, :finish
6
+
7
+ def initialize(project, attributes = {})
8
+ @project = project
9
+ super(attributes)
10
+ end
11
+
12
+ def number
13
+ @attributes['number'].to_i
14
+ end
15
+ alias to_i number
16
+
17
+ def range
18
+ start...finish
19
+ end
20
+
21
+ def include?(date)
22
+ range.include?(date)
23
+ end
24
+
25
+ def succ
26
+ self.class.new(project, 'number' => number.succ.to_s, 'start' => @attributes['finish'], 'finish' => (finish + (finish - start)).strftime("%b %d, %Y"))
27
+ end
28
+
29
+ def inspect
30
+ "#<#{self.class.inspect}:#{number.inspect} (#{range.inspect})>"
31
+ end
32
+
33
+ def to_s
34
+ "#{number} (#{start}...#{finish})"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,27 @@
1
+ class Pickler
2
+ class Tracker
3
+ class Note < Abstract
4
+ attr_reader :story
5
+ reader :text, :author
6
+ date_reader :date
7
+
8
+ def initialize(story, attributes = {})
9
+ @story = story
10
+ super(attributes)
11
+ end
12
+
13
+ def to_xml
14
+ @attributes.to_xml(:dasherize => false, :root => 'note')
15
+ end
16
+
17
+ def inspect
18
+ "#<#{self.class.inspect}:#{id.inspect}, story_id: #{story.id.inspect}, date: #{date.inspect}, author: #{author.inspect}, text: #{text.inspect}>"
19
+ end
20
+
21
+ def lines(width = 79)
22
+ text.scan(/(?:.{0,#{width}}|\S+?)(?:\s|$)/).map! {|line| line.strip}[0..-2]
23
+ end
24
+
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,47 @@
1
+ class Pickler
2
+ class Tracker
3
+ class Project < Abstract
4
+
5
+ attr_reader :tracker
6
+ reader :point_scale, :week_start_day, :name, :iteration_length
7
+
8
+ def initialize(tracker, attributes = {})
9
+ @tracker = tracker
10
+ super(attributes)
11
+ end
12
+
13
+ def story(story_id)
14
+ raise Error, "No story id given" if story_id.to_s.empty?
15
+ Story.new(self,tracker.get_xml("/projects/#{id}/stories/#{story_id}")["story"])
16
+ end
17
+
18
+ def stories(*args)
19
+ filter = encode_term(args) if args.any?
20
+ path = "/projects/#{id}/stories"
21
+ path << "?filter=#{CGI.escape(filter)}" if filter
22
+ response = tracker.get_xml(path)
23
+ [response["stories"]["story"]].flatten.compact.map {|s| Story.new(self,s)}
24
+ end
25
+
26
+ def new_story(attributes = {}, &block)
27
+ Story.new(self, attributes, &block)
28
+ end
29
+
30
+ def deliver_all_finished_stories
31
+ request_xml(:put,"/projects/#{id}/stories_deliver_all_finished")
32
+ end
33
+
34
+ private
35
+ def encode_term(term)
36
+ case term
37
+ when Array then term.map {|v| encode_term(v)}.join(" ")
38
+ when Hash then term.map {|k,v| encode_term("#{k}:#{v}")}.join(" ")
39
+ when /^\S+$/, Symbol then term
40
+ when /^(\S+?):(.*)$/ then %{#$1:"#$2"}
41
+ else %{"#{term}"}
42
+ end
43
+ end
44
+
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,166 @@
1
+ class Pickler
2
+ class Tracker
3
+ class Story < Abstract
4
+
5
+ TYPES = %w(bug feature chore release)
6
+ STATES = %w(unscheduled unstarted started finished delivered rejected accepted)
7
+
8
+ attr_reader :project, :iteration, :labels
9
+ reader :url
10
+ date_reader :created_at, :accepted_at, :deadline
11
+ accessor :current_state, :name, :description, :owned_by, :requested_by, :story_type
12
+
13
+ def initialize(project, attributes = {})
14
+ @project = project
15
+ super(attributes)
16
+ @iteration = Iteration.new(project, @attributes["iteration"]) if @attributes["iteration"]
17
+ @labels = normalize_labels(@attributes["labels"])
18
+ end
19
+
20
+ def labels=(value)
21
+ @labels = normalize_labels(value)
22
+ end
23
+
24
+ def transition!(state)
25
+ raise Pickler::Tracker::Error, "Invalid state #{state}", caller unless STATES.include?(state)
26
+ self.current_state = state
27
+ if id
28
+ xml = "<story><current_state>#{state}</current_state></story>"
29
+ error = tracker.request_xml(:put, resource_url, xml).fetch("errors",{})["error"] || true
30
+ else
31
+ error = save
32
+ end
33
+ raise Pickler::Tracker::Error, Array(error).join("\n"), caller unless error == true
34
+ end
35
+
36
+ def finish
37
+ case story_type
38
+ when "bug", "feature"
39
+ self.current_state = "finished" unless complete?
40
+ when "chore", "release"
41
+ self.current_state = "accepted"
42
+ end
43
+ current_state
44
+ end
45
+
46
+ def finish!
47
+ transition!(finish)
48
+ end
49
+
50
+ def backlog?(as_of = Date.today)
51
+ iteration && iteration.start >= as_of
52
+ end
53
+
54
+ def current?(as_of = Date.today)
55
+ iteration && iteration.include?(as_of)
56
+ end
57
+
58
+ # In a previous iteration
59
+ def done?(as_of = Date.today)
60
+ iteration && iteration.finish <= as_of
61
+ end
62
+
63
+ def complete?
64
+ %w(finished delivered accepted).include?(current_state)
65
+ end
66
+
67
+ def startable?
68
+ %w(unscheduled unstarted rejected).include?(current_state)
69
+ end
70
+
71
+ def tracker
72
+ project.tracker
73
+ end
74
+
75
+ def to_s
76
+ to_s = "# #{url}\n#{story_type.capitalize}: #{name}\n"
77
+ description_lines.each do |line|
78
+ to_s << " #{line}".rstrip << "\n"
79
+ end
80
+ to_s
81
+ end
82
+
83
+ def to_s=(body)
84
+ body = body.sub(/\A# .*\n/,'')
85
+ if body =~ /\A(\w+): (.*)/
86
+ self.story_type = $1.downcase
87
+ self.name = $2
88
+ description = $'
89
+ else
90
+ self.story_type = "feature"
91
+ self.name = body[/.*/]
92
+ description = $'
93
+ end
94
+ self.description = description.gsub(/\A\n+|\n+\Z/,'') + "\n"
95
+ if description_lines.all? {|l| l.empty? || l =~ /^ /}
96
+ self.description.gsub!(/^ /,'')
97
+ end
98
+ self
99
+ end
100
+
101
+ def description_lines
102
+ array = []
103
+ description.to_s.each_line do |line|
104
+ array << line.chomp
105
+ end
106
+ array
107
+ end
108
+
109
+ def notes
110
+ [@attributes["notes"]].flatten.compact.map {|n| Note.new(self,n)}
111
+ end
112
+
113
+ def estimate
114
+ @attributes["estimate"].to_i < 0 ? nil : @attributes["estimate"]
115
+ end
116
+
117
+ def comment!(body)
118
+ response = tracker.request_xml(:post, "#{resource_url}/notes",{:text => body}.to_xml(:dasherize => false, :root => 'note'))
119
+ if response["note"]
120
+ Note.new(self, response["note"])
121
+ else
122
+ raise Pickler::Tracker::Error, Array(response["errors"]["error"]).join("\n"), caller
123
+ end
124
+ end
125
+
126
+ def to_xml(force_labels = true)
127
+ hash = @attributes.reject do |k,v|
128
+ !%w(current_state deadline description estimate name owned_by requested_by story_type).include?(k)
129
+ end
130
+ if force_labels || !id || normalize_labels(@attributes["labels"]) != labels
131
+ hash["labels"] = labels.join(", ")
132
+ end
133
+ hash.to_xml(:dasherize => false, :root => "story")
134
+ end
135
+
136
+ def destroy
137
+ if id
138
+ response = tracker.request_xml(:delete, "/projects/#{project.id}/stories/#{id}", "")
139
+ raise Error, response["message"], caller if response["success"] != "true"
140
+ @attributes["id"] = nil
141
+ self
142
+ end
143
+ end
144
+
145
+ def resource_url
146
+ ["/projects/#{project.id}/stories",id].compact.join("/")
147
+ end
148
+
149
+ def save
150
+ response = tracker.request_xml(id ? :put : :post, resource_url, to_xml(false))
151
+ if response["success"] == "true"
152
+ initialize(project, response["story"])
153
+ true
154
+ else
155
+ Array(response["errors"]["error"])
156
+ end
157
+ end
158
+
159
+ private
160
+ def normalize_labels(value)
161
+ Array(value).join(", ").strip.split(/\s*,\s*/)
162
+ end
163
+
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,30 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = "pickler"
3
+ s.version = "0.0.6.1"
4
+
5
+ s.summary = "PIvotal traCKer Liaison to cucumbER"
6
+ s.description = "Synchronize between Cucumber and Pivotal Tracker"
7
+ s.authors = ["Tim Pope"]
8
+ s.email = "ruby@tpope.i"+'nfo'
9
+ s.homepage = "http://github.com/tpope/pickler"
10
+ s.default_executable = "pickler"
11
+ s.executables = ["pickler"]
12
+ s.files = [
13
+ "README.rdoc",
14
+ "MIT-LICENSE",
15
+ "pickler.gemspec",
16
+ "bin/pickler",
17
+ "lib/pickler.rb",
18
+ "lib/pickler/feature.rb",
19
+ "lib/pickler/runner.rb",
20
+ "lib/pickler/tracker.rb",
21
+ "lib/pickler/tracker/project.rb",
22
+ "lib/pickler/tracker/story.rb",
23
+ "lib/pickler/tracker/iteration.rb",
24
+ "lib/pickler/tracker/note.rb"
25
+ ]
26
+ s.add_dependency("activesupport", [">= 2.0.0"])
27
+ s.add_dependency("cucumber", [">= 0.1.9"])
28
+ s.add_dependency("builder")
29
+ s.add_dependency("xml-simple")
30
+ end
metadata ADDED
@@ -0,0 +1,99 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: schlick-pickler
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.6.1
5
+ platform: ruby
6
+ authors:
7
+ - Tim Pope
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-01-11 00:00:00 -08:00
13
+ default_executable: pickler
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: activesupport
17
+ version_requirement:
18
+ version_requirements: !ruby/object:Gem::Requirement
19
+ requirements:
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 2.0.0
23
+ version:
24
+ - !ruby/object:Gem::Dependency
25
+ name: cucumber
26
+ version_requirement:
27
+ version_requirements: !ruby/object:Gem::Requirement
28
+ requirements:
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 0.1.9
32
+ version:
33
+ - !ruby/object:Gem::Dependency
34
+ name: builder
35
+ version_requirement:
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: "0"
41
+ version:
42
+ - !ruby/object:Gem::Dependency
43
+ name: xml-simple
44
+ version_requirement:
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: "0"
50
+ version:
51
+ description: Synchronize between Cucumber and Pivotal Tracker
52
+ email: ruby@tpope.info
53
+ executables:
54
+ - pickler
55
+ extensions: []
56
+
57
+ extra_rdoc_files: []
58
+
59
+ files:
60
+ - README.rdoc
61
+ - MIT-LICENSE
62
+ - pickler.gemspec
63
+ - bin/pickler
64
+ - lib/pickler.rb
65
+ - lib/pickler/feature.rb
66
+ - lib/pickler/runner.rb
67
+ - lib/pickler/tracker.rb
68
+ - lib/pickler/tracker/project.rb
69
+ - lib/pickler/tracker/story.rb
70
+ - lib/pickler/tracker/iteration.rb
71
+ - lib/pickler/tracker/note.rb
72
+ has_rdoc: false
73
+ homepage: http://github.com/tpope/pickler
74
+ post_install_message:
75
+ rdoc_options: []
76
+
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: "0"
84
+ version:
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: "0"
90
+ version:
91
+ requirements: []
92
+
93
+ rubyforge_project:
94
+ rubygems_version: 1.2.0
95
+ signing_key:
96
+ specification_version: 2
97
+ summary: PIvotal traCKer Liaison to cucumbER
98
+ test_files: []
99
+