ssp-pickler 0.1.4

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.
@@ -0,0 +1,120 @@
1
+ require 'date'
2
+ require 'cgi'
3
+
4
+ class Pickler
5
+ class Tracker
6
+
7
+ ADDRESS = 'www.pivotaltracker.com'
8
+ BASE_PATH = '/services/v3'
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
+ if respond_to?("#{k}=")
74
+ send("#{k}=", v)
75
+ else
76
+ @attributes[k.to_s] = v
77
+ end
78
+ end
79
+ yield self if block_given?
80
+ end
81
+
82
+ def self.reader(*methods)
83
+ methods.each do |method|
84
+ define_method(method) { @attributes[method.to_s] }
85
+ end
86
+ end
87
+
88
+ def self.date_reader(*methods)
89
+ methods.each do |method|
90
+ define_method(method) do
91
+ value = @attributes[method.to_s]
92
+ value.kind_of?(String) ? Date.parse(value) : value
93
+ end
94
+ end
95
+ end
96
+
97
+ def self.accessor(*methods)
98
+ reader(*methods)
99
+ methods.each do |method|
100
+ define_method("#{method}=") { |v| @attributes[method.to_s] = v }
101
+ end
102
+ end
103
+
104
+ def id
105
+ id = @attributes['id'] and Integer(id)
106
+ end
107
+
108
+ def to_xml(options = nil)
109
+ @attributes.to_xml({:dasherize => false, :root => self.class.name.split('::').last.downcase}.merge(options||{}))
110
+ end
111
+
112
+ end
113
+
114
+ end
115
+ end
116
+
117
+ require 'pickler/tracker/project'
118
+ require 'pickler/tracker/story'
119
+ require 'pickler/tracker/iteration'
120
+ 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,51 @@
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 use_https?
14
+ @attributes['use_https'].to_s == 'true'
15
+ end
16
+
17
+ def story(story_id)
18
+ raise Error, "No story id given" if story_id.to_s.empty?
19
+ Story.new(self,tracker.get_xml("/projects/#{id}/stories/#{story_id}")["story"])
20
+ end
21
+
22
+ def stories(*args)
23
+ filter = encode_term(args) if args.any?
24
+ path = "/projects/#{id}/stories"
25
+ path << "?filter=#{CGI.escape(filter)}" if filter
26
+ response = tracker.get_xml(path)
27
+ [response["stories"]].flatten.compact.map {|s| Story.new(self,s)}
28
+ end
29
+
30
+ def new_story(attributes = {}, &block)
31
+ Story.new(self, attributes, &block)
32
+ end
33
+
34
+ def deliver_all_finished_stories
35
+ request_xml(:put,"/projects/#{id}/stories_deliver_all_finished")
36
+ end
37
+
38
+ private
39
+ def encode_term(term)
40
+ case term
41
+ when Array then term.map {|v| encode_term(v)}.join(" ")
42
+ when Hash then term.map {|k,v| encode_term("#{k}:#{v}")}.join(" ")
43
+ when /^\S+$/, Symbol then term
44
+ when /^(\S+?):(.*)$/ then %{#$1:"#$2"}
45
+ else %{"#{term}"}
46
+ end
47
+ end
48
+
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,208 @@
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
+ @labels = []
16
+ super(attributes)
17
+ @iteration = Iteration.new(project, @attributes["iteration"]) if @attributes["iteration"]
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 = :tag)
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
+ if to_s !~ /\A[\0-\177]*\z/
88
+ to_s = "# -*- coding: utf-8 -*-\n#{to_s}"
89
+ end
90
+ to_s
91
+ end
92
+
93
+ def header(format = :tag)
94
+ case format
95
+ when :tag
96
+ "@#{url || "#{project.use_https? ? 'https' : 'http'}://www.pivotaltracker.com/story/new"}#{labels.map {|l| " @#{l.tr(' _','_,')}"}.join}"
97
+ else
98
+ "# #{url}"
99
+ end
100
+ end
101
+
102
+ def to_s=(body)
103
+ if body =~ /\A@https?\b\S*(\s+@\S+)*\s*$/
104
+ self.labels = body[/\A@.*/].split(/\s+/)[1..-1].map {|l| l[1..-1].tr('_,',' _')}
105
+ end
106
+ body = body.sub(/\A(?:[@#].*\n)+/,'')
107
+ if body =~ /\A(\w+): (.*)/
108
+ self.story_type = $1.downcase
109
+ self.name = $2
110
+ description = $'
111
+ else
112
+ self.story_type = "feature"
113
+ self.name = body[/.*/]
114
+ description = $'
115
+ end
116
+ self.description = description.gsub(/\A\n+|\n+\Z/,'') + "\n"
117
+ if description_lines.all? {|l| l.empty? || l =~ /^ /}
118
+ self.description.gsub!(/^ /,'')
119
+ end
120
+ self
121
+ end
122
+
123
+ def description_lines
124
+ array = []
125
+ description.to_s.each_line do |line|
126
+ array << line.chomp
127
+ end
128
+ array
129
+ end
130
+
131
+ def notes
132
+ [@attributes["notes"]].flatten.compact.map {|n| Note.new(self,n)}
133
+ end
134
+
135
+ def estimate
136
+ @attributes["estimate"].to_i < 0 ? nil : @attributes["estimate"]
137
+ end
138
+
139
+ def estimate=(value)
140
+ @attributes["estimate"] = value.nil? ? -1 : value
141
+ end
142
+
143
+ def suggested_basename(user_override = nil)
144
+ if user_override.to_s !~ /\A-?\z/
145
+ user_override
146
+ else
147
+ name.to_s.empty? ? id.to_s : name.gsub(/[^\w-]+/,'_').downcase
148
+ end
149
+ end
150
+
151
+ def comment!(body)
152
+ response = tracker.request_xml(:post, "#{resource_url}/notes",{:text => body}.to_xml(:dasherize => false, :root => 'note'))
153
+ if response["note"]
154
+ Note.new(self, response["note"])
155
+ else
156
+ raise Pickler::Tracker::Error, Array(response["errors"]["error"]).join("\n"), caller
157
+ end
158
+ end
159
+
160
+ def to_xml(force_labels = true)
161
+ hash = @attributes.reject do |k,v|
162
+ !%w(current_state deadline description estimate name owned_by requested_by story_type).include?(k)
163
+ end
164
+ if force_labels || !id || normalize_labels(@attributes["labels"]) != labels
165
+ hash["labels"] = labels.join(", ")
166
+ end
167
+ hash.to_xml(:dasherize => false, :root => "story")
168
+ end
169
+
170
+ def destroy
171
+ if id
172
+ response = tracker.request_xml(:delete, "/projects/#{project.id}/stories/#{id}", "")
173
+ raise Error, response["message"], caller if response["message"]
174
+ @attributes["id"] = nil
175
+ self
176
+ end
177
+ end
178
+
179
+ def resource_url
180
+ ["/projects/#{project.id}/stories",id].compact.join("/")
181
+ end
182
+
183
+ def save
184
+ response = tracker.request_xml(id ? :put : :post, resource_url, to_xml(false))
185
+ if response["story"]
186
+ initialize(project, response["story"])
187
+ true
188
+ else
189
+ Array(response["errors"]["error"])
190
+ end
191
+ end
192
+
193
+ def save!
194
+ errors = save
195
+ if errors != true
196
+ raise Pickler::Tracker::Error, Array(errors).join("\n"), caller
197
+ end
198
+ self
199
+ end
200
+
201
+ private
202
+ def normalize_labels(value)
203
+ Array(value).join(", ").strip.split(/\s*,\s*/)
204
+ end
205
+
206
+ end
207
+ end
208
+ end