tpope-pickler 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
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
+