tpope-pickler 0.0.0

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,43 @@
1
+ = Pickler
2
+
3
+ Synchronize user stories in Pivotal Tracker with Cucumber features.
4
+
5
+ == Getting started
6
+
7
+ echo "api_token: ..." > ~/.tracker.yml
8
+ echo "project_id: ..." > ~/my/app/features/tracker.yml
9
+
10
+ For details about the Pivotal Tracker API, including where to find your API
11
+ token and project id, see http://www.pivotaltracker.com/help/api .
12
+
13
+ The pull and push commands map the story's name into the "Feature: ..." line
14
+ and the story's description with an additional two space indent into the
15
+ feature's body. Keep this in mind when entering stories into Pivotal Tracker.
16
+
17
+ == Usage
18
+
19
+ pickler pull
20
+
21
+ Download all well formed stories to the features/ directory. Previously
22
+ unseen stories will be given a numeric filename that you are encouraged to
23
+ change.
24
+
25
+ pickler push
26
+
27
+ Upload all features with a tracker url in a comment on the first line.
28
+
29
+ pickler search <query>
30
+
31
+ List all stories matching the given query.
32
+
33
+ pickler show <id>
34
+
35
+ Show details for the story referenced by the id.
36
+
37
+ == Disclaimer
38
+
39
+ No warranties, expressed or implied.
40
+
41
+ Notably, the push and pull commands are quite happy to blindly clobber
42
+ features if so instructed. Pivotal Tracker has a history to recover things
43
+ server side.
data/bin/pickler ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $:.unshift(File.join(File.dirname(File.dirname(__FILE__)),'lib'))
4
+ begin; require 'rubygems'; rescue LoadError; end
5
+ require 'pickler'
6
+
7
+ Pickler.run(ARGV)
@@ -0,0 +1,46 @@
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
+ Story.new(self,tracker.get_xml("/projects/#{id}/stories/#{story_id}")["story"])
15
+ end
16
+
17
+ def stories(*args)
18
+ filter = encode_term(args) if args.any?
19
+ path = "/projects/#{id}/stories"
20
+ path << "?filter=#{CGI.escape(filter)}" if filter
21
+ response = tracker.get_xml(path)
22
+ [response["stories"]["story"]].flatten.compact.map {|s| Story.new(self,s)}
23
+ end
24
+
25
+ def new_story(attributes = {}, &block)
26
+ Story.new(self, attributes, &block)
27
+ end
28
+
29
+ def deliver_all_finished_stories
30
+ request_xml(:put,"/projects/#{id}/stories_deliver_all_finished")
31
+ end
32
+
33
+ private
34
+ def encode_term(term)
35
+ case term
36
+ when Array then term.map {|v| encode_term(v)}.join(" ")
37
+ when Hash then term.map {|k,v| encode_term("#{k}:#{v}")}.join(" ")
38
+ when /^\S+$/, Symbol then term
39
+ when /^(\S+?):(.*)$/ then %{#$1:"#$2"}
40
+ else %{"#{term}"}
41
+ end
42
+ end
43
+
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,90 @@
1
+ class Pickler
2
+ class Tracker
3
+ class Story < Abstract
4
+
5
+ TYPES = %w(bug feature chore release)
6
+ STATES = %w(unstarted started finished delivered rejected accepted)
7
+
8
+ attr_reader :project
9
+ reader :created_at, :iteration, :url, :labels
10
+ accessor :current_state, :name, :description, :estimate, :owned_by, :requested_by, :story_type
11
+
12
+ def initialize(project, attributes = {})
13
+ @project = project
14
+ super(attributes)
15
+ end
16
+
17
+ def tracker
18
+ project.tracker
19
+ end
20
+
21
+ def to_s
22
+ to_s = "# #{url}\n#{story_type.capitalize}: #{name}\n"
23
+ description_lines.each do |line|
24
+ to_s << " #{line}".rstrip << "\n"
25
+ end
26
+ to_s
27
+ end
28
+
29
+ def to_s=(body)
30
+ body = body.sub(/\A# .*\n/,'')
31
+ if body =~ /\A(\w+): (.*)/
32
+ self.story_type = $1.downcase
33
+ self.name = $2
34
+ description = $'
35
+ else
36
+ self.story_type = "feature"
37
+ self.name = body[/.*/]
38
+ description = $'
39
+ end
40
+ self.description = description.gsub(/\A\n+|\n+\Z/,'') + "\n"
41
+ if description_lines.all? {|l| l.empty? || l =~ /^ /}
42
+ self.description.gsub!(/^ /,'')
43
+ end
44
+ self
45
+ end
46
+
47
+ def description_lines
48
+ array = []
49
+ description.to_s.each_line do |line|
50
+ array << line.chomp
51
+ end
52
+ array
53
+ end
54
+
55
+ def notes
56
+ [@attributes["notes"]].flatten.compact
57
+ end
58
+
59
+ def to_xml
60
+ hash = @attributes.except("id","url","iteration","notes","labels")
61
+ hash["labels"] = Array(@attributes["labels"]).join(", ")
62
+ hash.to_xml(:root => "story")
63
+ end
64
+
65
+ def destroy
66
+ if id
67
+ request = tracker.request_xml(:delete, "/projects/#{project.id}/stories/#{id}", to_xml)
68
+ raise Error, request["message"], caller if request["success"] != "true"
69
+ @attributes["id"] = nil
70
+ self
71
+ end
72
+ end
73
+
74
+ def save
75
+ if id
76
+ request = tracker.request_xml(:put, "/projects/#{project.id}/stories/#{id}", to_xml)
77
+ else
78
+ request = tracker.request_xml(:post, "/projects/#{project.id}/stories", to_xml)
79
+ end
80
+ if request["success"] == "true"
81
+ initialize(project, request["story"])
82
+ true
83
+ else
84
+ Array(request["errors"]["error"])
85
+ end
86
+ end
87
+
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,77 @@
1
+ class Pickler
2
+ class Tracker
3
+
4
+ ADDRESS = 'www.pivotaltracker.com'
5
+ BASE_PATH = '/services/v1'
6
+ SEARCH_KEYS = %w(label type state requester owner mywork id includedone)
7
+
8
+ class Error < Pickler::Error; end
9
+
10
+ attr_reader :token
11
+
12
+ def initialize(token)
13
+ require 'active_support'
14
+ @token = token
15
+ end
16
+
17
+ def request(method, path, *args)
18
+ require 'net/http'
19
+ Net::HTTP.start(ADDRESS) do |http|
20
+ headers = {
21
+ "Token" => @token,
22
+ "Accept" => "application/xml",
23
+ "Content-type" => "application/xml"
24
+ }
25
+ klass = Net::HTTP.const_get(method.to_s.capitalize)
26
+ http.request(klass.new("#{BASE_PATH}#{path}", headers), *args)
27
+ end
28
+ end
29
+
30
+ def request_xml(method, path, *args)
31
+ response = request(method,path,*args)
32
+ raise response.inspect if response["Content-type"].split(/; */).first != "application/xml"
33
+ Hash.from_xml(response.body)["response"]
34
+ end
35
+
36
+ def get_xml(path)
37
+ response = request_xml(:get, path)
38
+ unless response["success"] == "true"
39
+ if response["message"]
40
+ raise Error, response["message"], caller
41
+ else
42
+ raise "#{path}: #{response.inspect}"
43
+ end
44
+ end
45
+ response
46
+ end
47
+
48
+ def project(id)
49
+ Project.new(self,get_xml("/projects/#{id}")["project"].merge("id" => id.to_i))
50
+ end
51
+
52
+ class Abstract
53
+ def initialize(attributes)
54
+ @attributes = (attributes || {}).stringify_keys
55
+ yield self if block_given?
56
+ end
57
+
58
+ def self.reader(*methods)
59
+ methods.each do |method|
60
+ define_method(method) { @attributes[method.to_s] }
61
+ end
62
+ end
63
+
64
+ def self.accessor(*methods)
65
+ reader(*methods)
66
+ methods.each do |method|
67
+ define_method("#{method}=") { |v| @attributes[method.to_s] = v }
68
+ end
69
+ end
70
+ reader :id
71
+ end
72
+
73
+ end
74
+ end
75
+
76
+ require 'pickler/tracker/project'
77
+ require 'pickler/tracker/story'
data/lib/pickler.rb ADDED
@@ -0,0 +1,194 @@
1
+ require 'yaml'
2
+
3
+ class Pickler
4
+
5
+ class Error < RuntimeError
6
+ end
7
+
8
+ def self.config
9
+ @config ||= {'api_token' => ENV["TRACKER_API_TOKEN"]}.merge(
10
+ if File.exist?(path = File.expand_path('~/.tracker.yml'))
11
+ YAML.load_file(path)
12
+ end || {}
13
+ )
14
+ end
15
+
16
+ def self.run(argv)
17
+ pickler = new(Dir.getwd)
18
+
19
+ case first = argv.shift
20
+ when 'show', /^\d+$/
21
+ story = pickler.project.story(first == 'show' ? argv.shift : first)
22
+ puts story
23
+ when 'search'
24
+ stories = pickler.project.stories(*argv).group_by {|s| s.current_state}
25
+ first = true
26
+ states = Tracker::Story::STATES
27
+ states -= %w(unstarted accepted) if argv.empty?
28
+ states.each do |state|
29
+ next unless stories[state]
30
+ puts unless first
31
+ first = false
32
+ puts state.upcase
33
+ puts '-' * state.length
34
+ stories[state].each do |story|
35
+ puts "[#{story.id}] #{story.story_type.capitalize}: #{story.name}"
36
+ end
37
+ end
38
+ when 'push'
39
+ pickler.push(*argv)
40
+ when 'pull'
41
+ pickler.pull(*argv)
42
+ when 'help', '--help', '-h', '', nil
43
+ puts 'pickler commands: show <id>, search <query>, push, pull'
44
+ else
45
+ $stderr.puts "pickler: unknown command #{first}"
46
+ exit 1
47
+ end
48
+ rescue Pickler::Error
49
+ $stderr.puts "#$!"
50
+ exit 1
51
+ rescue Interrupt
52
+ $stderr.puts "Interrupted!"
53
+ exit 130
54
+ end
55
+
56
+ attr_reader :directory
57
+
58
+ def initialize(path = '.')
59
+ @lang = 'en'
60
+ @directory = File.expand_path(path)
61
+ until File.directory?(File.join(@directory,'features'))
62
+ if @directory == File.dirname(@directory)
63
+ raise Error, 'Project not found. Make sure you have a features/ directory.', caller
64
+ end
65
+ @directory = File.dirname(@directory)
66
+ end
67
+ end
68
+
69
+ def features_path(*subdirs)
70
+ File.join(@directory,'features',*subdirs)
71
+ end
72
+
73
+ def config_file
74
+ features_path('tracker.yml')
75
+ end
76
+
77
+ def config
78
+ @config ||= File.exist?(config_file) && YAML.load_file(config_file) || {}
79
+ self.class.config.merge(@config)
80
+ end
81
+
82
+ def parser
83
+ require 'cucumber'
84
+ require "cucumber/treetop_parser/feature_#@lang"
85
+ Cucumber.load_language(@lang)
86
+ @parser ||= Cucumber::TreetopParser::FeatureParser.new
87
+ end
88
+
89
+ def project_id
90
+ config["project_id"] || (self.class.config["projects"]||{})[File.basename(@directory)]
91
+ end
92
+
93
+ def project
94
+ @project ||= Dir.chdir(@directory) do
95
+ unless token = config['api_token']
96
+ raise Error, 'echo api_token: ... > ~/.tracker.yml'
97
+ end
98
+ unless id = project_id
99
+ raise Error, 'echo project_id: ... > features/tracker.yml'
100
+ end
101
+ Tracker.new(token).project(id)
102
+ end
103
+ end
104
+
105
+ def scenario_word
106
+ parser
107
+ Cucumber.language['scenario']
108
+ end
109
+
110
+ def remote_features
111
+ project.stories(scenario_word, :includedone => true).select do |s|
112
+ s.to_s =~ /^\s*#{Regexp.escape(scenario_word)}:/ && parser.parse(s.to_s)
113
+ end
114
+ end
115
+
116
+ def local_features
117
+ Dir[features_path('**','*.feature')].map {|f| LocalFeature.new(self,f)}
118
+ end
119
+
120
+ def pull
121
+ l = local_features
122
+ remote_features.each do |remote|
123
+ body = "# http://www.pivotaltracker.com/story/show/#{remote.id}\n" <<
124
+ normalize_feature(remote.to_s)
125
+ if local = l.detect {|f| f.id == remote.id}
126
+ filename = local.filename
127
+ else
128
+ next if remote.current_state == 'unstarted'
129
+ filename = features_path("#{remote.id}.feature")
130
+ end
131
+ File.open(filename,'w') {|f| f.puts body}
132
+ end
133
+ nil
134
+ end
135
+
136
+ def push
137
+ local_features.select do |local|
138
+ next unless local.id
139
+ remote = local.story
140
+ next if remote.to_s == local.to_s
141
+ remote.to_s = local.to_s
142
+ remote.save
143
+ end
144
+ end
145
+
146
+ protected
147
+
148
+ def normalize_feature(body)
149
+ return body unless ast = parser.parse(body)
150
+ feature = ast.compile
151
+ new = ''
152
+ (feature.header.chomp << "\n").each_line do |l|
153
+ new << ' ' unless new.empty?
154
+ new << l.strip << "\n"
155
+ end
156
+ feature.scenarios.each do |scenario|
157
+ new << "\n Scenario: #{scenario.name}\n"
158
+ scenario.steps.each do |step|
159
+ new << " #{step.keyword} #{step.name}\n"
160
+ end
161
+ end
162
+ new
163
+ end
164
+
165
+ class LocalFeature
166
+ attr_reader :pickler, :filename
167
+
168
+ def initialize(pickler, filename)
169
+ @pickler = pickler
170
+ @filename = filename
171
+ end
172
+
173
+ def to_s
174
+ File.read(@filename)
175
+ end
176
+
177
+ def id
178
+ unless defined?(@id)
179
+ @id = if id = to_s[%r{#\s*http://www\.pivotaltracker\.com/\S*?/(\d+)},1]
180
+ id.to_i
181
+ end
182
+ end
183
+ @id
184
+ end
185
+
186
+ def story
187
+ @pickler.project.story(id) if id
188
+ end
189
+
190
+ end
191
+
192
+ end
193
+
194
+ require 'pickler/tracker'
data/pickler.gemspec ADDED
@@ -0,0 +1,24 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = "pickler"
3
+ s.version = "0.0.0"
4
+
5
+ s.summary = "PIvotal traCKer Liaison to cucumbER"
6
+ s.description = "Synchronize between Cucumber and Pivotal Tracker"
7
+ s.authors = ["Tim Pope"]
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/tracker.rb",
19
+ "lib/pickler/tracker/project.rb",
20
+ "lib/pickler/tracker/story.rb"
21
+ ]
22
+ s.add_dependency("activesupport", [">= 2.0.0"])
23
+ s.add_dependency("cucumber", [">= 0.1.9"])
24
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tpope-pickler
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Tim Pope
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2008-10-27 00:00:00 -07:00
13
+ default_executable: pickler
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: activesupport
17
+ version_requirement:
18
+ version_requirements: !ruby/object:Gem::Requirement
19
+ requirements:
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 2.0.0
23
+ version:
24
+ - !ruby/object:Gem::Dependency
25
+ name: cucumber
26
+ version_requirement:
27
+ version_requirements: !ruby/object:Gem::Requirement
28
+ requirements:
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 0.1.9
32
+ version:
33
+ description: Synchronize between Cucumber and Pivotal Tracker
34
+ email: ruby@tpope.info
35
+ executables:
36
+ - pickler
37
+ extensions: []
38
+
39
+ extra_rdoc_files: []
40
+
41
+ files:
42
+ - README.rdoc
43
+ - MIT-LICENSE
44
+ - pickler.gemspec
45
+ - bin/pickler
46
+ - lib/pickler.rb
47
+ - lib/pickler/tracker.rb
48
+ - lib/pickler/tracker/project.rb
49
+ - lib/pickler/tracker/story.rb
50
+ has_rdoc: false
51
+ homepage: http://github.com/tpope/pickler
52
+ post_install_message:
53
+ rdoc_options: []
54
+
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: "0"
62
+ version:
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: "0"
68
+ version:
69
+ requirements: []
70
+
71
+ rubyforge_project:
72
+ rubygems_version: 1.2.0
73
+ signing_key:
74
+ specification_version: 2
75
+ summary: PIvotal traCKer Liaison to cucumbER
76
+ test_files: []
77
+