glennr-pickler 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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/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
+ 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,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,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
+ super(attributes)
16
+ @iteration = Iteration.new(project, @attributes["iteration"]) if @attributes["iteration"]
17
+ end
18
+
19
+ def iteration
20
+ unless current_state == 'unscheduled' || defined?(@iteration)
21
+ @iteration = project.stories(:id => id, :includedone => true).first.iteration
22
+ end
23
+ @iteration
24
+ end
25
+
26
+ def labels=(value)
27
+ @labels = normalize_labels(value)
28
+ end
29
+
30
+ def transition!(state)
31
+ raise Pickler::Tracker::Error, "Invalid state #{state}", caller unless STATES.include?(state)
32
+ self.current_state = state
33
+ if id
34
+ xml = "<story><current_state>#{state}</current_state></story>"
35
+ error = tracker.request_xml(:put, resource_url, xml).fetch("errors",{})["error"] || true
36
+ else
37
+ error = save
38
+ end
39
+ raise Pickler::Tracker::Error, Array(error).join("\n"), caller unless error == true
40
+ end
41
+
42
+ def finish
43
+ case story_type
44
+ when "bug", "feature"
45
+ self.current_state = "finished" unless complete?
46
+ when "chore", "release"
47
+ self.current_state = "accepted"
48
+ end
49
+ current_state
50
+ end
51
+
52
+ def finish!
53
+ transition!(finish)
54
+ end
55
+
56
+ def backlog?(as_of = Date.today)
57
+ iteration && iteration.start >= as_of
58
+ end
59
+
60
+ def current?(as_of = Date.today)
61
+ iteration && iteration.include?(as_of)
62
+ end
63
+
64
+ # In a previous iteration
65
+ def done?(as_of = Date.today)
66
+ iteration && iteration.finish <= as_of
67
+ end
68
+
69
+ def complete?
70
+ %w(finished delivered accepted).include?(current_state)
71
+ end
72
+
73
+ def startable?
74
+ %w(unscheduled unstarted rejected).include?(current_state)
75
+ end
76
+
77
+ def tracker
78
+ project.tracker
79
+ end
80
+
81
+ def to_s(format = :comment)
82
+ to_s = "#{header(format)}\n#{story_type.capitalize}: #{name}\n"
83
+ description_lines.each do |line|
84
+ to_s << " #{line}".rstrip << "\n"
85
+ end
86
+ to_s
87
+ end
88
+
89
+ def header(format = :comment)
90
+ case format
91
+ when :tag
92
+ unless labels.nil?
93
+ "@#{url}#{labels.map {|l| " @#{l.tr(' _','_,')}"}.join}"
94
+ else
95
+ "# #{url}"
96
+ end
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