tracker_api 0.1.0 → 0.2.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 8407c876c55351601c4fd2648ced15f1af1336b3
4
- data.tar.gz: 983fcb13633fd259089a96eab64accc1ea49a777
3
+ metadata.gz: 415d625d6e47d65a310da72f3a89b4c8d18cf48a
4
+ data.tar.gz: 02a6be2414450453a95a2a045a94e7d6d9cebc93
5
5
  SHA512:
6
- metadata.gz: d5cb62b0d1e08ea319e1b88281216a5874e13c8d431f391b43002d46571db29d210cd8294ed2cea1ff58608814145a4092ded83d527d84584a6fc0736b301f6c
7
- data.tar.gz: 777c67421852258ef4a05e0165f4aa6de55c9ea207b03966fb383ba6f520c34cec821107f0dd2e9f14a3f5553fed7149cd57959940ea2a970c06f2fd9cefda9d
6
+ metadata.gz: 22c7a09d01872c391e0bd0d7ce80b0efa00cd087e20a2ecacf63e0e2a099071bcf4a826be55353629010ab65838c77a0088cb7eb241dc8b68d96983246ce3714
7
+ data.tar.gz: a26439fcf3b28af52c43e737978bf8da4058bb35e953fbfae3ec2794b672911d853c0c478cf804f2524a8b8f1eebdf02b6ade4a882f380d7dbb4a7dadaaa5075
@@ -1,3 +1,9 @@
1
1
  0.1.0
2
2
  ---
3
3
  - Initial release.
4
+
5
+ 0.2.0
6
+ ---
7
+ - Feature: Added auto pagination.
8
+ - Bug: Added `current_velocity` attribute to `Project`.
9
+ - Bug: Removed attributes from `Iteration` that don't exist.
data/README.md CHANGED
@@ -42,8 +42,8 @@ client.project(project_id, fields: ':default,epics') # Eage
42
42
 
43
43
  ## TODO
44
44
 
45
- - Pagination
46
- - Create, Update, Delete of Resources
45
+ - Add missing resources and endpoints
46
+ - Add create, update, delete for resources
47
47
 
48
48
  ## Contributing
49
49
 
@@ -6,6 +6,7 @@ require 'faraday'
6
6
  require 'faraday_middleware'
7
7
 
8
8
  # stdlib
9
+ require 'addressable/uri'
9
10
  require 'forwardable'
10
11
  require 'logger'
11
12
 
@@ -1,14 +1,18 @@
1
1
  module TrackerApi
2
2
  class Client
3
- USER_AGENT = "Ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM}; #{RUBY_ENGINE}) TrackerApi/#{TrackerApi::VERSION} Faraday/#{Faraday::VERSION}".freeze
3
+ USER_AGENT = "Ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM}; #{RUBY_ENGINE}) TrackerApi/#{TrackerApi::VERSION} Faraday/#{Faraday::VERSION}".freeze
4
4
 
5
- attr_accessor :url, :api_version, :token, :logger, :connection
5
+ # Header keys that can be passed in options hash to {#get},{#paginate}
6
+ CONVENIENCE_HEADERS = Set.new([:accept, :content_type])
7
+
8
+ attr_reader :url, :api_version, :token, :logger, :connection, :auto_paginate, :last_response
6
9
 
7
10
  # Create Pivotal Tracker API client.
8
11
  #
9
12
  # @param [Hash] options the connection options
10
13
  # @option options [String] :token API token to use for requests
11
14
  # @option options [String] :url Main HTTP API root
15
+ # @option options [Boolean] :auto_paginate Client should perform pagination automatically. Default true.
12
16
  # @option options [String] :api_version The API version URL path
13
17
  # @option options [String] :logger Custom logger
14
18
  # @option options [String] :adapter Custom http adapter to configure Faraday with
@@ -17,15 +21,14 @@ module TrackerApi
17
21
  # @example Creating a Client
18
22
  # Client.new token: 'my-super-special-token'
19
23
  def initialize(options={})
20
- url = options[:url] || 'https://www.pivotaltracker.com'
21
- @url = URI.parse(url).to_s
22
-
23
- @api_version = options[:api_version] || '/services/v5'
24
- @logger = options[:logger] || Logger.new(nil)
25
- adapter = options[:adapter] || :net_http
26
- connection_options = options[:connection_options] || { ssl: { verify: true } }
24
+ url = options.fetch(:url, 'https://www.pivotaltracker.com')
25
+ @url = Addressable::URI.parse(url).to_s
26
+ @api_version = options.fetch(:api_version, '/services/v5')
27
+ @logger = options.fetch(:logger, Logger.new(nil))
28
+ adapter = options.fetch(:adapter, :net_http)
29
+ connection_options = options.fetch(:connection_options, { ssl: { verify: true } })
30
+ @auto_paginate = options.fetch(:auto_paginate, true)
27
31
  @token = options[:token]
