drnic-pickler 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,116 @@
1
+ require 'date'
2
+ require 'cgi'
3
+
4
+ class Pickler
5
+ class Tracker
6
+
7
+ ADDRESS = 'www.pivotaltracker.com'
8
+ BASE_PATH = '/services/v2'
9
+ SEARCH_KEYS = %w(label type state requester owner mywork id includedone)
10
+
11
+ class Error < Pickler::Error; end
12
+
13
+ attr_reader :token
14
+
15
+ def initialize(token, ssl = false)
16
+ require 'active_support'
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
+ http # trigger require of 'net/http'
47
+ klass = Net::HTTP.const_get(method.to_s.capitalize)
48
+ http.request(klass.new("#{BASE_PATH}#{path}", headers), *args)
49
+ end
50
+
51
+ def request_xml(method, path, *args)
52
+ response = request(method,path,*args)
53
+ raise response.inspect if response["Content-type"].split(/; */).first != "application/xml"
54
+ hash = Hash.from_xml(response.body)
55
+ if hash["message"] && (response.code.to_i >= 400 || hash["success"] == "false")
56
+ raise Error, hash["message"], caller
57
+ end
58
+ hash
59
+ end
60
+
61
+ def get_xml(path)
62
+ request_xml(:get, path)
63
+ end
64
+
65
+ def project(id)
66
+ Project.new(self,get_xml("/projects/#{id}")["project"])
67
+ end
68
+
69
+ class Abstract
70
+ def initialize(attributes = {})
71
+ @attributes = {}
72
+ (attributes || {}).each do |k,v|
73
+ @attributes[k.to_s] = v
74
+ end
75
+ yield self if block_given?
76
+ end
77
+
78
+ def self.reader(*methods)
79
+ methods.each do |method|
80
+ define_method(method) { @attributes[method.to_s] }
81
+ end
82
+ end
83
+
84
+ def self.date_reader(*methods)
85
+ methods.each do |method|
86
+ define_method(method) do
87
+ value = @attributes[method.to_s]
88
+ value.kind_of?(String) ? Date.parse(value) : value
89
+ end
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
+
100
+ def id
101
+ id = @attributes['id'] and Integer(id)
102
+ end
103
+
104
+ def to_xml(options = nil)
105
+ @attributes.to_xml({:dasherize => false, :root => self.class.name.split('::').last.downcase}.merge(options||{}))
106
+ end
107
+
108
+ end
109
+
110
+ end
111
+ end
112
+
113
+ require 'pickler/tracker/project'
114
+ require 'pickler/tracker/story'
115
+ require 'pickler/tracker/iteration'
116
+ require 'pickler/tracker/note'
@@ -0,0 +1,45 @@
1
+ class Pickler
2
+ class Tracker
3
+ class Iteration < Abstract
4
+ attr_reader :project
5
+
6
+ def initialize(project, attributes = {})
7
+ @project = project
8
+ super(attributes)
9
+ end
10
+
11
+ def start
12
+ Date.parse(@attributes['start'].to_s)
13
+ end
14
+
15
+ def finish
16
+ Date.parse(@attributes['finish'].to_s)
17
+ end
18
+
19
+ def number
20
+ @attributes['number'].to_i
21
+ end
22
+ alias to_i number
23
+
24
+ def range
25
+ start...finish
26
+ end
27
+
28
+ def include?(date)
29
+ range.include?(date)
30
+ end
31
+
32
+ def succ
33
+ self.class.new(project, 'number' => number.succ.to_s, 'start' => @attributes['finish'], 'finish' => (finish + (finish - start)))
34
+ end
35
+
36
+ def inspect
37
+ "#<#{self.class.inspect}:#{number.inspect} (#{range.inspect})>"
38
+ end
39
+
40
+ def to_s
41
+ "#{number} (#{start}...#{finish})"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,31 @@
1
+ class Pickler
2
+ class Tracker
3
+ class Note < Abstract
4
+ attr_reader :story
5
+ reader :text, :author
6
+ date_reader :noted_at
7
+
8
+ def date
9
+ noted_at && Date.new(noted_at.year, noted_at.mon, noted_at.day)
10
+ end
11
+
12
+ def initialize(story, attributes = {})
13
+ @story = story
14
+ super(attributes)
15
+ end
16
+
17
+ def to_xml
18
+ @attributes.to_xml(:dasherize => false, :root => 'note')
19
+ end
20
+
21
+ def inspect
22
+ "#<#{self.class.inspect}:#{id.inspect}, story_id: #{story.id.inspect}, date: #{date.inspect}, author: #{author.inspect}, text: #{text.inspect}>"
23
+ end
24
+
25
+ def lines(width = 79)
26
+ text.scan(/(?:.{0,#{width}}|\S+?)(?:\s|$)/).map! {|line| line.strip}[0..-2]
27
+ end
28
+
29
+ end
30
+ end
31
+ 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"]].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,193 @@
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, :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 iteration
21
+ unless current_state == 'unscheduled' || defined?(@iteration)
22
+ @iteration = project.stories(:id => id, :includedone => true).first.iteration
23
+ end
24
+ @iteration
25
+ end
26
+
27
+ def labels=(value)
28
+ @labels = normalize_labels(value)
29
+ end
30
+
31
+ def transition!(state)
32
+ raise Pickler::Tracker::Error, "Invalid state #{state}", caller unless STATES.include?(state)
33
+ self.current_state = state
34
+ if id
35
+ xml = "<story><current_state>#{state}</current_state></story>"
36
+ error = tracker.request_xml(:put, resource_url, xml).fetch("errors",{})["error"] || true
37
+ else
38
+ error = save
39
+ end
40
+ raise Pickler::Tracker::Error, Array(error).join("\n"), caller unless error == true
41
+ end
42
+
43
+ def finish
44
+ case story_type
45
+ when "bug", "feature"
46
+ self.current_state = "finished" unless complete?
47
+ when "chore", "release"
48
+ self.current_state = "accepted"
49
+ end
50
+ current_state
51
+ end
52
+
53
+ def finish!
54
+ transition!(finish)
55
+ end
56
+
57
+ def backlog?(as_of = Date.today)
58
+ iteration && iteration.start >= as_of
59
+ end
60
+
61
+ def current?(as_of = Date.today)
62
+ iteration && iteration.include?(as_of)
63
+ end
64
+
65
+ # In a previous iteration
66
+ def done?(as_of = Date.today)
67
+ iteration && iteration.finish <= as_of
68
+ end
69
+
70
+ def complete?
71
+ %w(finished delivered accepted).include?(current_state)
72
+ end
73
+
74
+ def startable?
75
+ %w(unscheduled unstarted rejected).include?(current_state)
76
+ end
77
+
78
+ def tracker
79
+ project.tracker
80
+ end
81
+
82
+ def to_s(format = :comment)
83
+ to_s = "#{header(format)}\n#{story_type.capitalize}: #{name}\n"
84
+ description_lines.each do |line|
85
+ to_s << " #{line}".rstrip << "\n"
86
+ end
87
+ to_s
88
+ end
89
+
90
+ def header(format = :comment)
91
+ case format
92
+ when :tag
93
+ "@#{url}#{labels.map {|l| " @#{l.tr('_,',' _')}"}.join}"
94
+ else
95
+ "# #{url}"
96
+ end
97
+ end
98
+
99
+ def to_s=(body)
100
+ if body =~ /\A@https?\b\S*(\s+@\S+)*\s*$/
101
+ self.labels = body[/\A@.*/].split(/\s+/)[1..-1].map {|l| l[1..-1].tr(' _','_,')}
102
+ end
103
+ body = body.sub(/\A(?:[@#].*\n)+/,'')
104
+ if body =~ /\A(\w+): (.*)/
105
+ self.story_type = $1.downcase
106
+ self.name = $2
107
+ description = $'
108
+ else
109
+ self.story_type = "feature"
110
+ self.name = body[/.*/]
111
+ description = $'
112
+ end
113
+ self.description = description.gsub(/\A\n+|\n+\Z/,'') + "\n"
114
+ if description_lines.all? {|l| l.empty? || l =~ /^ /}
115
+ self.description.gsub!(/^ /,'')
116
+ end
117
+ self
118
+ end
119
+
120
+ def description_lines
121
+ array = []
122
+ description.to_s.each_line do |line|
123
+ array << line.chomp
124
+ end
125
+ array
126
+ end
127
+
128
+ def notes
129
+ [@attributes["notes"]].flatten.compact.map {|n| Note.new(self,n)}
130
+ end
131
+
132
+ def estimate
133
+ @attributes["estimate"].to_i < 0 ? nil : @attributes["estimate"]
134
+ end
135
+
136
+ def comment!(body)
137
+ response = tracker.request_xml(:post, "#{resource_url}/notes",{:text => body}.to_xml(:dasherize => false, :root => 'note'))
138
+ if response["note"]
139
+ Note.new(self, response["note"])
140
+ else
141
+ raise Pickler::Tracker::Error, Array(response["errors"]["error"]).join("\n"), caller
142
+ end
143
+ end
144
+
145
+ def to_xml(force_labels = true)
146
+ hash = @attributes.reject do |k,v|
147
+ !%w(current_state deadline description estimate name owned_by requested_by story_type).include?(k)
148
+ end
149
+ if force_labels || !id || normalize_labels(@attributes["labels"]) != labels
150
+ hash["labels"] = labels.join(", ")
151
+ end
152
+ hash.to_xml(:dasherize => false, :root => "story")
153
+ end
154
+
155
+ def destroy
156
+ if id
157
+ response = tracker.request_xml(:delete, "/projects/#{project.id}/stories/#{id}", "")
158
+ raise Error, response["message"], caller if response["message"]
159
+ @attributes["id"] = nil
160
+ self
161
+ end
162
+ end
163
+
164
+ def resource_url
165
+ ["/projects/#{project.id}/stories",id].compact.join("/")
166
+ end
167
+
168
+ def save
169
+ response = tracker.request_xml(id ? :put : :post, resource_url, to_xml(false))
170
+ if response["story"]
171
+ initialize(project, response["story"])
172
+ true
173
+ else
174
+ Array(response["errors"]["error"])
175
+ end
176
+ end
177
+
178
+ def save!
179
+ errors = save
180
+ if errors != true
181
+ raise Pickler::Tracker::Error, Array(errors).join("\n"), caller
182
+ end
183
+ self
184
+ end
185
+
186
+ private
187
+ def normalize_labels(value)
188
+ Array(value).join(", ").strip.split(/\s*,\s*/)
189
+ end
190
+
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,28 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = "drnic-pickler"
3
+ s.version = "0.1.3"
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/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.3.96"])
28
+ end