ssp-pickler 0.1.4

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 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