28
-
29
32
  raise 'Missing required options: :token' unless @token
30
33
 
31
34
  @connection = Faraday.new({ url: @url }.merge(connection_options)) do |builder|
@@ -42,30 +45,140 @@ module TrackerApi
42
45
  end
43
46
  end
44
47
 
45
- def request(options={})
46
- method = options[:method] || :get
47
- url = options[:url] || File.join(@url, @api_version, options[:path])
48
- token = options[:token] || @token
48
+ # Make a HTTP GET request
49
+ #
50
+ # @param path [String] The path, relative to api endpoint
51
+ # @param options [Hash] Query and header params for request
52
+ # @return [Faraday::Response]
53
+ def get(path, options = {})
54
+ request(:get, parse_query_and_convenience_headers(path, options))
55
+ end
56
+
57
+ # Make one or more HTTP GET requests, optionally fetching
58
+ # the next page of results from information passed back in headers
59
+ # based on value in {#auto_paginate}.
60
+ #
61
+ # @param path [String] The path, relative to {#api_endpoint}
62
+ # @param options [Hash] Query and header params for request
63
+ # @param block [Block] Block to perform the data concatenation of the
64
+ # multiple requests. The block is called with two parameters, the first
65
+ # contains the contents of the requests so far and the second parameter
66
+ # contains the latest response.
67
+ # @return [Array]
68
+ def paginate(path, options = {}, &block)
69
+ opts = parse_query_and_convenience_headers path, options.dup
70
+ @last_response = request :get, opts
71
+ data = @last_response.body
72
+ raise TrackerApi::Errors::UnexpectedData, 'Array expected' unless data.is_a? Array
73
+
74
+ if @auto_paginate
75
+ pager = Pagination.new @last_response.headers
76
+
77
+ while pager.more?
78
+ opts[:params].update(pager.next_page_params)
79
+
80
+ @last_response = request :get, opts
81
+ pager = Pagination.new @last_response.headers
82
+ if block_given?
83
+ yield(data, @last_response)
84
+ else
85
+ data.concat(@last_response.body) if @last_response.body.is_a?(Array)
86
+ end
87
+ end
88
+ end
89
+
90
+ data
91
+ end
92
+
93
+ # Get projects
94
+ #
95
+ # @param [Hash] params
96
+ # @return [Array[TrackerApi::Resources::Project]]
97
+ def projects(params={})
98
+ Endpoints::Projects.new(self).get(params)
99
+ end
100
+
101
+ # Get project
102
+ #
103
+ # @param [Hash] params
104
+ # @return [TrackerApi::Resources::Project]
105
+ def project(id, params={})
106
+ Endpoints::Project.new(self).get(id, params)
107
+ end
108
+
109
+ private
110
+
111
+ def parse_query_and_convenience_headers(path, options)
112
+ raise 'Path can not be blank.' if path.to_s.empty?
113
+
114
+ opts = { body: options[:body] }
115
+
116
+ opts[:url] = options[:url] || File.join(@url, @api_version, path.to_s)
117
+ opts[:method] = options[:method] || :get
118
+ opts[:params] = options[:params] || {}
119
+ opts[:token] = options[:token] || @token
120
+ headers = { 'User-Agent' => USER_AGENT,
121
+ 'X-TrackerToken' => opts.fetch(:token) }.merge(options.fetch(:headers, {}))
122
+
123
+ CONVENIENCE_HEADERS.each do |h|
124
+ if header = options[h]
125
+ headers[h] = header
126
+ end
127
+ end
128
+ opts[:headers] = headers
129
+
130
+ opts
131
+ end
132
+
133
+ def request(method, options = {})
134
+ url = options.fetch(:url)
49
135
  params = options[:params] || {}
50
136
  body = options[:body]
51
- headers = { 'User-Agent' => USER_AGENT, 'X-TrackerToken' => token }.merge(options[:headers] || {})
137
+ headers = options[:headers]
52
138
 
53
- connection.send(method) do |req|
54
- req.url url
139
+ @last_response = response = connection.send(method) do |req|
140
+ req.url(url)
55
141
  req.headers.merge!(headers)
56
142
  req.params.merge!(params)
57
143
  req.body = body
58
144
  end
145
+ response
59
146
  rescue Faraday::Error::ClientError => e
60
147
  raise TrackerApi::Error.new(e)
61
148
  end
62
149
 
63
- def projects(params={})
64
- Endpoints::Projects.new(self).get(params)
65
- end
150
+ class Pagination
151
+ attr_accessor :headers, :total, :limit, :offset, :returned
66
152
 
