nofxx-pickler 0.0.10

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,79 @@
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 "username: ..." > ~/.tracker.yml
14
+ echo "project_id: ..." > ~/my/app/features/tracker.yml
15
+ echo "ssl: [true|false]" >> ~/my/app/features/tracker.yml
16
+ pickler --help
17
+
18
+ "ssl" defaults to false if not configured in the yml file.
19
+
20
+ For details about the Pivotal Tracker API, including where to find your API
21
+ token and project id, see http://www.pivotaltracker.com/help/api .
22
+
23
+ The pull and push commands map the story's name into the "Feature: ..." line
24
+ and the story's description with an additional two space indent into the
25
+ feature's body. Keep this in mind when entering stories into Pivotal Tracker.
26
+
27
+ == Writing stories
28
+
29
+ In order for pickler to pick up your stories from tracker, they need to meet the following criteria:
30
+
31
+ * They must be wellformed cucumber stories
32
+ * They must contain the word 'Scenario' (or the equivalent in your localized stories)
33
+ * Their status must be set to 'started' at least
34
+
35
+ == Usage
36
+
37
+ pickler pull
38
+
39
+ Download all well formed stories to the features/ directory.
40
+
41
+ pickler push
42
+
43
+ Upload all features with a tracker url in a comment on the first line.
44
+
45
+ pickler search <query>
46
+
47
+ List all stories matching the given query.
48
+
49
+ pickler start <story>
50
+
51
+ Pull a given feature and change its state to started.
52
+
53
+ pickler finish <story>
54
+
55
+ Push a given feature and change its state to finished.
56
+
57
+ pickler todo
58
+
59
+ List all stories assigned to your username.
60
+
61
+ pickler --help
62
+
63
+ Full list of commands.
64
+
65
+ pickler <command> --help
66
+
67
+ Further help for a given command.
68
+
69
+ piv <command>
70
+
71
+ For your fingers sake.
72
+
73
+ == Disclaimer
74
+
75
+ No warranties, expressed or implied.
76
+
77
+ Notably, the push and pull commands are quite happy to blindly clobber
78
+ features if so instructed. Pivotal Tracker has a history to recover things
79
+ server side.
data/bin/pickler ADDED
@@ -0,0 +1,8 @@
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
+ ARGV << "started" if ARGV.empty?
8
+ Pickler.run(ARGV)
data/bin/piv ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # TODO: better way to "alias" a bin?
3
+ $:.unshift(File.join(File.dirname(File.dirname(__FILE__)),'lib'))
4
+ begin; require 'rubygems'; rescue LoadError; end
5
+ require 'pickler'
6
+
7
+ ARGV << "started" if ARGV.empty?
8
+ Pickler.run(ARGV)
data/lib/pickler.rb ADDED
@@ -0,0 +1,139 @@
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 parser
86
+ require 'cucumber'
87
+ Cucumber.load_language(@lang)
88
+ @parser ||= Cucumber::Parser::FeatureParser.new
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
+ parser
110
+ Cucumber.keyword_hash['scenario']
111
+ end
112
+
113
+ def format
114
+ (config['format'] || :comment).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
122
+ project.stories(scenario_word, :includedone => true).reject do |s|
123
+ s.current_state =~ /^unscheduled|unstarted$/
124
+ end.select do |s|
125
+ s.to_s =~ /^\s*#{Regexp.escape(scenario_word)}:/ && parser.parse(s.to_s)
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,112 @@
1
+ class Pickler
2
+ class Feature
3
+ URL_REGEX = %r{\bhttp://www\.pivotaltracker\.com/\S*/(\d+)\b}
4
+ attr_reader :pickler
5
+
6
+ def initialize(pickler, identifier)
7
+ @pickler = pickler
8
+ case identifier
9
+ when nil, /^\s+$/
10
+ raise Error, "No feature given"
11
+
12
+ when Pickler::Tracker::Story
13
+ @story = identifier
14
+ @id = @story.id
15
+
16
+ when Integer
17
+ @id = identifier
18
+
19
+ when /^#{URL_REGEX}$/, /^(\d+)$/
20
+ @id = $1.to_i
21
+
22
+ when /\.feature$/
23
+ if File.exist?(identifier)
24
+ @filename = identifier
25
+ end
26
+
27
+ else
28
+ if File.exist?(path = pickler.features_path("#{identifier}.feature"))
29
+ @filename = path
30
+ end
31
+
32
+ end or raise Error, "Unrecognizable feature #{identifier}"
33
+ end
34
+
35
+ def local_body
36
+ File.read(filename) if filename
37
+ end
38
+
39
+ def filename
40
+ unless defined?(@filename)
41
+ @filename = Dir[pickler.features_path("**","*.feature")].detect do |f|
42
+ File.read(f)[/(?:#\s*|@[[: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
+ filename = filename() || pickler.features_path("#{default||id}.feature")
54
+ story = story() # force the read into local_body before File.open below blows it away
55
+ File.open(filename,'w') {|f| f.puts story.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:]]?(?:http://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
+ return if story.to_s(pickler.format) == body.to_s
73
+ if story
74
+ story.to_s = body
75
+ story.save!
76
+ else
77
+ unless pushable?
78
+ raise Error, "To create a new story, make the first line an empty comment"
79
+ end
80
+ story = pickler.new_story
81
+ story.to_s = body
82
+ @story = story.save!
83
+ body.sub!(/\A(?:#.*\n)?/,"# #{story.url}\n")
84
+ File.open(filename,'w') {|f| f.write body}
85
+ end
86
+ end
87
+
88
+ def finish
89
+ if filename
90
+ story.finish
91
+ story.to_s = local_body
92
+ story.save
93
+ else
94
+ story.finish!
95
+ end
96
+ end
97
+
98
+ def id
99
+ unless defined?(@id)
100
+ @id = if id = local_body.to_s[/(?:#\s*|@[[:punct:]]?)#{URL_REGEX}/,1]
101
+ id.to_i
102
+ end
103
+ end
104
+ @id
105
+ end
106
+
107
+ def story
108
+ @story ||= @pickler.project.story(id) if id
109
+ end
110
+
111
+ end
112
+ end
@@ -0,0 +1,553 @@
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.group_by(&:current_state).each do |state, state_stories|
284
+ print colorize("01;3#{STATE_COLORS[state]}", "# #{state.capitalize}")
285
+ sum = state_stories.sum {|s| s.estimate || 0 }
286
+ len = state_stories.length
287
+ puts colorize("01;30", " (#{sum} points, #{len} #{len > 1 ? 'stories' : 'story' })")
288
+ for story in state_stories
289
+ if @full
290
+ puts unless first
291
+ puts_full story
292
+ else
293
+ puts_summary story
294
+ end
295
+ first = false
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end
301
+
302
+ command :list do
303
+ banner_arguments "[query]"
304
+ summary "Same as search"
305
+
306
+ process do |*argv|
307
+ Pickler.run([:search, argv])
308
+ end
309
+ end
310
+
311
+ command :push do
312
+ banner_arguments "[story] ..."
313
+ summary "Upload stories"
314
+ description <<-EOF
315
+ Upload the given story or all features with a tracker url in a comment on the
316
+ first line. Features with a blank comment in the first line will created as
317
+ new stories.
318
+ EOF
319
+
320
+ process do |*args|
321
+ args.replace(pickler.local_features) if args.empty?
322
+ args.each do |arg|
323
+ pickler.feature(arg).push
324
+ end
325
+ end
326
+ end
327
+
328
+ command :pull do
329
+ banner_arguments "[story] ..."
330
+ summary "Download stories"
331
+ description <<-EOF
332
+ Download the given story or all well formed stories to the features/ directory.
333
+ Previously unseen stories will be given a numeric filename that you are
334
+ encouraged to change.
335
+ EOF
336
+
337
+ process do |*args|
338
+ args.replace(pickler.scenario_features) if args.empty?
339
+ args.each do |arg|
340
+ pickler.feature(arg).pull
341
+ end
342
+ end
343
+ end
344
+
345
+ command :start do
346
+ banner_arguments "<story> [basename]"
347
+ summary "Pull a story and mark it started"
348
+ description <<-EOF
349
+ Pull a given story and change its state to started. If basename is given
350
+ and no local file exists, features/basename.feature will be created.
351
+ EOF
352
+
353
+ process do |story, *args|
354
+ pickler.feature(story).start(args.first)
355
+ end
356
+ end
357
+
358
+ command :finish do
359
+ banner_arguments "<story>"
360
+ summary "Push a story and mark it finished"
361
+
362
+ process do |story|
363
+ pickler.feature(story).finish
364
+ end
365
+ end
366
+
367
+ command :deliver do
368
+ banner_arguments "[story] ..."
369
+ summary "Mark stories delivered"
370
+ on "--all-finished", "deliver all finished stories" do
371
+ @all = true
372
+ end
373
+ process do |*args|
374
+ if @all
375
+ pickler.deliver_all_finished_stories
376
+ end
377
+ args.each do |arg|
378
+ pickler.story(arg).transition!('delivered')
379
+ end
380
+ end
381
+ end
382
+
383
+ command :unstart do
384
+ banner_arguments "[story] ..."
385
+ summary "Mark stories unstarted"
386
+ on "--all-started", "unstart all started stories" do
387
+ @all = true
388
+ end
389
+ process do |*args|
390
+ if @all
391
+ pickler.project.stories(:state => "started").each do |story|
392
+ story.transition!('unstarted')
393
+ end
394
+ end
395
+ args.each do |arg|
396
+ pickler.story(arg).transition!('unstarted')
397
+ end
398
+ end
399
+ end
400
+
401
+ command :unschedule do
402
+ banner_arguments "[story] ..."
403
+ summary "Move stories to icebox"
404
+ process do |*args|
405
+ args.each do |arg|
406
+ pickler.story(arg).transition!('unscheduled')
407
+ end
408
+ end
409
+ end
410
+
411
+ command :browse do
412
+ banner_arguments "[story]"
413
+ summary "Open a story in the web browser"
414
+ description <<-EOF
415
+ Open project or a story in the web browser.
416
+
417
+ Requires launchy (gem install launchy).
418
+ EOF
419
+
420
+ on "--dashboard" do
421
+ @special = "dashboard"
422
+ end
423
+ on "--faq" do
424
+ @special = "help"
425
+ end
426
+ on "--profile", "get your API Token here" do
427
+ @special = "profile"
428
+ end
429
+ on "--time", "not publicly available" do
430
+ @special = "time_shifts?project=#{pickler.project_id}"
431
+ end
432
+
433
+ process do |*args|
434
+ too_many if args.size > 1 || @special && args.first
435
+ if args.first
436
+ url = pickler.story(args.first).url
437
+ elsif @special
438
+ url = "http://www.pivotaltracker.com/#@special"
439
+ else
440
+ url = "http://www.pivotaltracker.com/projects/#{pickler.project_id}/stories"
441
+ end
442
+ require 'launchy'
443
+ Launchy.open(url)
444
+ end
445
+ end
446
+
447
+ command :comment do
448
+ banner_arguments "<story> <paragraph> ..."
449
+ summary "Post a comment to a story"
450
+
451
+ process do |story, *paragraphs|
452
+ pickler.story(story).comment!(paragraphs.join("\n\n"))
453
+ end
454
+ end
455
+
456
+ command :started do
457
+ summary "Started stories."
458
+ description <<-EOF
459
+ Show only started stories.
460
+ EOF
461
+ process do
462
+ Pickler.run(["search", "-c"])
463
+ end
464
+ end
465
+
466
+ command :todo do
467
+ summary "Show my stories."
468
+ description <<-EOF
469
+ Show only stories assigned to yourself. Note: You must set 'username'
470
+ on ~/.tracker.yml.
471
+ EOF
472
+ process do
473
+ Pickler.run(["search", "--mywork=#{pickler.config['username']}"])
474
+ end
475
+ end
476
+
477
+ def initialize(argv)
478
+ @argv = argv
479
+ end
480
+
481
+ COLORS = {
482
+ :black => 0,
483
+ :red => 1,
484
+ :green => 2,
485
+ :yellow => 3,
486
+ :blue => 4,
487
+ :magenta => 5,
488
+ :cyan => 6,
489
+ :white => 7
490
+ }
491
+
492
+ STATE_COLORS = {
493
+ nil => COLORS[:black],
494
+ "rejected" => COLORS[:red],
495
+ "accepted" => COLORS[:blue],
496
+ "delivered" => COLORS[:yellow],
497
+ "unscheduled" => COLORS[:white],
498
+ "started" => COLORS[:green],
499
+ "finished" => COLORS[:cyan],
500
+ "unstarted" => COLORS[:magenta]
501
+ }
502
+
503
+ STATE_SYMBOLS = {
504
+ "unscheduled" => " ",
505
+ "unstarted" => ":|",
506
+ "started" => ":/",
507
+ "finished" => ":)",
508
+ "delivered" => ";)",
509
+ "rejected" => ":(",
510
+ "accepted" => ":D"
511
+ }
512
+
513
+ TYPE_COLORS = {
514
+ 'chore' => COLORS[:blue],
515
+ 'feature' => COLORS[:green],
516
+ 'bug' => COLORS[:red],
517
+ 'release' => COLORS[:cyan]
518
+ }
519
+
520
+ TYPE_SYMBOLS = {
521
+ "feature" => "*",
522
+ "chore" => "%",
523
+ "release" => "!",
524
+ "bug" => "/"
525
+ }
526
+
527
+ def run
528
+ command = @argv.shift
529
+ if klass = self.class[command]
530
+ result = klass.new(@argv).run
531
+ exit result.respond_to?(:to_int) ? result.to_int : 0
532
+ elsif ['help', '--help', '-h', '', nil].include?(command)
533
+ puts "usage: pickler <command> [options] [arguments]"
534
+ puts
535
+ puts "Commands:"
536
+ self.class.commands.each do |command|
537
+ puts " %-19s %s" % [command.command_name, command.summary]
538
+ end
539
+ puts
540
+ puts "Run pickler <command> --help for help with a given command"
541
+ else
542
+ raise Error, "Unknown pickler command #{command}"
543
+ end
544
+ rescue Pickler::Error
545
+ $stderr.puts "#$!"
546
+ exit 1
547
+ rescue Interrupt
548
+ $stderr.puts "Interrupted!"
549
+ exit 130
550
+ end
551
+
552
+ end
553
+ end