nofxx-pickler 0.0.10
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.
- data/MIT-LICENSE +20 -0
- data/README.rdoc +79 -0
- data/bin/pickler +8 -0
- data/bin/piv +8 -0
- data/lib/pickler.rb +139 -0
- data/lib/pickler/feature.rb +112 -0
- data/lib/pickler/runner.rb +553 -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 +193 -0
- data/pickler.gemspec +29 -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,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
|
data/pickler.gemspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = "pickler"
|
3
|
+
s.version = "0.0.10"
|
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 = ["piv", "pickler"]
|
12
|
+
s.files = [
|
13
|
+
"README.rdoc",
|
14
|
+
"MIT-LICENSE",
|
15
|
+
"pickler.gemspec",
|
16
|
+
"bin/piv",
|
17
|
+
"bin/pickler",
|
18
|
+
"lib/pickler.rb",
|
19
|
+
"lib/pickler/feature.rb",
|
20
|
+
"lib/pickler/runner.rb",
|
21
|
+
"lib/pickler/tracker.rb",
|
22
|
+
"lib/pickler/tracker/project.rb",
|
23
|
+
"lib/pickler/tracker/story.rb",
|
24
|
+
"lib/pickler/tracker/iteration.rb",
|
25
|
+
"lib/pickler/tracker/note.rb"
|
26
|
+
]
|
27
|
+
s.add_dependency("activesupport", [">= 2.0.0"])
|
28
|
+
s.add_dependency("cucumber", [">= 0.3.0"])
|
29
|
+
end
|