67
- def project(id, params={})
68
- Endpoints::Project.new(self).get(id, params)
153
+ def initialize(headers)
154
+ @headers = headers
155
+ @total = headers['x-tracker-pagination-total'].to_i
156
+ @limit = headers['x-tracker-pagination-limit'].to_i
157
+ @offset = headers['x-tracker-pagination-offset'].to_i
158
+ @returned = headers['x-tracker-pagination-returned'].to_i
159
+
160
+ # if offset is negative (e.g. Iterations Endpoint).
161
+ # For the 'Done' scope, negative numbers can be passed, which
162
+ # specifies the number of iterations preceding the 'Current' iteration.
163
+ # then need to adjust the negative offset to account for a smaller total,
164
+ # and set total to zero since we are paginating from -X to 0.
165
+ if @offset < 0
166
+ @offset = -@total if @offset.abs > @total
167
+ @total = 0
168
+ end
169
+ end
170
+
171
+ def more?
172
+ (offset + limit) < total
173
+ end
174
+
175
+ def next_offset
176
+ offset + limit
177
+ end
178
+
179
+ def next_page_params
180
+ { limit: limit, offset: next_offset }
181
+ end
69
182
  end
70
183
  end
71
184
  end
@@ -8,10 +8,7 @@ module TrackerApi
8
8
  end
9
9
 
10
10
  def get(project_id, id)
11
- data = client.request(
12
- method: :get,
13
- :path => "/projects/#{project_id}/epics/#{id}"
14
- ).body
11
+ data = client.get("/projects/#{project_id}/epics/#{id}").body
15
12
 
16
13
  Resources::Epic.new({ client: client }.merge(data))
17
14
  end
@@ -8,11 +8,7 @@ module TrackerApi
8
8
  end
9
9
 
10
10
  def get(project_id, params={})
11
- data = client.request(
12
- method: :get,
13
- path: "/projects/#{project_id}/epics",
14
- params: params
15
- ).body
11
+ data = client.paginate("/projects/#{project_id}/epics", params: params)
16
12
  raise TrackerApi::Errors::UnexpectedData, 'Array of epics expected' unless data.is_a? Array
17
13
 
18
14
  data.map { |epic| Resources::Epic.new({ client: client }.merge(epic)) }
@@ -8,11 +8,7 @@ module TrackerApi
8
8
  end
9
9
 
10
10
  def get(project_id, params={})
11
- data = client.request(
12
- method: :get,
13
- path: "/projects/#{project_id}/iterations",
14
- params: params
15
- ).body
11
+ data = client.paginate("/projects/#{project_id}/iterations", params: params)
16
12
  raise TrackerApi::Errors::UnexpectedData, 'Array of iterations expected' unless data.is_a? Array
17
13
 
18
14
  data.map { |iteration| Resources::Iteration.new({ client: client }.merge(iteration)) }
@@ -8,11 +8,7 @@ module TrackerApi
8
8
  end
9
9
 
10
10
  def get(id, params={})
11
- data = client.request(
12
- method: :get,
13
- path: "/projects/#{id}",
14
- params: params
15
- ).body
11
+ data = client.get("/projects/#{id}", params: params).body
16
12
 
17
13
  Resources::Project.new({ client: client }.merge(data))
18
14
  end
@@ -8,11 +8,7 @@ module TrackerApi
8
8
  end
9
9
 
10
10
  def get(params={})
11
- data = client.request(
12
- method: :get,
13
- path: '/projects',
14
- params: params
15
- ).body
11
+ data = client.paginate('/projects', params: params)
16
12
  raise TrackerApi::Errors::UnexpectedData, 'Array of projects expected' unless data.is_a? Array
17
13
 
18
14
  data.map { |project| Resources::Project.new({ client: client }.merge(project)) }
@@ -8,11 +8,7 @@ module TrackerApi
8
8
  end
9
9
 
10
10
  def get(project_id, params={})
11
- data = client.request(
12
- method: :get,
13
- path: "/projects/#{project_id}/stories",
14
- params: params
15
- ).body
11
+ data = client.paginate("/projects/#{project_id}/stories", params: params)
16
12
  raise TrackerApi::Errors::UnexpectedData, 'Array of stories expected' unless data.is_a? Array
17
13
 
18
14
  data.map { |story| Resources::Story.new({ client: client }.merge(story)) }
@@ -8,10 +8,7 @@ module TrackerApi
8
8
  end
9
9
 
10
10
  def get(project_id, id)
11
- data = client.request(
12
- method: :get,
13
- :path => "/projects/#{project_id}/stories/#{id}"
14
- ).body
11
+ data = client.get("/projects/#{project_id}/stories/#{id}").body
15
12
 
16
13
  Resources::Story.new({ client: client }.merge(data))
