schlick-pickler 0.0.6.1

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