ssp-pickler 0.1.4

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 pickler --source=http://gemcutter.org
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,139 @@
1
+ require 'yaml'
2
+ require 'active_support/core_ext/hash'
3
+
4
+ class Pickler
5
+
6
+ class Error < RuntimeError
7
+ end
8
+
9
+ autoload :Runner, 'pickler/runner'
10
+ autoload :Feature, 'pickler/feature'
11
+ autoload :Tracker, 'pickler/tracker'
12
+
13
+ def self.config
14
+ @config ||= {'api_token' => ENV["TRACKER_API_TOKEN"]}.merge(
15
+ if File.exist?(path = File.expand_path('~/.tracker.yml'))
16
+ YAML.load_file(path)
17
+ end || {}
18
+ )
19
+ end
20
+
21
+ def self.run(argv)
22
+ Runner.new(argv).run
23
+ end
24
+
25
+ attr_reader :directory
26
+
27
+ def initialize(path = '.')
28
+ @lang = 'en'
29
+ @directory = File.expand_path(path)
30
+ until File.directory?(File.join(@directory,'features'))
31
+ if @directory == File.dirname(@directory)
32
+ raise Error, 'Project not found. Make sure you have a features/ directory.', caller
33
+ end
34
+ @directory = File.dirname(@directory)
35
+ end
36
+ end
37
+
38
+ def features_path(*subdirs)
39
+ File.join(@directory,'features',*subdirs)
40
+ end
41
+
42
+ def config_file
43
+ features_path('tracker.yml')
44
+ end
45
+
46
+ def config
47
+ @config ||= File.exist?(config_file) && YAML.load_file(config_file) || {}
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 parse(story)
87
+ require 'cucumber'
88
+ Cucumber::FeatureFile.new(story.url, story.to_s).parse(Cucumber::StepMother.new, :lang => @lang)
89
+ end
90
+
91
+ def project_id
92
+ config["project_id"] || (self.class.config["projects"]||{})[File.basename(@directory)]
93
+ end
94
+
95
+ def project
96
+ @project ||= Dir.chdir(@directory) do
97
+ unless token = config['api_token']
98
+ raise Error, 'echo api_token: ... > ~/.tracker.yml'
99
+ end
100
+ unless id = project_id
101
+ raise Error, 'echo project_id: ... > features/tracker.yml'
102
+ end
103
+ ssl = config['ssl']
104
+ Tracker.new(token, ssl).project(id)
105
+ end
106
+ end
107
+
108
+ def scenario_word
109
+ require 'cucumber'
110
+ Gherkin::I18n::LANGUAGES[@lang]['scenario']
111
+ end
112
+
113
+ def format
114
+ (config['format'] || :tag).to_sym
115
+ end
116
+
117
+ def local_features
118
+ Dir[features_path('**','*.feature')].map {|f|feature(f)}.select {|f|f.pushable?}
119
+ end
120
+
121
+ def scenario_features(excluded_states = %w(unscheduled unstarted))
122
+ project.stories(scenario_word, :includedone => true).reject do |s|
123
+ Array(excluded_states).map {|state| state.to_s}.include?(s.current_state)
124
+ end.select do |s|
125
+ s.to_s =~ /^\s*#{Regexp.escape(scenario_word)}:/ && parse(s) rescue false
126
+ end
127
+ end
128
+
129
+ def feature(string)
130
+ string.kind_of?(Feature) ? string : Feature.new(self,string)
131
+ end
132
+
133
+ def story(string)
134
+ feature(string).story
135
+ end
136
+
137
+ protected
138
+
139
+ end
@@ -0,0 +1,124 @@
1
+ require 'pathname'
2
+
3
+ class Pickler
4
+ class Feature
5
+ URL_REGEX = %r{\bhttps?://www\.pivotaltracker\.com/\S*/(\d+)\b}
6
+ attr_reader :pickler
7
+
8
+ def initialize(pickler, identifier)
9
+ @pickler = pickler
10
+ case identifier
11
+ when nil, /^\s+$/
12
+ raise Error, "No feature given"
13
+
14
+ when Pickler::Tracker::Story
15
+ @story = identifier
16
+ @id = @story.id
17
+
18
+ when Integer
19
+ @id = identifier
20
+
21
+ when /^#{URL_REGEX}$/, /^(\d+)$/
22
+ @id = $1.to_i
23
+
24
+ when /\.feature$/
25
+ if File.exist?(identifier)
26
+ @filename = identifier
27
+ end
28
+
29
+ else
30
+ if File.exist?(path = pickler.features_path("#{identifier}.feature"))
31
+ @filename = path
32
+ end
33
+
34
+ end or raise Error, "Unrecognizable feature #{identifier}"
35
+ end
36
+
37
+ def local_body
38
+ File.read(filename) if filename
39
+ end
40
+
41
+ def filename
42
+ unless defined?(@filename)
43
+ @filename = Dir[pickler.features_path("**","*.feature")].detect do |f|
44
+ File.read(f)[/(?:#\s*|@[[:punct:]]?)#{URL_REGEX}/,1].to_i == @id
45
+ end
46
+ end
47
+ @filename
48
+ end
49
+
50
+ def to_s
51
+ local_body || story.to_s(pickler.format)
52
+ end
53
+
54
+ def pull(default = nil)
55
+ story = story() # force the read into local_body before File.open below blows it away
56
+ filename = filename() || pickler.features_path("#{story.suggested_basename(default)}.feature")
57
+ File.open(filename,'w') {|f| f.puts story.to_s(pickler.format)}
58
+ @filename = filename
59
+ end
60
+
61
+ def start(default = nil)
62
+ story.transition!("started") if story.startable?
63
+ if filename || default
64
+ pull(default)
65
+ end
66
+ end
67
+
68
+ def pushable?
69
+ id || local_body =~ %r{\A(?:#\s*|@[[:punct:]]?(?:https?://www\.pivotaltracker\.com/story/new)?[[:punct:]]?(?:\s+@\S+)*\s*)\n[[:upper:]][[:lower:]]+:} ? true : false
70
+ end
71
+
72
+ def push(github_url=nil)
73
+ body = local_body
74
+ if github_url
75
+ body.sub!(/\n[ \t]*\n\s*(?:@\S+\s+)*(?:Scenario|Background):.*\z/m, '')
76
+ relative_path = Pathname.new(filename).realpath.relative_path_from(Pathname.new(pickler.directory))
77
+ body += "\n\n#{github_url}/#{relative_path}#path\n"
78
+ end
79
+ if story
80
+ return if story.to_s(pickler.format) == body.to_s
81
+ story.to_s = body
82
+ story.save!
83
+ else
84
+ unless pushable?
85
+ raise Error, "To create a new story, tag it @http://www.pivotaltracker.com/story/new"
86
+ end
87
+ story = pickler.new_story
88
+ story.to_s = body
89
+ @story = story.save!
90
+ body = local_body
91
+ unless body.sub!(%r{\bhttps?://www\.pivotaltracker\.com/story/new\b}, story.url)
92
+ body.sub!(/\A(?:#.*\n)?/,"# #{story.url}\n")
93
+ end
94
+ File.open(filename,'w') {|f| f.write body}
95
+ end
96
+ rescue
97
+ $stderr.puts "Error with #{relative_path || filename}:\n #{$!}"
98
+ end
99
+
100
+ def finish
101
+ if filename
102
+ story.finish
103
+ story.to_s = local_body
104
+ story.save
105
+ else
106
+ story.finish!
107
+ end
108
+ end
109
+
110
+ def id
111
+ unless defined?(@id)
112
+ @id = if id = local_body.to_s[/(?:#\s*|@[[:punct:]]?)#{URL_REGEX}/,1]
113
+ id.to_i
114
+ end
115
+ end
116
+ @id
117
+ end
118
+
119
+ def story
120
+ @story ||= @pickler.project.story(id) if id
121
+ end
122
+
123
+ end
124
+ end
@@ -0,0 +1,531 @@
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
+ on "--full", "default format" do |full|
205
+ @format = :full
206
+ end
207
+
208
+ on "--raw", "same as the .feature" do |raw|
209
+ @format = :raw
210
+ end
211
+
212
+ process do |*args|
213
+ case args.size
214
+ when 0
215
+ puts "#{pickler.project_id} #{pickler.project.name}"
216
+ when 1
217
+ feature = pickler.feature(args.first)
218
+ story = feature.story
219
+ case @format
220
+ when :raw
221
+ puts feature.story.to_s(pickler.format) if feature.story
222
+ else
223
+ paginated_output do
224
+ puts_full feature.story
225
+ end
226
+ end
227
+ else
228
+ too_many
229
+ end
230
+ end
231
+ end
232
+
233
+ command :search do
234
+ banner_arguments "[query]"
235
+ summary "List all stories matching a query"
236
+
237
+ def modifications
238
+ @modifications ||= {}
239
+ end
240
+ [:label, :type, :state].each do |o|
241
+ on "--#{o} #{o.to_s.upcase}" do |value|
242
+ modifications[o] = value
243
+ end
244
+ end
245
+ [:requester, :owner, :mywork].each do |o|
246
+ on "--#{o}[=USERNAME]" do |value|
247
+ modifications[o] = value || pickler.real_name
248
+ end
249
+ end
250
+ on "--[no-]includedone", "include accepted stories" do |value|
251
+ modifications[:includedone] = value
252
+ @iterations ||= []
253
+ @iterations << :done?
254
+ end
255
+
256
+ on "-b", "--backlog", "filter results to future iterations" do |c|
257
+ @iterations ||= []
258
+ @iterations << :backlog?
259
+ end
260
+
261
+ on "-c", "--current", "filter results to current iteration" do |b|
262
+ @iterations ||= []
263
+ @iterations << :current?
264
+ end
265
+
266
+ on "--[no-]full", "show full story, not a summary line" do |b|
267
+ @full = b
268
+ end
269
+
270
+ process do |*argv|
271
+ argv << modifications unless modifications.empty?
272
+ if argv == [{:includedone => true}]
273
+ # Bypass the 200 search results limitation
274
+ stories = pickler.project.stories
275
+ else
276
+ stories = pickler.project.stories(*argv)
277
+ end
278
+ if @iterations && @iterations != [:done?]
279
+ stories.reject! {|s| !@iterations.any? {|i| s.send(i)}}
280
+ end
281
+ paginated_output do
282
+ first = true
283
+ stories.each do |story|
284
+ if @full
285
+ puts unless first
286
+ puts_full story
287
+ else
288
+ puts_summary story
289
+ end
290
+ first = false
291
+ end
292
+ end
293
+ end
294
+ end
295
+
296
+ command :push do
297
+ banner_arguments "[story] ..."
298
+ summary "Upload stories"
299
+ description <<-EOF
300
+ Upload the given story or all features with a tracker url in a comment on the
301
+ first line. Features with a blank comment in the first line will created as
302
+ new stories.
303
+ EOF
304
+
305
+ on "-g", "--github[=URL]", "push github url instead of scenarios" do |url|
306
+ url ||= begin
307
+ origin, branch = Dir.chdir(pickler.directory) do
308
+ origin = %x{git remote show origin}
309
+ branch = %x{git branch}[%r{\* (\S+)}, 1]
310
+ branch = "master" unless origin =~ %r{^ #{branch}\s+tracked}
311
+ [origin[%r{git@(github.com:[^/]+/[^\.]+)\.git}, 1], branch]
312
+ end
313
+ "http://#{origin.sub(':', '/')}/blob/#{branch}"
314
+ end
315
+ puts "Using GitHub url #{url.inspect}"
316
+ @github_url = url
317
+ end
318
+
319
+ process do |*args|
320
+ args.replace(pickler.local_features) if args.empty?
321
+ args.each do |arg|
322
+ pickler.feature(arg).push(@github_url)
323
+ end
324
+ end
325
+ end
326
+
327
+ command :pull do
328
+ banner_arguments "[story] ..."
329
+ summary "Download stories"
330
+ description <<-EOF
331
+ Download the given story or all well formed stories to the features/ directory.
332
+ EOF
333
+
334
+ process do |*args|
335
+ args.replace(pickler.scenario_features) if args.empty?
336
+ args.each do |arg|
337
+ pickler.feature(arg).pull
338
+ end
339
+ end
340
+ end
341
+
342
+ command :start do
343
+ banner_arguments "<story> [basename]"
344
+ summary "Pull a story and mark it started"
345
+ description <<-EOF
346
+ Pull a given story and change its state to started. If basename is given
347
+ and no local file exists, features/basename.feature will be created. Give a
348
+ basename of "-" to use a downcased, underscored version of the story name as
349
+ the basename.
350
+ EOF
351
+
352
+ process do |story, *args|
353
+ pickler.feature(story).start(args.first)
354
+ end
355
+ end
356
+
357
+ command :finish do
358
+ banner_arguments "<story>"
359
+ summary "Push a story and mark it finished"
360
+
361
+ process do |story|
362
+ pickler.feature(story).finish
363
+ end
364
+ end
365
+
366
+ command :deliver do
367
+ banner_arguments "[story] ..."
368
+ summary "Mark stories delivered"
369
+ on "--all-finished", "deliver all finished stories" do
370
+ @all = true
371
+ end
372
+ process do |*args|
373
+ if @all
374
+ pickler.deliver_all_finished_stories
375
+ end
376
+ args.each do |arg|
377
+ pickler.story(arg).transition!('delivered')
378
+ end
379
+ end
380
+ end
381
+
382
+ command :unstart do
383
+ banner_arguments "[story] ..."
384
+ summary "Mark stories unstarted"
385
+ on "--all-started", "unstart all started stories" do
386
+ @all = true
387
+ end
388
+ process do |*args|
389
+ if @all
390
+ pickler.project.stories(:state => "started").each do |story|
391
+ story.transition!('unstarted')
392
+ end
393
+ end
394
+ args.each do |arg|
395
+ pickler.story(arg).transition!('unstarted')
396
+ end
397
+ end
398
+ end
399
+
400
+ command :unschedule do
401
+ banner_arguments "[story] ..."
402
+ summary "Move stories to icebox"
403
+ process do |*args|
404
+ args.each do |arg|
405
+ pickler.story(arg).transition!('unscheduled')
406
+ end
407
+ end
408
+ end
409
+
410
+ command :browse do
411
+ banner_arguments "[story]"
412
+ summary "Open a story in the web browser"
413
+ description <<-EOF
414
+ Open project or a story in the web browser.
415
+
416
+ Requires launchy (gem install launchy).
417
+ EOF
418
+
419
+ on "--dashboard" do
420
+ @special = "dashboard"
421
+ end
422
+ on "--faq" do
423
+ @special = "help"
424
+ end
425
+ on "--profile", "get your API Token here" do
426
+ @special = "profile"
427
+ end
428
+ on "--time", "not publicly available" do
429
+ @special = "time_shifts?project=#{pickler.project_id}"
430
+ end
431
+
432
+ process do |*args|
433
+ too_many if args.size > 1 || @special && args.first
434
+ if args.first
435
+ url = pickler.story(args.first).url
436
+ elsif @special
437
+ url = "http://www.pivotaltracker.com/#@special"
438
+ else
439
+ url = "http://www.pivotaltracker.com/projects/#{pickler.project_id}/stories"
440
+ end
441
+ require 'launchy'
442
+ Launchy.open(url)
443
+ end
444
+ end
445
+
446
+ command :comment do
447
+ banner_arguments "<story> <paragraph> ..."
448
+ summary "Post a comment to a story"
449
+
450
+ process do |story, *paragraphs|
451
+ pickler.story(story).comment!(paragraphs.join("\n\n"))
452
+ end
453
+ end
454
+
455
+ def initialize(argv)
456
+ @argv = argv
457
+ end
458
+
459
+ COLORS = {
460
+ :black => 0,
461
+ :red => 1,
462
+ :green => 2,
463
+ :yellow => 3,
464
+ :blue => 4,
465
+ :magenta => 5,
466
+ :cyan => 6,
467
+ :white => 7
468
+ }
469
+
470
+ STATE_COLORS = {
471
+ nil => COLORS[:black],
472
+ "rejected" => COLORS[:red],
473
+ "accepted" => COLORS[:green],
474
+ "delivered" => COLORS[:yellow],
475
+ "unscheduled" => COLORS[:white],
476
+ "started" => COLORS[:magenta],
477
+ "finished" => COLORS[:cyan],
478
+ "unstarted" => COLORS[:blue]
479
+ }
480
+
481
+ STATE_SYMBOLS = {
482
+ "unscheduled" => " ",
483
+ "unstarted" => ":|",
484
+ "started" => ":/",
485
+ "finished" => ":)",
486
+ "delivered" => ";)",
487
+ "rejected" => ":(",
488
+ "accepted" => ":D"
489
+ }
490
+
491
+ TYPE_COLORS = {
492
+ 'chore' => COLORS[:blue],
493
+ 'feature' => COLORS[:magenta],
494
+ 'bug' => COLORS[:red],
495
+ 'release' => COLORS[:cyan]
496
+ }
497
+
498
+ TYPE_SYMBOLS = {
499
+ "feature" => "*",
500
+ "chore" => "%",
501
+ "release" => "!",
502
+ "bug" => "/"
503
+ }
504
+
505
+ def run
506
+ command = @argv.shift
507
+ if klass = self.class[command]
508
+ result = klass.new(@argv).run
509
+ exit result.respond_to?(:to_int) ? result.to_int : 0
510
+ elsif ['help', '--help', '-h', '', nil].include?(command)
511
+ puts "usage: pickler <command> [options] [arguments]"
512
+ puts
513
+ puts "Commands:"
514
+ self.class.commands.each do |command|
515
+ puts " %-19s %s" % [command.command_name, command.summary]
516
+ end
517
+ puts
518
+ puts "Run pickler <command> --help for help with a given command"
519
+ else
520
+ raise Error, "Unknown pickler command #{command}"
521
+ end
522
+ rescue Pickler::Error
523
+ $stderr.puts "#$!"
524
+ exit 1
525
+ rescue Interrupt
526
+ $stderr.puts "Interrupted!"
527
+ exit 130
528
+ end
529
+
530
+ end
531
+ end