pickler 0.1.3
Sign up to get free protection for your applications and to get access to all the features.
- data/MIT-LICENSE +20 -0
- data/README.rdoc +62 -0
- data/bin/pickler +7 -0
- data/lib/pickler.rb +138 -0
- data/lib/pickler/feature.rb +114 -0
- data/lib/pickler/runner.rb +517 -0
- data/lib/pickler/tracker.rb +116 -0
- data/lib/pickler/tracker/iteration.rb +45 -0
- data/lib/pickler/tracker/note.rb +31 -0
- data/lib/pickler/tracker/project.rb +47 -0
- data/lib/pickler/tracker/story.rb +201 -0
- data/pickler.gemspec +28 -0
- metadata +85 -0
@@ -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,201 @@
|
|
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 suggested_basename(user_override = nil)
|
137
|
+
if user_override.to_s !~ /\A-?\z/
|
138
|
+
user_override
|
139
|
+
else
|
140
|
+
name.to_s.empty? ? id.to_s : name.gsub(/[^\w-]+/,'_').downcase
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def comment!(body)
|
145
|
+
response = tracker.request_xml(:post, "#{resource_url}/notes",{:text => body}.to_xml(:dasherize => false, :root => 'note'))
|
146
|
+
if response["note"]
|
147
|
+
Note.new(self, response["note"])
|
148
|
+
else
|
149
|
+
raise Pickler::Tracker::Error, Array(response["errors"]["error"]).join("\n"), caller
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def to_xml(force_labels = true)
|
154
|
+
hash = @attributes.reject do |k,v|
|
155
|
+
!%w(current_state deadline description estimate name owned_by requested_by story_type).include?(k)
|
156
|
+
end
|
157
|
+
if force_labels || !id || normalize_labels(@attributes["labels"]) != labels
|
158
|
+
hash["labels"] = labels.join(", ")
|
159
|
+
end
|
160
|
+
hash.to_xml(:dasherize => false, :root => "story")
|
161
|
+
end
|
162
|
+
|
163
|
+
def destroy
|
164
|
+
if id
|
165
|
+
response = tracker.request_xml(:delete, "/projects/#{project.id}/stories/#{id}", "")
|
166
|
+
raise Error, response["message"], caller if response["message"]
|
167
|
+
@attributes["id"] = nil
|
168
|
+
self
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
def resource_url
|
173
|
+
["/projects/#{project.id}/stories",id].compact.join("/")
|
174
|
+
end
|
175
|
+
|
176
|
+
def save
|
177
|
+
response = tracker.request_xml(id ? :put : :post, resource_url, to_xml(false))
|
178
|
+
if response["story"]
|
179
|
+
initialize(project, response["story"])
|
180
|
+
true
|
181
|
+
else
|
182
|
+
Array(response["errors"]["error"])
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
def save!
|
187
|
+
errors = save
|
188
|
+
if errors != true
|
189
|
+
raise Pickler::Tracker::Error, Array(errors).join("\n"), caller
|
190
|
+
end
|
191
|
+
self
|
192
|
+
end
|
193
|
+
|
194
|
+
private
|
195
|
+
def normalize_labels(value)
|
196
|
+
Array(value).join(", ").strip.split(/\s*,\s*/)
|
197
|
+
end
|
198
|
+
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
data/pickler.gemspec
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = "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
|