Skip to content
This repository was archived by the owner on Apr 13, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
0.1.0
---
- Initial release.

0.2.0
---
- Feature: Added auto pagination.
- Bug: Added `current_velocity` attribute to `Project`.
- Bug: Removed attributes from `Iteration` that don't exist.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ client.project(project_id, fields: ':default,epics') # Eage

## TODO

- Pagination
- Create, Update, Delete of Resources
- Add missing resources and endpoints
- Add create, update, delete for resources

## Contributing

Expand Down
1 change: 1 addition & 0 deletions lib/tracker_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require 'faraday_middleware'

# stdlib
require 'addressable/uri'
require 'forwardable'
require 'logger'

Expand Down
147 changes: 125 additions & 22 deletions lib/tracker_api/client.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
module TrackerApi
class Client
USER_AGENT = "Ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM}; #{RUBY_ENGINE}) TrackerApi/#{TrackerApi::VERSION} Faraday/#{Faraday::VERSION}".freeze
USER_AGENT = "Ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM}; #{RUBY_ENGINE}) TrackerApi/#{TrackerApi::VERSION} Faraday/#{Faraday::VERSION}".freeze

attr_accessor :url, :api_version, :token, :logger, :connection
# Header keys that can be passed in options hash to {#get},{#paginate}
CONVENIENCE_HEADERS = Set.new([:accept, :content_type])

attr_reader :url, :api_version, :token, :logger, :connection, :auto_paginate, :last_response

# Create Pivotal Tracker API client.
#
# @param [Hash] options the connection options
# @option options [String] :token API token to use for requests
# @option options [String] :url Main HTTP API root
# @option options [Boolean] :auto_paginate Client should perform pagination automatically. Default true.
# @option options [String] :api_version The API version URL path
# @option options [String] :logger Custom logger
# @option options [String] :adapter Custom http adapter to configure Faraday with
Expand All @@ -17,15 +21,14 @@ class Client
# @example Creating a Client
# Client.new token: 'my-super-special-token'
def initialize(options={})
url = options[:url] || 'https://www.pivotaltracker.com'
@url = URI.parse(url).to_s

@api_version = options[:api_version] || '/services/v5'
@logger = options[:logger] || Logger.new(nil)
adapter = options[:adapter] || :net_http
connection_options = options[:connection_options] || { ssl: { verify: true } }
url = options.fetch(:url, 'https://www.pivotaltracker.com')
@url = Addressable::URI.parse(url).to_s
@api_version = options.fetch(:api_version, '/services/v5')
@logger = options.fetch(:logger, Logger.new(nil))
adapter = options.fetch(:adapter, :net_http)
connection_options = options.fetch(:connection_options, { ssl: { verify: true } })
@auto_paginate = options.fetch(:auto_paginate, true)
@token = options[:token]

raise 'Missing required options: :token' unless @token

@connection = Faraday.new({ url: @url }.merge(connection_options)) do |builder|
Expand All @@ -42,30 +45,130 @@ def initialize(options={})
end
end

def request(options={})
method = options[:method] || :get
url = options[:url] || File.join(@url, @api_version, options[:path])
token = options[:token] || @token
# Make a HTTP GET request
#
# @param path [String] The path, relative to api endpoint
# @param options [Hash] Query and header params for request
# @return [Faraday::Response]
def get(path, options = {})
request(:get, parse_query_and_convenience_headers(path, options))
end

# Make one or more HTTP GET requests, optionally fetching
# the next page of results from information passed back in headers
# based on value in {#auto_paginate}.
#
# @param path [String] The path, relative to {#api_endpoint}
# @param options [Hash] Query and header params for request
# @param block [Block] Block to perform the data concatenation of the
# multiple requests. The block is called with two parameters, the first
# contains the contents of the requests so far and the second parameter
# contains the latest response.
# @return [Array]
def paginate(path, options = {}, &block)
opts = parse_query_and_convenience_headers path, options.dup
@last_response = request :get, opts
data = @last_response.body
raise TrackerApi::Errors::UnexpectedData, 'Array expected' unless data.is_a? Array

if @auto_paginate
pager = Pagination.new @last_response.headers

while pager.more?
opts[:params].update(pager.next_page_params)

