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 +20 -0
- data/README.rdoc +79 -0
- data/bin/pickler +8 -0
- data/bin/piv +8 -0
- data/lib/pickler.rb +139 -0
- data/lib/pickler/feature.rb +112 -0
- data/lib/pickler/runner.rb +553 -0
- data/lib/pickler/tracker.rb +116 -0
- data/lib/pickler/tracker/iteration.rb +45 -0
- data/lib/pickler/tracker/note.rb +31 -0
- data/lib/pickler/tracker/project.rb +47 -0
- data/lib/pickler/tracker/story.rb +193 -0
- data/pickler.gemspec +29 -0
- metadata +85 -0
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
data/bin/piv
ADDED
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
|