kosmas58-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.
data/MIT-LICENSE ADDED
@@ -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.
data/README.rdoc ADDED
@@ -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.
data/bin/pickler ADDED
@@ -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)
data/lib/pickler.rb ADDED
@@ -0,0 +1,138 @@
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
+ @lang = @config["lang"] || "en"
48
+ self.class.config.merge(@config)
49
+ end
50
+
51
+ def real_name
52
+ config["real_name"] || (require 'etc'; Etc.getpwuid.gecos.split(',').first)
53
+ end
54
+
55
+ def new_story(attributes = {}, &block)
56
+ attributes = attributes.inject('requested_by' => real_name) do |h,(k,v)|
57
+ h.update(k.to_s => v)
58
+ end
59
+ project.new_story(attributes, &block)
60
+ end
61
+
62
+ def stories(*args)
63
+ project.stories(*args)
64
+ end
65
+
66
+ def name
67
+ project.name
68
+ end
69
+
70
+ def iteration_length
71
+ project.iteration_length
72
+ end
73
+
74
+ def point_scale
75
+ project.point_scale
76
+ end
77
+
78
+ def week_start_day
79
+ project.week_start_day
80
+ end
81
+
82
+ def deliver_all_finished_stories
83
+ project.deliver_all_finished_stories
84
+ end
85
+
86
+ def parser
87
+ require 'cucumber'
88
+ require "cucumber/treetop_parser/feature_#@lang"
89
+ Cucumber.load_language(@lang)
90
+ @parser ||= Cucumber::TreetopParser::FeatureParser.new
91
+ end
92
+
93
+ def project_id
94
+ config["project_id"] || (self.class.config["projects"]||{})[File.basename(@directory)]
95
+ end
96
+
97
+ def project
98
+ @project ||= Dir.chdir(@directory) do
99
+ unless token = config['api_token']
100
+ raise Error, 'echo api_token: ... > ~/.tracker.yml'
101
+ end
102
+ unless id = project_id
103
+ raise Error, 'echo project_id: ... > features/tracker.yml'
104
+ end
105
+ ssl = config['ssl']
106
+ Tracker.new(token, ssl).project(id)
107
+ end
108
+ end
109
+
110
+ def scenario_word
111
+ parser
112
+ Cucumber.language['scenario']
113
+ end
114
+
115
+ def local_features
116
+ Dir[features_path('**','*.feature')].map {|f|feature(f)}.select {|f|f.id}
117
+ end
118
+
119
+ def scenario_features(includes)
120
+ ignored_states = %w(unscheduled unstarted) - Array(includes)
121
+ project.stories(scenario_word, :includedone => true).reject do |s|
122
+ ignored_states.include?(s.current_state)
123
+ end.select do |s|
124
+ s.to_s =~ /^\s*#{Regexp.escape(scenario_word)}:/ && parser.parse(s.to_s)
125
+ end
126
+ end
127
+
128
+ def feature(string)
129
+ string.kind_of?(Feature) ? string : Feature.new(self,string)
130
+ end
131
+
132
+ def story(string)
133
+ feature(string).story
134
+ end
135
+
136
+ protected
137
+
138
+ end
@@ -0,0 +1,103 @@
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, "Unrecognizable 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
+ story = story() # force the read into local_body before File.open below blows it away
55
+ File.open(filename,'w') {|f| f.puts story}
56
+ @filename = filename
57
+ end
58
+
59
+ def start(default = nil)
60
+ story.transition!("started") if story.startable?
61
+ if filename || default
62
+ pull(default)
63
+ end
64
+ end
65
+
66
+ def push
67
+ return if story.to_s == local_body.to_s
68
+ story.to_s = local_body
69
+ story.save
70
+ self
71
+ end
72
+
73
+ def finish
74
+ if filename
75
+ story.finish
76
+ story.to_s = local_body
77
+ story.save
78
+ else
79
+ story.finish!
80
+ end
81
+ end
82
+
83
+ def id
84
+ unless defined?(@id)
85
+ @id = if id = local_body.to_s[/#\s*#{URL_REGEX}/,1]
86
+ id.to_i
87
+ end
88
+ end
89
+ @id
90
+ end
91
+
92
+ def story
93
+ unless defined?(@story)
94
+ @story = new_feature? ? pickler.new_story(:story_type => "feature") : pickler.project.story(id)
95
+ end
96
+ @story
97
+ end
98
+
99
+ def new_feature?
100
+ id == nil
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,514 @@
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
+ feature = pickler.feature(arg).push
294
+ File.open(feature.filename, 'w') { |f| f.puts feature.story } if feature.new_feature?
295
+ end
296
+ end
297
+ end
298
+
299
+ command :pull do
300
+ banner_arguments "[story] ..."
301
+ summary "Download stories"
302
+ description <<-EOF
303
+ Download the given story or all well formed stories to the features/ directory.
304
+ Previously unseen stories will be given a numeric filename that you are
305
+ encouraged to change.
306
+ EOF
307
+ on "--include-unscheduled", "pull unscheduled stories" do
308
+ @includes ||= []
309
+ @includes << "unscheduled"
310
+ end
311
+ on "--include-unstarted", "pull unstarted stories" do
312
+ @includes ||= []
313
+ @includes << "unstarted"
314
+ end
315
+ on "--all", "pull unstarted and unscheduled stories" do
316
+ @includes = %w(unscheduled unstarted)
317
+ end
318
+
319
+ process do |*args|
320
+ args.replace(pickler.scenario_features(@includes)) if args.empty?
321
+ args.each do |arg|
322
+ pickler.feature(arg).pull
323
+ end
324
+ end
325
+ end
326
+
327
+ command :start do
328
+ banner_arguments "<story> [basename]"
329
+ summary "Pull a story and mark it started"
330
+ description <<-EOF
331
+ Pull a given story and change its state to started. If basename is given
332
+ and no local file exists, features/basename.feature will be created.
333
+ EOF
334
+
335
+ process do |story, *args|
336
+ pickler.feature(story).start(args.first)
337
+ end
338
+ end
339
+
340
+ command :finish do
341
+ banner_arguments "<story>"
342
+ summary "Push a story and mark it finished"
343
+
344
+ process do |story|
345
+ pickler.feature(story).finish
346
+ end
347
+ end
348
+
349
+ command :deliver do
350
+ banner_arguments "[story] ..."
351
+ summary "Mark stories delivered"
352
+ on "--all-finished", "deliver all finished stories" do
353
+ @all = true
354
+ end
355
+ process do |*args|
356
+ if @all
357
+ pickler.deliver_all_finished_stories
358
+ end
359
+ args.each do |arg|
360
+ pickler.story(arg).transition!('delivered')
361
+ end
362
+ end
363
+ end
364
+
365
+ command :unstart do
366
+ banner_arguments "[story] ..."
367
+ summary "Mark stories unstarted"
368
+ on "--all-started", "unstart all started stories" do
369
+ @all = true
370
+ end
371
+ process do |*args|
372
+ if @all
373
+ pickler.project.stories(:state => "started").each do |story|
374
+ story.transition!('unstarted')
375
+ end
376
+ end
377
+ args.each do |arg|
378
+ pickler.story(arg).transition!('unstarted')
379
+ end
380
+ end
381
+ end
382
+
383
+ command :unschedule do
384
+ banner_arguments "[story] ..."
385
+ summary "Move stories to icebox"
386
+ process do |*args|
387
+ args.each do |arg|
388
+ pickler.story(arg).transition!('unscheduled')
389
+ end
390
+ end
391
+ end
392
+
393
+ command :browse do
394
+ banner_arguments "[story]"
395
+ summary "Open a story in the web browser"
396
+ description <<-EOF
397
+ Open project or a story in the web browser.
398
+
399
+ Requires launchy (gem install launchy).
400
+ EOF
401
+
402
+ on "--dashboard" do
403
+ @special = "dashboard"
404
+ end
405
+ on "--faq" do
406
+ @special = "help"
407
+ end
408
+ on "--profile", "get your API Token here" do
409
+ @special = "profile"
410
+ end
411
+ on "--time", "not publicly available" do
412
+ @special = "time_shifts?project=#{pickler.project_id}"
413
+ end
414
+
415
+ process do |*args|
416
+ too_many if args.size > 1 || @special && args.first
417
+ if args.first
418
+ url = pickler.story(args.first).url
419
+ elsif @special
420
+ url = "http://www.pivotaltracker.com/#@special"
421
+ else
422
+ url = "http://www.pivotaltracker.com/projects/#{pickler.project_id}/stories"
423
+ end
424
+ require 'launchy'
425
+ Launchy.open(url)
426
+ end
427
+ end
428
+
429
+ command :comment do
430
+ banner_arguments "<story> <paragraph> ..."
431
+ summary "Post a comment to a story"
432
+
433
+ process do |story, *paragraphs|
434
+ pickler.story(story).comment!(paragraphs.join("\n\n"))
435
+ end
436
+ end
437
+
438
+ def initialize(argv)
439
+ @argv = argv
440
+ end
441
+
442
+ COLORS = {
443
+ :black => 0,
444
+ :red => 1,
445
+ :green => 2,
446
+ :yellow => 3,
447
+ :blue => 4,
448
+ :magenta => 5,
449
+ :cyan => 6,
450
+ :white => 7
451
+ }
452
+
453
+ STATE_COLORS = {
454
+ nil => COLORS[:black],
455
+ "rejected" => COLORS[:red],
456
+ "accepted" => COLORS[:green],
457
+ "delivered" => COLORS[:yellow],
458
+ "unscheduled" => COLORS[:white],
459
+ "started" => COLORS[:magenta],
460
+ "finished" => COLORS[:cyan],
461
+ "unstarted" => COLORS[:blue]
462
+ }
463
+
464
+ STATE_SYMBOLS = {
465
+ "unscheduled" => " ",
466
+ "unstarted" => ":|",
467
+ "started" => ":/",
468
+ "finished" => ":)",
469
+ "delivered" => ";)",
470
+ "rejected" => ":(",
471
+ "accepted" => ":D"
472
+ }
473
+
474
+ TYPE_COLORS = {
475
+ 'chore' => COLORS[:blue],
476
+ 'feature' => COLORS[:magenta],
477
+ 'bug' => COLORS[:red],
478
+ 'release' => COLORS[:cyan]
479
+ }
480
+
481
+ TYPE_SYMBOLS = {
482
+ "feature" => "*",
483
+ "chore" => "%",
484
+ "release" => "!",
485
+ "bug" => "/"
486
+ }
487
+
488
+ def run
489
+ command = @argv.shift
490
+ if klass = self.class[command]
491
+ result = klass.new(@argv).run
492
+ exit result.respond_to?(:to_int) ? result.to_int : 0
493
+ elsif ['help', '--help', '-h', '', nil].include?(command)
494
+ puts "usage: pickler <command> [options] [arguments]"
495
+ puts
496
+ puts "Commands:"
497
+ self.class.commands.each do |command|
498
+ puts " %-19s %s" % [command.command_name, command.summary]
499
+ end
500
+ puts
501
+ puts "Run pickler <command> --help for help with a given command"
502
+ else
503
+ raise Error, "Unknown pickler command #{command}"
504
+ end
505
+ rescue Pickler::Error
506
+ $stderr.puts "#$!"
507
+ exit 1
508
+ rescue Interrupt
509
+ $stderr.puts "Interrupted!"
510
+ exit 130
511
+ end
512
+
513
+ end
514
+ 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
data/pickler.gemspec ADDED
@@ -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", "Kamal Fariz Mahyuddin", "Liam Morley", "Kosmas Sch�tz"]
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,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kosmas58-pickler
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.6.1
5
+ platform: ruby
6
+ authors:
7
+ - Tim Pope
8
+ - Kamal Fariz Mahyuddin
9
+ - Liam Morley
10
+ - "Kosmas Sch\xFCtz"
11
+ autorequire:
12
+ bindir: bin
13
+ cert_chain: []
14
+
15
+ date: 2009-01-11 00:00:00 -08:00
16
+ default_executable: pickler
17
+ dependencies:
18
+ - !ruby/object:Gem::Dependency
19
+ name: activesupport
20
+ version_requirement:
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 2.0.0
26
+ version:
27
+ - !ruby/object:Gem::Dependency
28
+ name: cucumber
29
+ version_requirement:
30
+ version_requirements: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: 0.1.9
35
+ version:
36
+ - !ruby/object:Gem::Dependency
37
+ name: builder
38
+ version_requirement:
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: "0"
44
+ version:
45
+ - !ruby/object:Gem::Dependency
46
+ name: xml-simple
47
+ version_requirement:
48
+ version_requirements: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: "0"
53
+ version:
54
+ description: Synchronize between Cucumber and Pivotal Tracker
55
+ email: ruby@tpope.info
56
+ executables:
57
+ - pickler
58
+ extensions: []
59
+
60
+ extra_rdoc_files: []
61
+
62
+ files:
63
+ - README.rdoc
64
+ - MIT-LICENSE
65
+ - pickler.gemspec
66
+ - bin/pickler
67
+ - lib/pickler.rb
68
+ - lib/pickler/feature.rb
69
+ - lib/pickler/runner.rb
70
+ - lib/pickler/tracker.rb
71
+ - lib/pickler/tracker/project.rb
72
+ - lib/pickler/tracker/story.rb
73
+ - lib/pickler/tracker/iteration.rb
74
+ - lib/pickler/tracker/note.rb
75
+ has_rdoc: false
76
+ homepage: http://github.com/tpope/pickler
77
+ post_install_message:
78
+ rdoc_options: []
79
+
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: "0"
87
+ version:
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: "0"
93
+ version:
94
+ requirements: []
95
+
96
+ rubyforge_project:
97
+ rubygems_version: 1.2.0
98
+ signing_key:
99
+ specification_version: 2
100
+ summary: PIvotal traCKer Liaison to cucumbER
101
+ test_files: []
102
+