kosmas58-pickler 0.0.6.1

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