@last_response = request :get, opts
pager = Pagination.new @last_response.headers
if block_given?
yield(data, @last_response)
else
data.concat(@last_response.body) if @last_response.body.is_a?(Array)
end
end
end

data
end

# Get projects
#
# @param [Hash] params
# @return [Array[TrackerApi::Resources::Project]]
def projects(params={})
Endpoints::Projects.new(self).get(params)
end

# Get project
#
# @param [Hash] params
# @return [TrackerApi::Resources::Project]
def project(id, params={})
Endpoints::Project.new(self).get(id, params)
end

private

def parse_query_and_convenience_headers(path, options)
raise 'Path can not be blank.' if path.to_s.empty?

opts = { body: options[:body] }

opts[:url] = options[:url] || File.join(@url, @api_version, path.to_s)
opts[:method] = options[:method] || :get
opts[:params] = options[:params] || {}
opts[:token] = options[:token] || @token
headers = { 'User-Agent' => USER_AGENT,
'X-TrackerToken' => opts.fetch(:token) }.merge(options.fetch(:headers, {}))

CONVENIENCE_HEADERS.each do |h|
if header = options[h]
headers[h] = header
end
end
opts[:headers] = headers

opts
end

def request(method, options = {})
url = options.fetch(:url)
params = options[:params] || {}
body = options[:body]
headers = { 'User-Agent' => USER_AGENT, 'X-TrackerToken' => token }.merge(options[:headers] || {})
headers = options[:headers]

connection.send(method) do |req|
req.url url
@last_response = response = connection.send(method) do |req|
req.url(url)
req.headers.merge!(headers)
req.params.merge!(params)
req.body = body
end
response
rescue Faraday::Error::ClientError => e
raise TrackerApi::Error.new(e)
end

def projects(params={})
Endpoints::Projects.new(self).get(params)
end
class Pagination
attr_accessor :headers, :total, :limit, :offset, :returned

def project(id, params={})
Endpoints::Project.new(self).get(id, params)
def initialize(headers)
@headers = headers
@total = headers['x-tracker-pagination-total'].to_i
@limit = headers['x-tracker-pagination-limit'].to_i
@offset = headers['x-tracker-pagination-offset'].to_i
@returned = headers['x-tracker-pagination-returned'].to_i
end

def more?
(offset + limit) < total
end

def next_offset
offset + limit
end

def next_page_params
{ limit: limit, offset: next_offset }
end
end
end
end
5 changes: 1 addition & 4 deletions lib/tracker_api/endpoints/epic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@ def initialize(client)
end

def get(project_id, id)
data = client.request(
method: :get,
:path => "/projects/#{project_id}/epics/#{id}"
).body
data = client.get("/projects/#{project_id}/epics/#{id}").body

Resources::Epic.new({ client: client }.merge(data))
end
Expand Down
6 changes: 1 addition & 5 deletions lib/tracker_api/endpoints/epics.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@ def initialize(client)
end

def get(project_id, params={})
data = client.request(
method: :get,
path: "/projects/#{project_id}/epics",
params: params
).body
data = client.paginate("/projects/#{project_id}/epics", params: params)
raise TrackerApi::Errors::UnexpectedData, 'Array of epics expected' unless data.is_a? Array

data.map { |epic| Resources::Epic.new({ client: client }.merge(epic)) }
Expand Down
6 changes: 1 addition & 5 deletions lib/tracker_api/endpoints/iterations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@ def initialize(client)
end

def get(project_id, params={})
data = client.request(
method: :get,
path: "/projects/#{project_id}/iterations",
params: params
).body
data = client.paginate("/projects/#{project_id}/iterations", params: params)
raise TrackerApi::Errors::UnexpectedData, 'Array of iterations expected' unless data.is_a? Array

data.map { |iteration| Resources::Iteration.new({ client: client }.merge(iteration)) }
Expand Down
6 changes: 1 addition & 5 deletions lib/tracker_api/endpoints/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@ def initialize(client)
end

def get(id, params={})
data = client.request(
method: :get,
path: "/projects/#{id}",
params: params
).body
data = client.get("/projects/#{id}", params: params).body

Resources::Project.new({ client: client }.merge(data))
end
Expand Down
6 changes: 1 addition & 5 deletions lib/tracker_api/endpoints/projects.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@ def initialize(client)
end

