kosmas58-pickler 0.0.6.1
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 +62 -0
- data/bin/pickler +7 -0
- data/lib/pickler.rb +138 -0
- data/lib/pickler/feature.rb +103 -0
- data/lib/pickler/runner.rb +514 -0
- data/lib/pickler/tracker.rb +113 -0
- data/lib/pickler/tracker/iteration.rb +38 -0
- data/lib/pickler/tracker/note.rb +27 -0
- data/lib/pickler/tracker/project.rb +47 -0
- data/lib/pickler/tracker/story.rb +166 -0
- data/pickler.gemspec +30 -0
- metadata +102 -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,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.
|
data/bin/pickler
ADDED
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
|
+
@lang = @config["lang"] || "en"
|
48
|
+
self.class.config.merge(@config)
|
49
|
+
end
|
50
|
+
|
51
|
+
def real_name
|
52
|
+
config["real_name"] || (require 'etc'; Etc.getpwuid.gecos.split(',').first)
|
53
|
+
end
|
54
|
+
|
55
|
+
def new_story(attributes = {}, &block)
|
56
|
+
attributes = attributes.inject('requested_by' => real_name) do |h,(k,v)|
|
57
|
+
h.update(k.to_s => v)
|
58
|
+
end
|
59
|
+
project.new_story(attributes, &block)
|
60
|
+
end
|
61
|
+
|
62
|
+
def stories(*args)
|
63
|
+
project.stories(*args)
|
64
|
+
end
|
65
|
+
|
66
|
+
def name
|
67
|
+
project.name
|
68
|
+
end
|
69
|
+
|
70
|
+
def iteration_length
|
71
|
+
project.iteration_length
|
72
|
+
end
|
73
|
+
|
74
|
+
def point_scale
|
75
|
+
project.point_scale
|
76
|
+
end
|
77
|
+
|
78
|
+
def week_start_day
|
79
|
+
project.week_start_day
|
80
|
+
end
|
81
|
+
|
82
|
+
def deliver_all_finished_stories
|
83
|
+
project.deliver_all_finished_stories
|
84
|
+
end
|
85
|
+
|
86
|
+
def parser
|
87
|
+
require 'cucumber'
|
88
|
+
require "cucumber/treetop_parser/feature_#@lang"
|
89
|
+
Cucumber.load_language(@lang)
|
90
|
+
@parser ||= Cucumber::TreetopParser::FeatureParser.new
|
91
|
+
end
|
92
|
+
|
93
|
+
def project_id
|
94
|
+
config["project_id"] || (self.class.config["projects"]||{})[File.basename(@directory)]
|
95
|
+
end
|
96
|
+
|
97
|
+
def project
|
98
|
+
@project ||= Dir.chdir(@directory) do
|
99
|
+
unless token = config['api_token']
|
100
|
+
raise Error, 'echo api_token: ... > ~/.tracker.yml'
|
101
|
+
end
|
102
|
+
unless id = project_id
|
103
|
+
raise Error, 'echo project_id: ... > features/tracker.yml'
|
104
|
+
end
|
105
|
+
ssl = config['ssl']
|
106
|
+
Tracker.new(token, ssl).project(id)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def scenario_word
|
111
|
+
parser
|
112
|
+
Cucumber.language['scenario']
|
113
|
+
end
|
114
|
+
|
115
|
+
def local_features
|
116
|
+
Dir[features_path('**','*.feature')].map {|f|feature(f)}.select {|f|f.id}
|
117
|
+
end
|
118
|
+
|
119
|
+
def scenario_features(includes)
|
120
|
+
ignored_states = %w(unscheduled unstarted) - Array(includes)
|
121
|
+
project.stories(scenario_word, :includedone => true).reject do |s|
|
122
|
+
ignored_states.include?(s.current_state)
|
123
|
+
end.select do |s|
|
124
|
+
s.to_s =~ /^\s*#{Regexp.escape(scenario_word)}:/ && parser.parse(s.to_s)
|
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,103 @@
|
|
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*#{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
|
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}
|
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 push
|
67
|
+
return if story.to_s == local_body.to_s
|
68
|
+
story.to_s = local_body
|
69
|
+
story.save
|
70
|
+
self
|
71
|
+
end
|
72
|
+
|
73
|
+
def finish
|
74
|
+
if filename
|
75
|
+
story.finish
|
76
|
+
story.to_s = local_body
|
77
|
+
story.save
|
78
|
+
else
|
79
|
+
story.finish!
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def id
|
84
|
+
unless defined?(@id)
|
85
|
+
@id = if id = local_body.to_s[/#\s*#{URL_REGEX}/,1]
|
86
|
+
id.to_i
|
87
|
+
end
|
88
|
+
end
|
89
|
+
@id
|
90
|
+
end
|
91
|
+
|
92
|
+
def story
|
93
|
+
unless defined?(@story)
|
94
|
+
@story = new_feature? ? pickler.new_story(:story_type => "feature") : pickler.project.story(id)
|
95
|
+
end
|
96
|
+
@story
|
97
|
+
end
|
98
|
+
|
99
|
+
def new_feature?
|
100
|
+
id == nil
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,514 @@
|
|
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
|
+
process do |*args|
|
205
|
+
case args.size
|
206
|
+
when 0
|
207
|
+
puts "#{pickler.project_id} #{pickler.project.name}"
|
208
|
+
when 1
|
209
|
+
story = pickler.story(args.first)
|
210
|
+
paginated_output do
|
211
|
+
puts_full story
|
212
|
+
end
|
213
|
+
else
|
214
|
+
too_many
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
command :search do
|
220
|
+
banner_arguments "[query]"
|
221
|
+
summary "List all stories matching a query"
|
222
|
+
|
223
|
+
def modifications
|
224
|
+
@modifications ||= {}
|
225
|
+
end
|
226
|
+
[:label, :type, :state].each do |o|
|
227
|
+
on "--#{o} #{o.to_s.upcase}" do |value|
|
228
|
+
modifications[o] = value
|
229
|
+
end
|
230
|
+
end
|
231
|
+
[:requester, :owner, :mywork].each do |o|
|
232
|
+
on "--#{o}[=USERNAME]" do |value|
|
233
|
+
modifications[o] = value || pickler.real_name
|
234
|
+
end
|
235
|
+
end
|
236
|
+
on "--[no-]includedone", "include accepted stories" do |value|
|
237
|
+
modifications[:includedone] = value
|
238
|
+
@iterations ||= []
|
239
|
+
@iterations << :done?
|
240
|
+
end
|
241
|
+
|
242
|
+
on "-b", "--backlog", "filter results to future iterations" do |c|
|
243
|
+
@iterations ||= []
|
244
|
+
@iterations << :backlog?
|
245
|
+
end
|
246
|
+
|
247
|
+
on "-c", "--current", "filter results to current iteration" do |b|
|
248
|
+
@iterations ||= []
|
249
|
+
@iterations << :current?
|
250
|
+
end
|
251
|
+
|
252
|
+
on "--[no-]full", "show full story, not a summary line" do |b|
|
253
|
+
@full = b
|
254
|
+
end
|
255
|
+
|
256
|
+
process do |*argv|
|
257
|
+
argv << modifications unless modifications.empty?
|
258
|
+
if argv == [{:includedone => true}]
|
259
|
+
# Bypass the 200 search results limitation
|
260
|
+
stories = pickler.project.stories
|
261
|
+
else
|
262
|
+
stories = pickler.project.stories(*argv)
|
263
|
+
end
|
264
|
+
if @iterations && @iterations != [:done?]
|
265
|
+
stories.reject! {|s| !@iterations.any? {|i| s.send(i)}}
|
266
|
+
end
|
267
|
+
paginated_output do
|
268
|
+
first = true
|
269
|
+
stories.each do |story|
|
270
|
+
if @full
|
271
|
+
puts unless first
|
272
|
+
puts_full story
|
273
|
+
else
|
274
|
+
puts_summary story
|
275
|
+
end
|
276
|
+
first = false
|
277
|
+
end
|
278
|
+
end
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
command :push do
|
283
|
+
banner_arguments "[story] ..."
|
284
|
+
summary "Upload stories"
|
285
|
+
description <<-EOF
|
286
|
+
Upload the given story or all features with a tracker url in a comment on the
|
287
|
+
first line.
|
288
|
+
EOF
|
289
|
+
|
290
|
+
process do |*args|
|
291
|
+
args.replace(pickler.local_features) if args.empty?
|
292
|
+
args.each do |arg|
|
293
|
+
feature = pickler.feature(arg).push
|
294
|
+
File.open(feature.filename, 'w') { |f| f.puts feature.story } if feature.new_feature?
|
295
|
+
end
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
299
|
+
command :pull do
|
300
|
+
banner_arguments "[story] ..."
|
301
|
+
summary "Download stories"
|
302
|
+
description <<-EOF
|
303
|
+
Download the given story or all well formed stories to the features/ directory.
|
304
|
+
Previously unseen stories will be given a numeric filename that you are
|
305
|
+
encouraged to change.
|
306
|
+
EOF
|
307
|
+
on "--include-unscheduled", "pull unscheduled stories" do
|
308
|
+
@includes ||= []
|
309
|
+
@includes << "unscheduled"
|
310
|
+
end
|
311
|
+
on "--include-unstarted", "pull unstarted stories" do
|
312
|
+
@includes ||= []
|
313
|
+
@includes << "unstarted"
|
314
|
+
end
|
315
|
+
on "--all", "pull unstarted and unscheduled stories" do
|
316
|
+
@includes = %w(unscheduled unstarted)
|
317
|
+
end
|
318
|
+
|
319
|
+
process do |*args|
|
320
|
+
args.replace(pickler.scenario_features(@includes)) if args.empty?
|
321
|
+
args.each do |arg|
|
322
|
+
pickler.feature(arg).pull
|
323
|
+
end
|
324
|
+
end
|
325
|
+
end
|
326
|
+
|
327
|
+
command :start do
|
328
|
+
banner_arguments "<story> [basename]"
|
329
|
+
summary "Pull a story and mark it started"
|
330
|
+
description <<-EOF
|
331
|
+
Pull a given story and change its state to started. If basename is given
|
332
|
+
and no local file exists, features/basename.feature will be created.
|
333
|
+
EOF
|
334
|
+
|
335
|
+
process do |story, *args|
|
336
|
+
pickler.feature(story).start(args.first)
|
337
|
+
end
|
338
|
+
end
|
339
|
+
|
340
|
+
command :finish do
|
341
|
+
banner_arguments "<story>"
|
342
|
+
summary "Push a story and mark it finished"
|
343
|
+
|
344
|
+
process do |story|
|
345
|
+
pickler.feature(story).finish
|
346
|
+
end
|
347
|
+
end
|
348
|
+
|
349
|
+
command :deliver do
|
350
|
+
banner_arguments "[story] ..."
|
351
|
+
summary "Mark stories delivered"
|
352
|
+
on "--all-finished", "deliver all finished stories" do
|
353
|
+
@all = true
|
354
|
+
end
|
355
|
+
process do |*args|
|
356
|
+
if @all
|
357
|
+
pickler.deliver_all_finished_stories
|
358
|
+
end
|
359
|
+
args.each do |arg|
|
360
|
+
pickler.story(arg).transition!('delivered')
|
361
|
+
end
|
362
|
+
end
|
363
|
+
end
|
364
|
+
|
365
|
+
command :unstart do
|
366
|
+
banner_arguments "[story] ..."
|
367
|
+
summary "Mark stories unstarted"
|
368
|
+
on "--all-started", "unstart all started stories" do
|
369
|
+
@all = true
|
370
|
+
end
|
371
|
+
process do |*args|
|
372
|
+
if @all
|
373
|
+
pickler.project.stories(:state => "started").each do |story|
|
374
|
+
story.transition!('unstarted')
|
375
|
+
end
|
376
|
+
end
|
377
|
+
args.each do |arg|
|
378
|
+
pickler.story(arg).transition!('unstarted')
|
379
|
+
end
|
380
|
+
end
|
381
|
+
end
|
382
|
+
|
383
|
+
command :unschedule do
|
384
|
+
banner_arguments "[story] ..."
|
385
|
+
summary "Move stories to icebox"
|
386
|
+
process do |*args|
|
387
|
+
args.each do |arg|
|
388
|
+
pickler.story(arg).transition!('unscheduled')
|
389
|
+
end
|
390
|
+
end
|
391
|
+
end
|
392
|
+
|
393
|
+
command :browse do
|
394
|
+
banner_arguments "[story]"
|
395
|
+
summary "Open a story in the web browser"
|
396
|
+
description <<-EOF
|
397
|
+
Open project or a story in the web browser.
|
398
|
+
|
399
|
+
Requires launchy (gem install launchy).
|
400
|
+
EOF
|
401
|
+
|
402
|
+
on "--dashboard" do
|
403
|
+
@special = "dashboard"
|
404
|
+
end
|
405
|
+
on "--faq" do
|
406
|
+
@special = "help"
|
407
|
+
end
|
408
|
+
on "--profile", "get your API Token here" do
|
409
|
+
@special = "profile"
|
410
|
+
end
|
411
|
+
on "--time", "not publicly available" do
|
412
|
+
@special = "time_shifts?project=#{pickler.project_id}"
|
413
|
+
end
|
414
|
+
|
415
|
+
process do |*args|
|
416
|
+
too_many if args.size > 1 || @special && args.first
|
417
|
+
if args.first
|
418
|
+
url = pickler.story(args.first).url
|
419
|
+
elsif @special
|
420
|
+
url = "http://www.pivotaltracker.com/#@special"
|
421
|
+
else
|
422
|
+
url = "http://www.pivotaltracker.com/projects/#{pickler.project_id}/stories"
|
423
|
+
end
|
424
|
+
require 'launchy'
|
425
|
+
Launchy.open(url)
|
426
|
+
end
|
427
|
+
end
|
428
|
+
|
429
|
+
command :comment do
|
430
|
+
banner_arguments "<story> <paragraph> ..."
|
431
|
+
summary "Post a comment to a story"
|
432
|
+
|
433
|
+
process do |story, *paragraphs|
|
434
|
+
pickler.story(story).comment!(paragraphs.join("\n\n"))
|
435
|
+
end
|
436
|
+
end
|
437
|
+
|
438
|
+
def initialize(argv)
|
439
|
+
@argv = argv
|
440
|
+
end
|
441
|
+
|
442
|
+
COLORS = {
|
443
|
+
:black => 0,
|
444
|
+
:red => 1,
|
445
|
+
:green => 2,
|
446
|
+
:yellow => 3,
|
447
|
+
:blue => 4,
|
448
|
+
:magenta => 5,
|
449
|
+
:cyan => 6,
|
450
|
+
:white => 7
|
451
|
+
}
|
452
|
+
|
453
|
+
STATE_COLORS = {
|
454
|
+
nil => COLORS[:black],
|
455
|
+
"rejected" => COLORS[:red],
|
456
|
+
"accepted" => COLORS[:green],
|
457
|
+
"delivered" => COLORS[:yellow],
|
458
|
+
"unscheduled" => COLORS[:white],
|
459
|
+
"started" => COLORS[:magenta],
|
460
|
+
"finished" => COLORS[:cyan],
|
461
|
+
"unstarted" => COLORS[:blue]
|
462
|
+
}
|
463
|
+
|
464
|
+
STATE_SYMBOLS = {
|
465
|
+
"unscheduled" => " ",
|
466
|
+
"unstarted" => ":|",
|
467
|
+
"started" => ":/",
|
468
|
+
"finished" => ":)",
|
469
|
+
"delivered" => ";)",
|
470
|
+
"rejected" => ":(",
|
471
|
+
"accepted" => ":D"
|
472
|
+
}
|
473
|
+
|
474
|
+
TYPE_COLORS = {
|
475
|
+
'chore' => COLORS[:blue],
|
476
|
+
'feature' => COLORS[:magenta],
|
477
|
+
'bug' => COLORS[:red],
|
478
|
+
'release' => COLORS[:cyan]
|
479
|
+
}
|
480
|
+
|
481
|
+
TYPE_SYMBOLS = {
|
482
|
+
"feature" => "*",
|
483
|
+
"chore" => "%",
|
484
|
+
"release" => "!",
|
485
|
+
"bug" => "/"
|
486
|
+
}
|
487
|
+
|
488
|
+
def run
|
489
|
+
command = @argv.shift
|
490
|
+
if klass = self.class[command]
|
491
|
+
result = klass.new(@argv).run
|
492
|
+
exit result.respond_to?(:to_int) ? result.to_int : 0
|
493
|
+
elsif ['help', '--help', '-h', '', nil].include?(command)
|
494
|
+
puts "usage: pickler <command> [options] [arguments]"
|
495
|
+
puts
|
496
|
+
puts "Commands:"
|
497
|
+
self.class.commands.each do |command|
|
498
|
+
puts " %-19s %s" % [command.command_name, command.summary]
|
499
|
+
end
|
500
|
+
puts
|
501
|
+
puts "Run pickler <command> --help for help with a given command"
|
502
|
+
else
|
503
|
+
raise Error, "Unknown pickler command #{command}"
|
504
|
+
end
|
505
|
+
rescue Pickler::Error
|
506
|
+
$stderr.puts "#$!"
|
507
|
+
exit 1
|
508
|
+
rescue Interrupt
|
509
|
+
$stderr.puts "Interrupted!"
|
510
|
+
exit 130
|
511
|
+
end
|
512
|
+
|
513
|
+
end
|
514
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
class Pickler
|
4
|
+
class Tracker
|
5
|
+
|
6
|
+
ADDRESS = 'www.pivotaltracker.com'
|
7
|
+
BASE_PATH = '/services/v1'
|
8
|
+
SEARCH_KEYS = %w(label type state requester owner mywork id includedone)
|
9
|
+
|
10
|
+
class Error < Pickler::Error; end
|
11
|
+
|
12
|
+
attr_reader :token
|
13
|
+
|
14
|
+
def initialize(token, ssl = false)
|
15
|
+
require 'active_support/core_ext/blank'
|
16
|
+
require 'active_support/core_ext/hash'
|
17
|
+
@token = token
|
18
|
+
@ssl = ssl
|
19
|
+
end
|
20
|
+
|
21
|
+
def ssl?
|
22
|
+
@ssl
|
23
|
+
end
|
24
|
+
|
25
|
+
def http
|
26
|
+
unless @http
|
27
|
+
if ssl?
|
28
|
+
require 'net/https'
|
29
|
+
@http = Net::HTTP.new(ADDRESS, Net::HTTP.https_default_port)
|
30
|
+
@http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
31
|
+
@http.use_ssl = true
|
32
|
+
else
|
33
|
+
require 'net/http'
|
34
|
+
@http = Net::HTTP.new(ADDRESS)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
@http
|
38
|
+
end
|
39
|
+
|
40
|
+
def request(method, path, *args)
|
41
|
+
headers = {
|
42
|
+
"X-TrackerToken" => @token,
|
43
|
+
"Accept" => "application/xml",
|
44
|
+
"Content-type" => "application/xml"
|
45
|
+
}
|
46
|
+
klass = Net::HTTP.const_get(method.to_s.capitalize)
|
47
|
+
http.request(klass.new("#{BASE_PATH}#{path}", headers), *args)
|
48
|
+
end
|
49
|
+
|
50
|
+
def request_xml(method, path, *args)
|
51
|
+
response = request(method,path,*args)
|
52
|
+
raise response.inspect if response["Content-type"].split(/; */).first != "application/xml"
|
53
|
+
Hash.from_xml(response.body)["response"]
|
54
|
+
end
|
55
|
+
|
56
|
+
def get_xml(path)
|
57
|
+
response = request_xml(:get, path)
|
58
|
+
unless response["success"] == "true"
|
59
|
+
if response["message"]
|
60
|
+
raise Error, response["message"], caller
|
61
|
+
else
|
62
|
+
raise "#{path}: #{response.inspect}"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
response
|
66
|
+
end
|
67
|
+
|
68
|
+
def project(id)
|
69
|
+
Project.new(self,get_xml("/projects/#{id}")["project"].merge("id" => id.to_i))
|
70
|
+
end
|
71
|
+
|
72
|
+
class Abstract
|
73
|
+
def initialize(attributes = {})
|
74
|
+
@attributes = {}
|
75
|
+
(attributes || {}).each do |k,v|
|
76
|
+
@attributes[k.to_s] = v
|
77
|
+
end
|
78
|
+
yield self if block_given?
|
79
|
+
end
|
80
|
+
|
81
|
+
def self.reader(*methods)
|
82
|
+
methods.each do |method|
|
83
|
+
define_method(method) { @attributes[method.to_s] }
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def self.date_reader(*methods)
|
88
|
+
methods.each do |method|
|
89
|
+
define_method(method) { value = @attributes[method.to_s] and Date.parse(value) }
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def self.accessor(*methods)
|
94
|
+
reader(*methods)
|
95
|
+
methods.each do |method|
|
96
|
+
define_method("#{method}=") { |v| @attributes[method.to_s] = v }
|
97
|
+
end
|
98
|
+
end
|
99
|
+
reader :id
|
100
|
+
|
101
|
+
def to_xml(options = nil)
|
102
|
+
@attributes.to_xml({:dasherize => false, :root => self.class.name.split('::').last.downcase}.merge(options||{}))
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
require 'pickler/tracker/project'
|
111
|
+
require 'pickler/tracker/story'
|
112
|
+
require 'pickler/tracker/iteration'
|
113
|
+
require 'pickler/tracker/note'
|
@@ -0,0 +1,38 @@
|
|
1
|
+
class Pickler
|
2
|
+
class Tracker
|
3
|
+
class Iteration < Abstract
|
4
|
+
attr_reader :project
|
5
|
+
date_reader :start, :finish
|
6
|
+
|
7
|
+
def initialize(project, attributes = {})
|
8
|
+
@project = project
|
9
|
+
super(attributes)
|
10
|
+
end
|
11
|
+
|
12
|
+
def number
|
13
|
+
@attributes['number'].to_i
|
14
|
+
end
|
15
|
+
alias to_i number
|
16
|
+
|
17
|
+
def range
|
18
|
+
start...finish
|
19
|
+
end
|
20
|
+
|
21
|
+
def include?(date)
|
22
|
+
range.include?(date)
|
23
|
+
end
|
24
|
+
|
25
|
+
def succ
|
26
|
+
self.class.new(project, 'number' => number.succ.to_s, 'start' => @attributes['finish'], 'finish' => (finish + (finish - start)).strftime("%b %d, %Y"))
|
27
|
+
end
|
28
|
+
|
29
|
+
def inspect
|
30
|
+
"#<#{self.class.inspect}:#{number.inspect} (#{range.inspect})>"
|
31
|
+
end
|
32
|
+
|
33
|
+
def to_s
|
34
|
+
"#{number} (#{start}...#{finish})"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
class Pickler
|
2
|
+
class Tracker
|
3
|
+
class Note < Abstract
|
4
|
+
attr_reader :story
|
5
|
+
reader :text, :author
|
6
|
+
date_reader :date
|
7
|
+
|
8
|
+
def initialize(story, attributes = {})
|
9
|
+
@story = story
|
10
|
+
super(attributes)
|
11
|
+
end
|
12
|
+
|
13
|
+
def to_xml
|
14
|
+
@attributes.to_xml(:dasherize => false, :root => 'note')
|
15
|
+
end
|
16
|
+
|
17
|
+
def inspect
|
18
|
+
"#<#{self.class.inspect}:#{id.inspect}, story_id: #{story.id.inspect}, date: #{date.inspect}, author: #{author.inspect}, text: #{text.inspect}>"
|
19
|
+
end
|
20
|
+
|
21
|
+
def lines(width = 79)
|
22
|
+
text.scan(/(?:.{0,#{width}}|\S+?)(?:\s|$)/).map! {|line| line.strip}[0..-2]
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
class Pickler
|
2
|
+
class Tracker
|
3
|
+
class Project < Abstract
|
4
|
+
|
5
|
+
attr_reader :tracker
|
6
|
+
reader :point_scale, :week_start_day, :name, :iteration_length
|
7
|
+
|
8
|
+
def initialize(tracker, attributes = {})
|
9
|
+
@tracker = tracker
|
10
|
+
super(attributes)
|
11
|
+
end
|
12
|
+
|
13
|
+
def story(story_id)
|
14
|
+
raise Error, "No story id given" if story_id.to_s.empty?
|
15
|
+
Story.new(self,tracker.get_xml("/projects/#{id}/stories/#{story_id}")["story"])
|
16
|
+
end
|
17
|
+
|
18
|
+
def stories(*args)
|
19
|
+
filter = encode_term(args) if args.any?
|
20
|
+
path = "/projects/#{id}/stories"
|
21
|
+
path << "?filter=#{CGI.escape(filter)}" if filter
|
22
|
+
response = tracker.get_xml(path)
|
23
|
+
[response["stories"]["story"]].flatten.compact.map {|s| Story.new(self,s)}
|
24
|
+
end
|
25
|
+
|
26
|
+
def new_story(attributes = {}, &block)
|
27
|
+
Story.new(self, attributes, &block)
|
28
|
+
end
|
29
|
+
|
30
|
+
def deliver_all_finished_stories
|
31
|
+
request_xml(:put,"/projects/#{id}/stories_deliver_all_finished")
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
def encode_term(term)
|
36
|
+
case term
|
37
|
+
when Array then term.map {|v| encode_term(v)}.join(" ")
|
38
|
+
when Hash then term.map {|k,v| encode_term("#{k}:#{v}")}.join(" ")
|
39
|
+
when /^\S+$/, Symbol then term
|
40
|
+
when /^(\S+?):(.*)$/ then %{#$1:"#$2"}
|
41
|
+
else %{"#{term}"}
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
class Pickler
|
2
|
+
class Tracker
|
3
|
+
class Story < Abstract
|
4
|
+
|
5
|
+
TYPES = %w(bug feature chore release)
|
6
|
+
STATES = %w(unscheduled unstarted started finished delivered rejected accepted)
|
7
|
+
|
8
|
+
attr_reader :project, :iteration, :labels
|
9
|
+
reader :url
|
10
|
+
date_reader :created_at, :accepted_at, :deadline
|
11
|
+
accessor :current_state, :name, :description, :owned_by, :requested_by, :story_type
|
12
|
+
|
13
|
+
def initialize(project, attributes = {})
|
14
|
+
@project = project
|
15
|
+
super(attributes)
|
16
|
+
@iteration = Iteration.new(project, @attributes["iteration"]) if @attributes["iteration"]
|
17
|
+
@labels = normalize_labels(@attributes["labels"])
|
18
|
+
end
|
19
|
+
|
20
|
+
def labels=(value)
|
21
|
+
@labels = normalize_labels(value)
|
22
|
+
end
|
23
|
+
|
24
|
+
def transition!(state)
|
25
|
+
raise Pickler::Tracker::Error, "Invalid state #{state}", caller unless STATES.include?(state)
|
26
|
+
self.current_state = state
|
27
|
+
if id
|
28
|
+
xml = "<story><current_state>#{state}</current_state></story>"
|
29
|
+
error = tracker.request_xml(:put, resource_url, xml).fetch("errors",{})["error"] || true
|
30
|
+
else
|
31
|
+
error = save
|
32
|
+
end
|
33
|
+
raise Pickler::Tracker::Error, Array(error).join("\n"), caller unless error == true
|
34
|
+
end
|
35
|
+
|
36
|
+
def finish
|
37
|
+
case story_type
|
38
|
+
when "bug", "feature"
|
39
|
+
self.current_state = "finished" unless complete?
|
40
|
+
when "chore", "release"
|
41
|
+
self.current_state = "accepted"
|
42
|
+
end
|
43
|
+
current_state
|
44
|
+
end
|
45
|
+
|
46
|
+
def finish!
|
47
|
+
transition!(finish)
|
48
|
+
end
|
49
|
+
|
50
|
+
def backlog?(as_of = Date.today)
|
51
|
+
iteration && iteration.start >= as_of
|
52
|
+
end
|
53
|
+
|
54
|
+
def current?(as_of = Date.today)
|
55
|
+
iteration && iteration.include?(as_of)
|
56
|
+
end
|
57
|
+
|
58
|
+
# In a previous iteration
|
59
|
+
def done?(as_of = Date.today)
|
60
|
+
iteration && iteration.finish <= as_of
|
61
|
+
end
|
62
|
+
|
63
|
+
def complete?
|
64
|
+
%w(finished delivered accepted).include?(current_state)
|
65
|
+
end
|
66
|
+
|
67
|
+
def startable?
|
68
|
+
%w(unscheduled unstarted rejected).include?(current_state)
|
69
|
+
end
|
70
|
+
|
71
|
+
def tracker
|
72
|
+
project.tracker
|
73
|
+
end
|
74
|
+
|
75
|
+
def to_s
|
76
|
+
to_s = "# #{url}\n#{story_type.capitalize}: #{name}\n"
|
77
|
+
description_lines.each do |line|
|
78
|
+
to_s << " #{line}".rstrip << "\n"
|
79
|
+
end
|
80
|
+
to_s
|
81
|
+
end
|
82
|
+
|
83
|
+
def to_s=(body)
|
84
|
+
body = body.sub(/\A# .*\n/,'')
|
85
|
+
if body =~ /\A(\w+): (.*)/
|
86
|
+
self.story_type = $1.downcase
|
87
|
+
self.name = $2
|
88
|
+
description = $'
|
89
|
+
else
|
90
|
+
self.story_type = "feature"
|
91
|
+
self.name = body[/.*/]
|
92
|
+
description = $'
|
93
|
+
end
|
94
|
+
self.description = description.gsub(/\A\n+|\n+\Z/,'') + "\n"
|
95
|
+
if description_lines.all? {|l| l.empty? || l =~ /^ /}
|
96
|
+
self.description.gsub!(/^ /,'')
|
97
|
+
end
|
98
|
+
self
|
99
|
+
end
|
100
|
+
|
101
|
+
def description_lines
|
102
|
+
array = []
|
103
|
+
description.to_s.each_line do |line|
|
104
|
+
array << line.chomp
|
105
|
+
end
|
106
|
+
array
|
107
|
+
end
|
108
|
+
|
109
|
+
def notes
|
110
|
+
[@attributes["notes"]].flatten.compact.map {|n| Note.new(self,n)}
|
111
|
+
end
|
112
|
+
|
113
|
+
def estimate
|
114
|
+
@attributes["estimate"].to_i < 0 ? nil : @attributes["estimate"]
|
115
|
+
end
|
116
|
+
|
117
|
+
def comment!(body)
|
118
|
+
response = tracker.request_xml(:post, "#{resource_url}/notes",{:text => body}.to_xml(:dasherize => false, :root => 'note'))
|
119
|
+
if response["note"]
|
120
|
+
Note.new(self, response["note"])
|
121
|
+
else
|
122
|
+
raise Pickler::Tracker::Error, Array(response["errors"]["error"]).join("\n"), caller
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def to_xml(force_labels = true)
|
127
|
+
hash = @attributes.reject do |k,v|
|
128
|
+
!%w(current_state deadline description estimate name owned_by requested_by story_type).include?(k)
|
129
|
+
end
|
130
|
+
if force_labels || !id || normalize_labels(@attributes["labels"]) != labels
|
131
|
+
hash["labels"] = labels.join(", ")
|
132
|
+
end
|
133
|
+
hash.to_xml(:dasherize => false, :root => "story")
|
134
|
+
end
|
135
|
+
|
136
|
+
def destroy
|
137
|
+
if id
|
138
|
+
response = tracker.request_xml(:delete, "/projects/#{project.id}/stories/#{id}", "")
|
139
|
+
raise Error, response["message"], caller if response["success"] != "true"
|
140
|
+
@attributes["id"] = nil
|
141
|
+
self
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def resource_url
|
146
|
+
["/projects/#{project.id}/stories",id].compact.join("/")
|
147
|
+
end
|
148
|
+
|
149
|
+
def save
|
150
|
+
response = tracker.request_xml(id ? :put : :post, resource_url, to_xml(false))
|
151
|
+
if response["success"] == "true"
|
152
|
+
initialize(project, response["story"])
|
153
|
+
true
|
154
|
+
else
|
155
|
+
Array(response["errors"]["error"])
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
private
|
160
|
+
def normalize_labels(value)
|
161
|
+
Array(value).join(", ").strip.split(/\s*,\s*/)
|
162
|
+
end
|
163
|
+
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
data/pickler.gemspec
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = "pickler"
|
3
|
+
s.version = "0.0.6.1"
|
4
|
+
|
5
|
+
s.summary = "PIvotal traCKer Liaison to cucumbER"
|
6
|
+
s.description = "Synchronize between Cucumber and Pivotal Tracker"
|
7
|
+
s.authors = ["Tim Pope", "Kamal Fariz Mahyuddin", "Liam Morley", "Kosmas Sch�tz"]
|
8
|
+
s.email = "ruby@tpope.i"+'nfo'
|
9
|
+
s.homepage = "http://github.com/tpope/pickler"
|
10
|
+
s.default_executable = "pickler"
|
11
|
+
s.executables = ["pickler"]
|
12
|
+
s.files = [
|
13
|
+
"README.rdoc",
|
14
|
+
"MIT-LICENSE",
|
15
|
+
"pickler.gemspec",
|
16
|
+
"bin/pickler",
|
17
|
+
"lib/pickler.rb",
|
18
|
+
"lib/pickler/feature.rb",
|
19
|
+
"lib/pickler/runner.rb",
|
20
|
+
"lib/pickler/tracker.rb",
|
21
|
+
"lib/pickler/tracker/project.rb",
|
22
|
+
"lib/pickler/tracker/story.rb",
|
23
|
+
"lib/pickler/tracker/iteration.rb",
|
24
|
+
"lib/pickler/tracker/note.rb"
|
25
|
+
]
|
26
|
+
s.add_dependency("activesupport", [">= 2.0.0"])
|
27
|
+
s.add_dependency("cucumber", [">= 0.1.9"])
|
28
|
+
s.add_dependency("builder")
|
29
|
+
s.add_dependency("xml-simple")
|
30
|
+
end
|
metadata
ADDED
@@ -0,0 +1,102 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: kosmas58-pickler
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.6.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Tim Pope
|
8
|
+
- Kamal Fariz Mahyuddin
|
9
|
+
- Liam Morley
|
10
|
+
- "Kosmas Sch\xFCtz"
|
11
|
+
autorequire:
|
12
|
+
bindir: bin
|
13
|
+
cert_chain: []
|
14
|
+
|
15
|
+
date: 2009-01-11 00:00:00 -08:00
|
16
|
+
default_executable: pickler
|
17
|
+
dependencies:
|
18
|
+
- !ruby/object:Gem::Dependency
|
19
|
+
name: activesupport
|
20
|
+
version_requirement:
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - ">="
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: 2.0.0
|
26
|
+
version:
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: cucumber
|
29
|
+
version_requirement:
|
30
|
+
version_requirements: !ruby/object:Gem::Requirement
|
31
|
+
requirements:
|
32
|
+
- - ">="
|
33
|
+
- !ruby/object:Gem::Version
|
34
|
+
version: 0.1.9
|
35
|
+
version:
|
36
|
+
- !ruby/object:Gem::Dependency
|
37
|
+
name: builder
|
38
|
+
version_requirement:
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
40
|
+
requirements:
|
41
|
+
- - ">="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: "0"
|
44
|
+
version:
|
45
|
+
- !ruby/object:Gem::Dependency
|
46
|
+
name: xml-simple
|
47
|
+
version_requirement:
|
48
|
+
version_requirements: !ruby/object:Gem::Requirement
|
49
|
+
requirements:
|
50
|
+
- - ">="
|
51
|
+
- !ruby/object:Gem::Version
|
52
|
+
version: "0"
|
53
|
+
version:
|
54
|
+
description: Synchronize between Cucumber and Pivotal Tracker
|
55
|
+
email: ruby@tpope.info
|
56
|
+
executables:
|
57
|
+
- pickler
|
58
|
+
extensions: []
|
59
|
+
|
60
|
+
extra_rdoc_files: []
|
61
|
+
|
62
|
+
files:
|
63
|
+
- README.rdoc
|
64
|
+
- MIT-LICENSE
|
65
|
+
- pickler.gemspec
|
66
|
+
- bin/pickler
|
67
|
+
- lib/pickler.rb
|
68
|
+
- lib/pickler/feature.rb
|
69
|
+
- lib/pickler/runner.rb
|
70
|
+
- lib/pickler/tracker.rb
|
71
|
+
- lib/pickler/tracker/project.rb
|
72
|
+
- lib/pickler/tracker/story.rb
|
73
|
+
- lib/pickler/tracker/iteration.rb
|
74
|
+
- lib/pickler/tracker/note.rb
|
75
|
+
has_rdoc: false
|
76
|
+
homepage: http://github.com/tpope/pickler
|
77
|
+
post_install_message:
|
78
|
+
rdoc_options: []
|
79
|
+
|
80
|
+
require_paths:
|
81
|
+
- lib
|
82
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
83
|
+
requirements:
|
84
|
+
- - ">="
|
85
|
+
- !ruby/object:Gem::Version
|
86
|
+
version: "0"
|
87
|
+
version:
|
88
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
89
|
+
requirements:
|
90
|
+
- - ">="
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
version: "0"
|
93
|
+
version:
|
94
|
+
requirements: []
|
95
|
+
|
96
|
+
rubyforge_project:
|
97
|
+
rubygems_version: 1.2.0
|
98
|
+
signing_key:
|
99
|
+
specification_version: 2
|
100
|
+
summary: PIvotal traCKer Liaison to cucumbER
|
101
|
+
test_files: []
|
102
|
+
|