tracker_api 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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