pickler 0.1.3

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,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
+ self.class.config.merge(@config)
48
+ end
49
+
50
+ def real_name
51
+ config["real_name"] || (require 'etc'; Etc.getpwuid.gecos.split(',').first)
52
+ end
53
+
54
+ def new_story(attributes = {}, &block)
55
+ attributes = attributes.inject('requested_by' => real_name) do |h,(k,v)|
56
+ h.update(k.to_s => v)
57
+ end
58
+ project.new_story(attributes, &block)
59
+ end
60
+
61
+ def stories(*args)
62
+ project.stories(*args)
63
+ end
64
+
65
+ def name
66
+ project.name
67
+ end
68
+
69
+ def iteration_length
70
+ project.iteration_length
71
+ end
72
+
73
+ def point_scale
74
+ project.point_scale
75
+ end
76
+
77
+ def week_start_day
78
+ project.week_start_day
79
+ end
80
+
81
+ def deliver_all_finished_stories
82
+ project.deliver_all_finished_stories
83
+ end
84
+
85
+ def parse(story)
86
+ require 'cucumber'
87
+ Cucumber::FeatureFile.new(story.url, story.to_s).parse(Cucumber::StepMother.new, :lang => @lang)
88
+ end
89
+
90
+ def project_id
91
+ config["project_id"] || (self.class.config["projects"]||{})[File.basename(@directory)]
92
+ end
93
+
94
+ def project
95
+ @project ||= Dir.chdir(@directory) do
96
+ unless token = config['api_token']
97
+ raise Error, 'echo api_token: ... > ~/.tracker.yml'
98
+ end
99
+ unless id = project_id
100
+ raise Error, 'echo project_id: ... > features/tracker.yml'
101
+ end
102
+ ssl = config['ssl']
103
+ Tracker.new(token, ssl).project(id)
104
+ end
105
+ end
106
+
107
+ def scenario_word
108
+ require 'cucumber'
109
+ Cucumber::LANGUAGES[@lang]['scenario']
110
+ end
111
+
112
+ def format
113
+ (config['format'] || :tag).to_sym
114
+ end
115
+
116
+ def local_features
117
+ Dir[features_path('**','*.feature')].map {|f|feature(f)}.select {|f|f.pushable?}
118
+ end
119
+
120
+ def scenario_features(excluded_states = %w(unscheduled unstarted))
121
+ project.stories(scenario_word, :includedone => true).reject do |s|
122
+ Array(excluded_states).map {|state| state.to_s}.include?(s.current_state)
123
+ end.select do |s|
124
+ s.to_s =~ /^\s*#{Regexp.escape(scenario_word)}:/ && parse(s) rescue false
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,114 @@
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 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*|@[[:punct:]]?)#{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(pickler.format)
50
+ end
51
+
52
+ def pull(default = nil)
53
+ story = story() # force the read into local_body before File.open below blows it away
54
+ filename = filename() || pickler.features_path("#{story.suggested_basename(default)}.feature")
55
+ File.open(filename,'w') {|f| f.puts story.to_s(pickler.format)}
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 pushable?
67
+ id || local_body =~ %r{\A(?:#\s*|@[[:punct:]]?(?:https?://www\.pivotaltracker\.com/story/new)?[[:punct:]]?(?:\s+@\S+)*\s*)\n[[:upper:]][[:lower:]]+:} ? true : false
68
+ end
69
+
70
+ def push
71
+ body = local_body
72
+ if story
73
+ return if story.to_s(pickler.format) == body.to_s
74
+ story.to_s = body
75
+ story.save!
76
+ else
77
+ unless pushable?
78
+ raise Error, "To create a new story, tag it @http://www.pivotaltracker.com/story/new"
79
+ end
80
+ story = pickler.new_story
81
+ story.to_s = body
82
+ @story = story.save!
83
+ unless body.sub!(%r{\bhttps?://www\.pivotaltracker\.com/story/new\b}, story.url)
84
+ body.sub!(/\A(?:#.*\n)?/,"# #{story.url}\n")
85
+ end
86
+ File.open(filename,'w') {|f| f.write body}
87
+ end
88
+ end
89
+
90
+ def finish
91
+ if filename
92
+ story.finish
93
+ story.to_s = local_body
94
+ story.save
95
+ else
96
+ story.finish!
97
+ end
98
+ end
99
+
100
+ def id
101
+ unless defined?(@id)
102
+ @id = if id = local_body.to_s[/(?:#\s*|@[[:punct:]]?)#{URL_REGEX}/,1]
103
+ id.to_i
104
+ end
105
+ end
106
+ @id
107
+ end
108
+
109
+ def story
110
+ @story ||= @pickler.project.story(id) if id
111
+ end
112
+
113
+ end
114
+ 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
+ EOF
319
+
320
+ process do |*args|
321
+ args.replace(pickler.scenario_features) if args.empty?
322
+ args.each do |arg|
323
+ pickler.feature(arg).pull
324
+ end
325
+ end
326
+ end
327
+
328
+ command :start do
329
+ banner_arguments "<story> [basename]"
330
+ summary "Pull a story and mark it started"
331
+ description <<-EOF
332
+ Pull a given story and change its state to started. If basename is given
333
+ and no local file exists, features/basename.feature will be created. Give a
334
+ basename of "-" to use a downcased, underscored version of the story name as
335
+ the basename.
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