drnic-pickler 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008 Tim Pope
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,62 @@
1
+ = Pickler
2
+
3
+ Synchronize user stories in Pivotal Tracker with Cucumber features.
4
+
5
+ If you aren't using Cucumber, you can still use pickler as a Pivotal Tracker
6
+ command line client, provided you humor it with a features/ directory
7
+ containing a tracker.yml file.
8
+
9
+ == Getting started
10
+
11
+ gem install tpope-pickler --source=http://gems.github.com
12
+ echo "api_token: ..." > ~/.tracker.yml
13
+ echo "project_id: ..." > ~/my/app/features/tracker.yml
14
+ echo "ssl: [true|false]" >> ~/my/app/features/tracker.yml
15
+ pickler --help
16
+
17
+ "ssl" defaults to false if not configured in the yml file.
18
+
19
+ For details about the Pivotal Tracker API, including where to find your API
20
+ token and project id, see http://www.pivotaltracker.com/help/api .
21
+
22
+ The pull and push commands map the story's name into the "Feature: ..." line
23
+ and the story's description with an additional two space indent into the
24
+ feature's body. Keep this in mind when entering stories into Pivotal Tracker.
25
+
26
+ == Usage
27
+
28
+ pickler pull
29
+
30
+ Download all well formed stories to the features/ directory.
31
+
32
+ pickler push
33
+
34
+ Upload all features with a tracker url in a comment on the first line.
35
+
36
+ pickler search <query>
37
+
38
+ List all stories matching the given query.
39
+
40
+ pickler start <story>
41
+
42
+ Pull a given feature and change its state to started.
43
+
44
+ pickler finish <story>
45
+
46
+ Push a given feature and change its state to finished.
47
+
48
+ pickler --help
49
+
50
+ Full list of commands.
51
+
52
+ pickler <command> --help
53
+
54
+ Further help for a given command.
55
+
56
+ == Disclaimer
57
+
58
+ No warranties, expressed or implied.
59
+
60
+ Notably, the push and pull commands are quite happy to blindly clobber
61
+ features if so instructed. Pivotal Tracker has a history to recover things
62
+ server side.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $:.unshift(File.join(File.dirname(File.dirname(__FILE__)),'lib'))
4
+ begin; require 'rubygems'; rescue LoadError; end
5
+ require 'pickler'
6
+
7
+ Pickler.run(ARGV)
@@ -0,0 +1,140 @@
1
+ require 'yaml'
2
+
3
+ require "activesupport"
4
+
5
+ class Pickler
6
+
7
+ class Error < RuntimeError
8
+ end
9
+
10
+ autoload :Runner, 'pickler/runner'
11
+ autoload :Feature, 'pickler/feature'
12
+ autoload :Tracker, 'pickler/tracker'
13
+
14
+ def self.config
15
+ @config ||= {'api_token' => ENV["TRACKER_API_TOKEN"]}.merge(
16
+ if File.exist?(path = File.expand_path('~/.tracker.yml'))
17
+ YAML.load_file(path)
18
+ end || {}
19
+ )
20
+ end
21
+
22
+ def self.run(argv)
23
+ Runner.new(argv).run
24
+ end
25
+
26
+ attr_reader :directory
27
+
28
+ def initialize(path = '.')
29
+ @lang = 'en'
30
+ @directory = File.expand_path(path)
31
+ until File.directory?(File.join(@directory,'features'))
32
+ if @directory == File.dirname(@directory)
33
+ raise Error, 'Project not found. Make sure you have a features/ directory.', caller
34
+ end
35
+ @directory = File.dirname(@directory)
36
+ end
37
+ end
38
+
39
+ def features_path(*subdirs)
40
+ File.join(@directory,'features',*subdirs)
41
+ end
42
+
43
+ def config_file
44
+ features_path('tracker.yml')
45
+ end
46
+
47
+ def config
48
+ @config ||= File.exist?(config_file) && YAML.load_file(config_file) || {}
49
+ self.class.config.merge(@config)
50
+ end
51
+
52
+ def real_name
53
+ config["real_name"] || (require 'etc'; Etc.getpwuid.gecos.split(',').first)
54
+ end
55
+
56
+ def new_story(attributes = {}, &block)
57
+ attributes = attributes.inject('requested_by' => real_name) do |h,(k,v)|
58
+ h.update(k.to_s => v)
59
+ end
60
+ project.new_story(attributes, &block)
61
+ end
62
+
63
+ def stories(*args)
64
+ project.stories(*args)
65
+ end
66
+
67
+ def name
68
+ project.name
69
+ end
70
+
71
+ def iteration_length
72
+ project.iteration_length
73
+ end
74
+
75
+ def point_scale
76
+ project.point_scale
77
+ end
78
+
79
+ def week_start_day
80
+ project.week_start_day
81
+ end
82
+
83
+ def deliver_all_finished_stories
84
+ project.deliver_all_finished_stories
85
+ end
86
+
87
+ def parse(story)
88
+ require 'cucumber'
89
+ Cucumber::FeatureFile.new(story.url, story.to_s).parse(:lang => @lang)
90
+ end
91
+
92
+ def project_id
93
+ config["project_id"] || (self.class.config["projects"]||{})[File.basename(@directory)]
94
+ end
95
+
96
+ def project
97
+ @project ||= Dir.chdir(@directory) do
98
+ unless token = config['api_token']
99
+ raise Error, 'echo api_token: ... > ~/.tracker.yml'
100
+ end
101
+ unless id = project_id
102
+ raise Error, 'echo project_id: ... > features/tracker.yml'
103
+ end
104
+ ssl = config['ssl']
105
+ Tracker.new(token, ssl).project(id)
106
+ end
107
+ end
108
+
109
+ def scenario_word
110
+ require 'cucumber'
111
+ Cucumber::LANGUAGES[@lang]['scenario']
112
+ end
113
+
114
+ def format
115
+ (config['format'] || :tag).to_sym
116
+ end
117
+
118
+ def local_features
119
+ Dir[features_path('**','*.feature')].map {|f|feature(f)}.select {|f|f.pushable?}
120
+ end
121
+
122
+ def scenario_features(excluded_states = %w(unscheduled unstarted))
123
+ project.stories(scenario_word, :includedone => true).reject do |s|
124
+ Array(excluded_states).map {|state| state.to_s}.include?(s.current_state)
125
+ end.select do |s|
126
+ s.to_s =~ /^\s*#{Regexp.escape(scenario_word)}:/ && parse(s) rescue false
127
+ end
128
+ end
129
+
130
+ def feature(string)
131
+ string.kind_of?(Feature) ? string : Feature.new(self,string)
132
+ end
133
+
134
+ def story(string)
135
+ feature(string).story
136
+ end
137
+
138
+ protected
139
+
140
+ end
@@ -0,0 +1,128 @@
1
+ class Pickler
2
+ class Feature
3
+ URL_REGEX = %r{\bhttps?://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 existing_filename
37
+ end
38
+
39
+ def find_feature_filename
40
+ Dir[pickler.features_path("**","*.feature")].detect do |f|
41
+ File.read(f)[/(?:#\s*|@[[:punct:]]?)#{URL_REGEX}/,1].to_i == @id
42
+ end
43
+ end
44
+
45
+ def existing_filename
46
+ unless defined?(@filename)
47
+ @filename = find_feature_filename
48
+ end
49
+ @filename
50
+ end
51
+
52
+ def feature_title
53
+ contents = self.to_s
54
+ contents[/Feature: (.*)$/, 1]
55
+ end
56
+
57
+ def to_s
58
+ local_body || story.to_s(pickler.format)
59
+ end
60
+
61
+ def filename
62
+ filename = existing_filename
63
+ name = feature_title ? feature_title.gsub(/ /,'_').underscore : id
64
+ filename ||= pickler.features_path("#{name}.feature")
65
+ end
66
+
67
+ def pull
68
+ story = story() # force the read into local_body before File.open below blows it away
69
+ File.open(filename, 'w') {|f| f.puts self.to_s}
70
+ @filename = filename
71
+ end
72
+
73
+ def start(default = nil)
74
+ story.transition!("started") if story.startable?
75
+ if filename || default
76
+ pull(default)
77
+ end
78
+ end
79
+
80
+ def pushable?
81
+ id || local_body =~ %r{\A(?:#\s*|@[[:punct:]]?(?:https?://www\.pivotaltracker\.com/story/new)?[[:punct:]]?(?:\s+@\S+)*\s*)\n[[:upper:]][[:lower:]]+:} ? true : false
82
+ end
83
+
84
+ def push
85
+ body = local_body
86
+ if story
87
+ return if story.to_s(pickler.format) == body.to_s
88
+ story.to_s = body
89
+ story.save!
90
+ else
91
+ unless pushable?
92
+ raise Error, "To create a new story, tag it @http://www.pivotaltracker.com/story/new"
93
+ end
94
+ story = pickler.new_story
95
+ story.to_s = body
96
+ @story = story.save!
97
+ unless body.sub!(%r{\bhttps?://www\.pivotaltracker\.com/story/new\b}, story.url)
98
+ body.sub!(/\A(?:#.*\n)?/,"# #{story.url}\n")
99
+ end
100
+ File.open(filename,'w') {|f| f.write body}
101
+ end
102
+ end
103
+
104
+ def finish
105
+ if filename
106
+ story.finish
107
+ story.to_s = local_body
108
+ story.save
109
+ else
110
+ story.finish!
111
+ end
112
+ end
113
+
114
+ def id
115
+ unless defined?(@id)
116
+ @id = if id = local_body.to_s[/(?:#\s*|@[[:punct:]]?)#{URL_REGEX}/,1]
117
+ id.to_i
118
+ end
119
+ end
120
+ @id
121
+ end
122
+
123
+ def story
124
+ @story ||= @pickler.project.story(id) if id
125
+ end
126
+
127
+ end
128
+ end
@@ -0,0 +1,517 @@
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
205
+ @format = :full
206
+ end
207
+
208
+ on "--raw", "same as the .feature" do
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
+ process do |*args|
306
+ args.replace(pickler.local_features) if args.empty?
307
+ args.each do |arg|
308
+ pickler.feature(arg).push
309
+ end
310
+ end
311
+ end
312
+
313
+ command :pull do
314
+ banner_arguments "[story] ..."
315
+ summary "Download stories"
316
+ description <<-EOF
317
+ Download the given story or all well formed stories to the features/ directory.
318
+ Previously unseen stories will be given a numeric filename that you are
319
+ encouraged to change.
320
+ EOF
321
+
322
+ process do |*args|
323
+ args.replace(pickler.scenario_features) if args.empty?
324
+ args.each do |identifier|
325
+ pickler.feature(identifier).pull
326
+ end
327
+ end
328
+ end
329
+
330
+ command :start do
331
+ banner_arguments "<story> [basename]"
332
+ summary "Pull a story and mark it started"
333
+ description <<-EOF
334
+ Pull a given story and change its state to started. If basename is given
335
+ and no local file exists, features/basename.feature will be created.
336
+ EOF
337
+
338
+ process do |story, *args|
339
+ pickler.feature(story).start(args.first)
340
+ end
341
+ end
342
+
343
+ command :finish do
344
+ banner_arguments "<story>"
345
+ summary "Push a story and mark it finished"
346
+
347
+ process do |story|
348
+ pickler.feature(story).finish
349
+ end
350
+ end
351
+
352
+ command :deliver do
353
+ banner_arguments "[story] ..."
354
+ summary "Mark stories delivered"
355
+ on "--all-finished", "deliver all finished stories" do
356
+ @all = true
357
+ end
358
+ process do |*args|
359
+ if @all
360
+ pickler.deliver_all_finished_stories
361
+ end
362
+ args.each do |arg|
363
+ pickler.story(arg).transition!('delivered')
364
+ end
365
+ end
366
+ end
367
+
368
+ command :unstart do
369
+ banner_arguments "[story] ..."
370
+ summary "Mark stories unstarted"
371
+ on "--all-started", "unstart all started stories" do
372
+ @all = true
373
+ end
374
+ process do |*args|
375
+ if @all
376
+ pickler.project.stories(:state => "started").each do |story|
377
+ story.transition!('unstarted')
378
+ end
379
+ end
380
+ args.each do |arg|
381
+ pickler.story(arg).transition!('unstarted')
382
+ end
383
+ end
384
+ end
385
+
386
+ command :unschedule do
387
+ banner_arguments "[story] ..."
388
+ summary "Move stories to icebox"
389
+ process do |*args|
390
+ args.each do |arg|
391
+ pickler.story(arg).transition!('unscheduled')
392
+ end
393
+ end
394
+ end
395
+
396
+ command :browse do
397
+ banner_arguments "[story]"
398
+ summary "Open a story in the web browser"
399
+ description <<-EOF
400
+ Open project or a story in the web browser.
401
+
402
+ Requires launchy (gem install launchy).
403
+ EOF
404
+
405
+ on "--dashboard" do
406
+ @special = "dashboard"
407
+ end
408
+ on "--faq" do
409
+ @special = "help"
410
+ end
411
+ on "--profile", "get your API Token here" do
412
+ @special = "profile"
413
+ end
414
+ on "--time", "not publicly available" do
415
+ @special = "time_shifts?project=#{pickler.project_id}"
416
+ end
417
+
418
+ process do |*args|
419
+ too_many if args.size > 1 || @special && args.first
420
+ if args.first
421
+ url = pickler.story(args.first).url
422
+ elsif @special
423
+ url = "http://www.pivotaltracker.com/#@special"
424
+ else
425
+ url = "http://www.pivotaltracker.com/projects/#{pickler.project_id}/stories"
426
+ end
427
+ require 'launchy'
428
+ Launchy.open(url)
429
+ end
430
+ end
431
+
432
+ command :comment do
433
+ banner_arguments "<story> <paragraph> ..."
434
+ summary "Post a comment to a story"
435
+
436
+ process do |story, *paragraphs|
437
+ pickler.story(story).comment!(paragraphs.join("\n\n"))
438
+ end
439
+ end
440
+
441
+ def initialize(argv)
442
+ @argv = argv
443
+ end
444
+
445
+ COLORS = {
446
+ :black => 0,
447
+ :red => 1,
448
+ :green => 2,
449
+ :yellow => 3,
450
+ :blue => 4,
451
+ :magenta => 5,
452
+ :cyan => 6,
453
+ :white => 7
454
+ }
455
+
456
+ STATE_COLORS = {
457
+ nil => COLORS[:black],
458
+ "rejected" => COLORS[:red],
459
+ "accepted" => COLORS[:green],
460
+ "delivered" => COLORS[:yellow],
461
+ "unscheduled" => COLORS[:white],
462
+ "started" => COLORS[:magenta],
463
+ "finished" => COLORS[:cyan],
464
+ "unstarted" => COLORS[:blue]
465
+ }
466
+
467
+ STATE_SYMBOLS = {
468
+ "unscheduled" => " ",
469
+ "unstarted" => ":|",
470
+ "started" => ":/",
471
+ "finished" => ":)",
472
+ "delivered" => ";)",
473
+ "rejected" => ":(",
474
+ "accepted" => ":D"
475
+ }
476
+
477
+ TYPE_COLORS = {
478
+ 'chore' => COLORS[:blue],
479
+ 'feature' => COLORS[:magenta],
480
+ 'bug' => COLORS[:red],
481
+ 'release' => COLORS[:cyan]
482
+ }
483
+
484
+ TYPE_SYMBOLS = {
485
+ "feature" => "*",
486
+ "chore" => "%",
487
+ "release" => "!",
488
+ "bug" => "/"
489
+ }
490
+
491
+ def run
492
+ command = @argv.shift
493
+ if klass = self.class[command]
494
+ result = klass.new(@argv).run
495
+ exit result.respond_to?(:to_int) ? result.to_int : 0
496
+ elsif ['help', '--help', '-h', '', nil].include?(command)
497
+ puts "usage: pickler <command> [options] [arguments]"
498
+ puts
499
+ puts "Commands:"
500
+ self.class.commands.each do |command|
501
+ puts " %-19s %s" % [command.command_name, command.summary]
502
+ end
503
+ puts
504
+ puts "Run pickler <command> --help for help with a given command"
505
+ else
506
+ raise Error, "Unknown pickler command #{command}"
507
+ end
508
+ rescue Pickler::Error
509
+ $stderr.puts "#$!"
510
+ exit 1
511
+ rescue Interrupt
512
+ $stderr.puts "Interrupted!"
513
+ exit 130
514
+ end
515
+
516
+ end
517
+ end