17
14
  end
@@ -5,9 +5,7 @@ module TrackerApi
5
5
 
6
6
  attribute :client
7
7
 
8
- attribute :created_at, DateTime
9
8
  attribute :finish, DateTime
10
- attribute :id, Integer
11
9
  attribute :kind, String
12
10
  attribute :length, Integer
13
11
  attribute :number, Integer
@@ -17,7 +15,6 @@ module TrackerApi
17
15
  attribute :stories, [TrackerApi::Resources::Story]
18
16
  attribute :story_ids, [Integer]
19
17
  attribute :team_strength, Float
20
- attribute :updated_at, DateTime
21
18
  end
22
19
  end
23
20
  end
@@ -11,6 +11,7 @@ module TrackerApi
11
11
  attribute :bugs_and_chores_are_estimatable, Boolean
12
12
  attribute :created_at, DateTime
13
13
  attribute :current_iteration_number, Integer
14
+ attribute :current_velocity, Integer
14
15
  attribute :description, String
15
16
  attribute :enable_following, Boolean
16
17
  attribute :enable_incoming_emails, Boolean
@@ -39,6 +40,11 @@ module TrackerApi
39
40
  attribute :version, Integer
40
41
  attribute :week_start_day, String
41
42
 
43
+ # @return [String] Comma separated list of labels.
44
+ def label_list
45
+ @label_list ||= labels.collect(&:name).join(',')
46
+ end
47
+
42
48
  # @return [Array[Epic]] epics associated with this project
43
49
  def epics(params={})
44
50
  raise ArgumentError, 'Expected @epics to be an Array' unless @epics.is_a? Array
@@ -48,7 +54,7 @@ module TrackerApi
48
54
  end
49
55
 
50
56
  # @param [Hash] params
51
- # @option params [String] :scope ('') Restricts the state of iterations to return.
57
+ # @option params [String] :scope Restricts the state of iterations to return.
52
58
  # If not specified, it defaults to all iterations including done.
53
59
  # Valid enumeration values: done, current, backlog, current_backlog.
54
60
  # @option params [Integer] :offset The offset of first iteration to return, relative to the
@@ -28,6 +28,11 @@ module TrackerApi
28
28
  attribute :task_ids, Array[Integer]
29
29
  attribute :updated_at, DateTime
30
30
  attribute :url, String
31
+
32
+ # @return [String] Comma separated list of labels.
33
+ def label_list
34
+ @label_list ||= labels.collect(&:name).join(',')
35
+ end
31
36
  end
32
37
  end
33
38
  end
@@ -1,3 +1,3 @@
1
1
  module TrackerApi
2
- VERSION = '0.1.0'
2
+ VERSION = '0.2.0'
3
3
  end
@@ -58,4 +58,38 @@ describe TrackerApi::Client do
58
58
  end
59
59
  end
60
60
  end
61
+
62
+ describe '.paginate' do
63
+ let(:pt_user) { PT_USER_1 }
64
+ let(:client) { TrackerApi::Client.new token: pt_user[:token] }
65
+ let(:project_id) { pt_user[:project_id] }
66
+
67
+ it 'auto paginates when needed' do
68
+ VCR.use_cassette('client: get all stories with pagination', record: :new_episodes) do
69
+ project = client.project(project_id)
70
+
71
+ # skip pagination with a hugh limit
72
+ unpaged_stories = project.stories(limit: 300)
73
+ unpaged_stories.wont_be_empty
74
+ unpaged_stories.length.must_be :>, 7
75
+
76
+ # force pagination with a small limit
77
+ paged_stories = project.stories(limit: 7)
78
+ paged_stories.wont_be_empty
79
+ paged_stories.length.must_equal unpaged_stories.length
80
+ paged_stories.map(&:id).sort.uniq.must_equal unpaged_stories.map(&:id).sort.uniq
81
+ end
82
+ end
83
+
84
+ it 'can handle negative offsets' do
85
+ VCR.use_cassette('client: done iterations with pagination', record: :new_episodes) do
86
+ project = client.project(project_id)
87
+
88
+ done_iterations = project.iterations(scope: :done, offset: -12, limit: 5)
89
+
90
+ done_iterations.wont_be_empty
91
+ done_iterations.length.must_be :<=, 12
92
+ end
93
+ end
94
+ end
61
95
  end
@@ -66,7 +66,7 @@ describe TrackerApi::Resources::Project do
66
66
  end
67
67
 
68
68
  describe '.stories' do
69
- it 'can get unscheduled stories (icebox)' do
69
+ it 'can get unscheduled stories' do
70
70
  VCR.use_cassette('get unscheduled stories', record: :new_episodes) do
71
71
  stories = project.stories(with_state: :unscheduled)
72
72