def get(params={})
data = client.request(
method: :get,
path: '/projects',
params: params
).body
data = client.paginate('/projects', params: params)
raise TrackerApi::Errors::UnexpectedData, 'Array of projects expected' unless data.is_a? Array

data.map { |project| Resources::Project.new({ client: client }.merge(project)) }
Expand Down
6 changes: 1 addition & 5 deletions lib/tracker_api/endpoints/stories.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@ def initialize(client)
end

def get(project_id, params={})
data = client.request(
method: :get,
path: "/projects/#{project_id}/stories",
params: params
).body
data = client.paginate("/projects/#{project_id}/stories", params: params)
raise TrackerApi::Errors::UnexpectedData, 'Array of stories expected' unless data.is_a? Array

data.map { |story| Resources::Story.new({ client: client }.merge(story)) }
Expand Down
5 changes: 1 addition & 4 deletions lib/tracker_api/endpoints/story.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@ def initialize(client)
end

def get(project_id, id)
data = client.request(
method: :get,
:path => "/projects/#{project_id}/stories/#{id}"
).body
data = client.get("/projects/#{project_id}/stories/#{id}").body

Resources::Story.new({ client: client }.merge(data))
end
Expand Down
3 changes: 0 additions & 3 deletions lib/tracker_api/resources/iteration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ class Iteration

attribute :client

attribute :created_at, DateTime
attribute :finish, DateTime
attribute :id, Integer
attribute :kind, String
attribute :length, Integer
attribute :number, Integer
Expand All @@ -17,7 +15,6 @@ class Iteration
attribute :stories, [TrackerApi::Resources::Story]
attribute :story_ids, [Integer]
attribute :team_strength, Float
attribute :updated_at, DateTime
end
end
end
8 changes: 7 additions & 1 deletion lib/tracker_api/resources/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Project
attribute :bugs_and_chores_are_estimatable, Boolean
attribute :created_at, DateTime
attribute :current_iteration_number, Integer
attribute :current_velocity, Integer
attribute :description, String
attribute :enable_following, Boolean
attribute :enable_incoming_emails, Boolean
Expand Down Expand Up @@ -39,6 +40,11 @@ class Project
attribute :version, Integer
attribute :week_start_day, String

# @return [String] Comma separated list of labels.
def label_list
@label_list ||= labels.collect(&:name).join(',')
end

# @return [Array[Epic]] epics associated with this project
def epics(params={})
raise ArgumentError, 'Expected @epics to be an Array' unless @epics.is_a? Array
Expand All @@ -48,7 +54,7 @@ def epics(params={})
end

# @param [Hash] params
# @option params [String] :scope ('') Restricts the state of iterations to return.
# @option params [String] :scope Restricts the state of iterations to return.
# If not specified, it defaults to all iterations including done.
# Valid enumeration values: done, current, backlog, current_backlog.
# @option params [Integer] :offset The offset of first iteration to return, relative to the
Expand Down
5 changes: 5 additions & 0 deletions lib/tracker_api/resources/story.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ class Story
attribute :task_ids, Array[Integer]
attribute :updated_at, DateTime
attribute :url, String

# @return [String] Comma separated list of labels.
def label_list
@label_list ||= labels.collect(&:name).join(',')
end
end
end
end
2 changes: 1 addition & 1 deletion lib/tracker_api/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module TrackerApi
VERSION = '0.1.0'
VERSION = '0.2.0'
end
23 changes: 23 additions & 0 deletions test/client_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,27 @@
end
end
end

describe '.paginate' do
let(:pt_user) { PT_USER_1 }
let(:client) { TrackerApi::Client.new token: pt_user[:token] }
let(:project_id) { pt_user[:project_id] }

it 'auto paginates when needed' do
VCR.use_cassette('client: get all stories with pagination', record: :new_episodes) do
project = client.project(project_id)

# skip pagination with a hugh limit
unpaged_stories = project.stories(limit: 300)
unpaged_stories.wont_be_empty
unpaged_stories.length.must_be :>, 7

# force pagination with a small limit
paged_stories = project.stories(limit: 7)
paged_stories.wont_be_empty
paged_stories.length.must_equal unpaged_stories.length
paged_stories.map(&:id).sort.uniq.must_equal unpaged_stories.map(&:id).sort.uniq
end
end
end
end
Loading