diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..f57ef21c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,20 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: Bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +A runnable code example to reproduce the issue. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..f24e6f6e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: Feature +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context about the feature request here. diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 00000000..2e1387f7 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,28 @@ +name: Ruby + +on: [push, pull_request] + +jobs: + test: + name: CI-tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: + - '3.3' + - '3.2' + - '3.1' + steps: + - uses: actions/checkout@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run tests + run: | + bundle exec rake jira:generate_public_cert + bundle exec rake spec diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..44aa3f48 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,100 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "master" ] + paths-ignore: + - '**/*.md' + - 'http-basic-example.rb' + - 'example.rb' + pull_request: + branches: [ "master" ] + paths-ignore: + - '**/*.md' + - 'http-basic-example.rb' + - 'example.rb' + schedule: + - cron: '0 13 * * *' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: ruby + build-mode: none + # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.gitignore b/.gitignore index dc5f2747..cc85860d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ pkg/* .DS_STORE doc .ruby-version + +.rakeTasks diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 1bab3706..00000000 --- a/.travis.yml +++ /dev/null @@ -1,8 +0,0 @@ -language: ruby -rvm: - - 2.3 - - 2.4 - - ruby-head -before_script: - - rake jira:generate_public_cert -script: bundle exec rake spec diff --git a/Gemfile b/Gemfile index 04597bf0..4912c25c 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,4 @@ -source 'http://rubygems.org' +source 'https://rubygems.org' group :development do gem 'guard' diff --git a/README.md b/README.md index 386b912a..2f0cb9cd 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,114 @@ # JIRA API Gem [![Code Climate](https://codeclimate.com/github/sumoheavy/jira-ruby.svg)](https://codeclimate.com/github/sumoheavy/jira-ruby) -[![Build Status](https://travis-ci.org/sumoheavy/jira-ruby.svg?branch=master)](https://travis-ci.org/sumoheavy/jira-ruby) +[![Build Status](https://github.com/sumoheavy/jira-ruby/actions/workflows/CI.yml/badge.svg)](https://github.com/sumoheavy/jira-ruby/actions/workflows/CI.yml) This gem provides access to the Atlassian JIRA REST API. -## Slack +## Example usage -Join our Slack channel! You can find us [here](https://jira-ruby-slackin.herokuapp.com/) +# Jira Ruby API - Sample Usage -## Example usage +This sample usage demonstrates how you can interact with JIRA's API using the [jira-ruby gem](https://github.com/sumoheavy/jira-ruby). -```ruby -require 'rubygems' -require 'jira-ruby' +### Dependencies + +Before running, install the `jira-ruby` gem: +```shell +gem install jira-ruby +``` + +### Sample Usage +Connect to JIRA +Firstly, establish a connection with your JIRA instance by providing a few configuration parameters: +There are other ways to connect to JIRA listed below | [Personal Access Token](#configuring-jira-to-use-personal-access-tokens-auth) +- private_key_file: The path to your RSA private key file. +- consumer_key: Your consumer key. +- site: The URL of your JIRA instance. + +```ruby options = { - :username => 'username', - :password => 'pass1234', - :site => 'http://mydomain.atlassian.net:443/', - :context_path => '', - :auth_type => :basic + :private_key_file => "rsakey.pem", + :context_path => '', + :consumer_key => 'your_consumer_key', + :site => 'your_jira_instance_url' } client = JIRA::Client.new(options) +``` -project = client.Project.find('SAMPLEPROJECT') +### Retrieve and Display Projects -project.issues.each do |issue| - puts "#{issue.id} - #{issue.summary}" +After establishing the connection, you can fetch all projects and display their key and name: +```ruby +projects = client.Project.all + +projects.each do |project| + puts "Project -> key: #{project.key}, name: #{project.name}" end ``` +### Handling Fields by Name +The jira-ruby gem allows you to refer to fields by their custom names rather than identifiers. Make sure to map fields before using them: + +```ruby +client.Field.map_fields + +old_way = issue.customfield_12345 + +# Note: The methods mapped here adopt a unique style combining PascalCase and snake_case conventions. +new_way = issue.Special_Field +``` + +### JQL Queries +To find issues based on specific criteria, you can use JIRA Query Language (JQL): + +```ruby +client.Issue.jql(a_normal_jql_search, fields:[:description, :summary, :Special_field, :created]) +``` + +### Several actions can be performed on the Issue object such as create, update, transition, delete, etc: +### Creating an Issue +```ruby +issue = client.Issue.build +labels = ['label1', 'label2'] +issue.save({ + "fields" => { + "summary" => "blarg from in example.rb", + "project" => {"key" => "SAMPLEPROJECT"}, + "issuetype" => {"id" => "3"}, + "labels" => labels, + "priority" => {"id" => "1"} + } +}) +``` + +### Updating/Transitioning an Issue +```ruby +issue = client.Issue.find("10002") +issue.save({"fields"=>{"summary"=>"EVEN MOOOOOOARRR NINJAAAA!"}}) + +issue_transition = issue.transitions.build +issue_transition.save!('transition' => {'id' => transition_id}) +``` + +### Deleting an Issue +```ruby +issue = client.Issue.find('SAMPLEPROJECT-2') +issue.delete +``` + +### Other Capabilities +Apart from the operations listed above, this API wrapper supports several other capabilities like: + • Searching for a user + • Retrieving an issue's watchers + • Changing the assignee of an issue + • Adding attachments and comments to issues + • Managing issue links and much more. + +Not all examples are shown in this README; refer to the complete script example for a full overview of the capabilities supported by this API wrapper. + ## Links to JIRA REST API documentation * [Overview](https://developer.atlassian.com/display/JIRADEV/JIRA+REST+APIs) @@ -87,7 +164,7 @@ key. > After you have entered all the information click OK and ensure OAuth authentication is > enabled. -For 2 legged oauth in server mode only, not in cloud based JIRA, make sure to `Allow 2-Legged OAuth` +For two legged oauth in server mode only, not in cloud based JIRA, make sure to `Allow 2-Legged OAuth` ## Configuring JIRA to use HTTP Basic Auth @@ -99,7 +176,7 @@ defaults to HTTP Basic Auth. Jira supports cookie based authentication whereby user credentials are passed to JIRA via a JIRA REST API call. This call returns a session cookie which must -then be sent to all following JIRA REST API calls. +then be sent to all following JIRA REST API calls. To enable cookie based authentication, set `:auth_type` to `:cookie`, set `:use_cookies` to `true` and set `:username` and `:password` accordingly. @@ -114,7 +191,7 @@ options = { :context_path => '', :auth_type => :cookie, # Set cookie based authentication :use_cookies => true, # Send cookies with each request - :additional_cookies => ['AUTH=vV7uzixt0SScJKg7'] # Optional cookies to send + :additional_cookies => ['AUTH=vV7uzixt0SScJKg7'] # Optional cookies to send # with each request } @@ -134,15 +211,40 @@ cookie to add to the request. Some authentication schemes that require additional cookies ignore the username and password sent in the JIRA REST API call. For those use cases, `:username` -and `:password` may be omitted from `options`. +and `:password` may be omitted from `options`. + +## Configuring JIRA to use Personal Access Tokens Auth +If your JIRA system is configured to support Personal Access Token authorization, minor modifications are needed in how credentials are communicated to the server. Specifically, the paremeters `:username` and `:password` are not needed. Also, the parameter `:default_headers` is needed to contain the api_token, which can be obtained following the official documentation from [Atlassian](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html). Please note that the Personal Access Token can only be used as it is. If it is encoded (with base64 or any other encoding method) then the token will not work correctly and authentication will fail. + +```ruby +require 'jira-ruby' + +# NOTE: the token should not be encoded +api_token = API_TOKEN_OBTAINED_FROM_JIRA_UI + +options = { + :site => 'http://mydomain.atlassian.net:443/', + :context_path => '', + :username => '', + :password => api_token, + :auth_type => :basic +} +client = JIRA::Client.new(options) + +project = client.Project.find('SAMPLEPROJECT') + +project.issues.each do |issue| + puts "#{issue.id} - #{issue.summary}" +end +``` ## Using the API Gem in a command line application Using HTTP Basic Authentication, configure and connect a client to your instance of JIRA. Note: If your Jira install is hosted on [atlassian.net](atlassian.net), it will have no context -path by default. If you're having issues connecting, try setting context_path +path by default. If you're having issues connecting, try setting context_path to an empty string in the options hash. ```ruby @@ -163,7 +265,7 @@ api_token = "myApiToken" options = { :username => username, :password => api_token, - :site => 'http://localhost:8080/', # or 'https://.atlassian.net' + :site => 'http://localhost:8080/', # or 'https://.atlassian.net/' :context_path => '/myjira', # often blank :auth_type => :basic, :read_timeout => 120 @@ -307,7 +409,7 @@ class App < Sinatra::Base # site uri, and the request token, access token, and authorize paths before do options = { - :site => 'http://localhost:2990', + :site => 'http://localhost:2990/', :context_path => '/jira', :signature_method => 'RSA-SHA1', :request_token_path => "/plugins/servlet/oauth/request-token", @@ -405,7 +507,7 @@ require 'pp' require 'jira-ruby' options = { - :site => 'http://localhost:2990', + :site => 'http://localhost:2990/', :context_path => '/jira', :signature_method => 'RSA-SHA1', :private_key_file => "rsakey.pem", diff --git a/Rakefile b/Rakefile index 983806e8..cdb06d6f 100644 --- a/Rakefile +++ b/Rakefile @@ -14,13 +14,13 @@ desc 'Prepare and run rspec tests' task :prepare do rsa_key = File.expand_path('rsakey.pem') unless File.exist?(rsa_key) - raise 'rsakey.pem does not exist, tests will fail. Run `rake jira:generate_public_cert` first' + Rake::Task['jira:generate_public_cert'].invoke end end desc 'Run RSpec tests' # RSpec::Core::RakeTask.new(:spec) -RSpec::Core::RakeTask.new(:spec) do |task| +RSpec::Core::RakeTask.new(:spec, [] => [:prepare]) do |task| task.rspec_opts = ['--color', '--format', 'doc'] end diff --git a/example.rb b/example.rb index 5e92b808..f332c74e 100644 --- a/example.rb +++ b/example.rb @@ -166,6 +166,14 @@ # # -------------------------- # issue.comments.first.save({"body" => "an updated comment frome example.rb"}) + +# # Add attachment to Issue +# # ------------------------ +# issue = client.Issue.find('PROJ-1') +# attachment = issue.attachments.build +# attachment.save('file': '/path/to/file') +# + # List all available link types # ------------------------------ pp client.Issuelinktype.all diff --git a/jira-ruby.gemspec b/jira-ruby.gemspec index c898f811..70ce734b 100644 --- a/jira-ruby.gemspec +++ b/jira-ruby.gemspec @@ -11,9 +11,7 @@ Gem::Specification.new do |s| s.licenses = ['MIT'] s.metadata = { 'source_code_uri' => 'https://github.com/sumoheavy/jira-ruby' } - s.required_ruby_version = '>= 1.9.3' - - s.rubyforge_project = 'jira-ruby' + s.required_ruby_version = '>= 3.1.0' s.files = `git ls-files`.split("\n") s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") @@ -24,14 +22,14 @@ Gem::Specification.new do |s| s.add_runtime_dependency 'activesupport' s.add_runtime_dependency 'atlassian-jwt' s.add_runtime_dependency 'multipart-post' - s.add_runtime_dependency 'oauth', '~> 0.5', '>= 0.5.0' + s.add_runtime_dependency 'oauth', '~> 1.0' # Development Dependencies - s.add_development_dependency 'guard', '~> 2.13', '>= 2.13.0' - s.add_development_dependency 'guard-rspec', '~> 4.6', '>= 4.6.5' - s.add_development_dependency 'pry', '~> 0.10', '>= 0.10.3' + s.add_development_dependency 'guard', '~> 2.18', '>= 2.18.1' + s.add_development_dependency 'guard-rspec', '~> 4.7', '>= 4.7.3' + s.add_development_dependency 'pry', '~> 0.14', '>= 0.14.3' s.add_development_dependency 'railties' - s.add_development_dependency 'rake', '~> 10.3', '>= 10.3.2' - s.add_development_dependency 'rspec', '~> 3.0', '>= 3.0.0' - s.add_development_dependency 'webmock', '~> 1.18', '>= 1.18.0' + s.add_development_dependency 'rake', '~> 13.2', '>= 13.2.1' + s.add_development_dependency 'rspec', '~> 3.0', '>= 3.13' + s.add_development_dependency 'webmock', '~> 3.23', '>= 3.23.0' end diff --git a/lib/jira-ruby.rb b/lib/jira-ruby.rb index 4bfccf00..addb96c6 100644 --- a/lib/jira-ruby.rb +++ b/lib/jira-ruby.rb @@ -1,5 +1,6 @@ $LOAD_PATH << __dir__ +require 'active_support' require 'active_support/inflector' ActiveSupport::Inflector.inflections do |inflector| inflector.singular /status$/, 'status' @@ -17,6 +18,7 @@ require 'jira/resource/issuetype' require 'jira/resource/version' require 'jira/resource/status' +require 'jira/resource/status_category' require 'jira/resource/transition' require 'jira/resource/project' require 'jira/resource/priority' @@ -25,19 +27,22 @@ require 'jira/resource/applinks' require 'jira/resource/issuelinktype' require 'jira/resource/issuelink' +require 'jira/resource/suggested_issue' +require 'jira/resource/issue_picker_suggestions_issue' +require 'jira/resource/issue_picker_suggestions' require 'jira/resource/remotelink' require 'jira/resource/sprint' -require 'jira/resource/sprint_report' +require 'jira/resource/resolution' require 'jira/resource/issue' require 'jira/resource/filter' require 'jira/resource/field' require 'jira/resource/rapidview' -require 'jira/resource/resolution' require 'jira/resource/serverinfo' require 'jira/resource/createmeta' require 'jira/resource/webhook' require 'jira/resource/agile' require 'jira/resource/board' +require 'jira/resource/board_configuration' require 'jira/request_client' require 'jira/oauth_client' diff --git a/lib/jira/base.rb b/lib/jira/base.rb index 008d5e4e..8a8520cc 100644 --- a/lib/jira/base.rb +++ b/lib/jira/base.rb @@ -141,7 +141,7 @@ def self.collection_path(client, prefix = '/') # JIRA::Resource::Comment.singular_path('456','/issue/123/') # # => /jira/rest/api/2/issue/123/comment/456 def self.singular_path(client, key, prefix = '/') - collection_path(client, prefix) + '/' + key + collection_path(client, prefix) + '/' + key.to_s end # Returns the attribute name of the attribute used for find. @@ -424,7 +424,7 @@ def url end if @attrs['self'] the_url = @attrs['self'] - the_url = the_url.sub(@client.options[:site], '') if @client.options[:site] + the_url = the_url.sub(@client.options[:site].chomp('/'), '') if @client.options[:site] the_url elsif key_value self.class.singular_path(client, key_value.to_s, prefix) diff --git a/lib/jira/client.rb b/lib/jira/client.rb index d9f88b06..d80fa754 100644 --- a/lib/jira/client.rb +++ b/lib/jira/client.rb @@ -6,7 +6,7 @@ module JIRA # This class is the main access point for all JIRA::Resource instances. # # The client must be initialized with an options hash containing - # configuration options. The available options are: + # configuration options. The available options are: # # :site => 'http://localhost:2990', # :context_path => '/jira', @@ -14,19 +14,34 @@ module JIRA # :request_token_path => "/plugins/servlet/oauth/request-token", # :authorize_path => "/plugins/servlet/oauth/authorize", # :access_token_path => "/plugins/servlet/oauth/access-token", + # :private_key => nil, # :private_key_file => "rsakey.pem", # :rest_base_path => "/rest/api/2", # :consumer_key => nil, # :consumer_secret => nil, # :ssl_verify_mode => OpenSSL::SSL::VERIFY_PEER, + # :ssl_version => nil, # :use_ssl => true, # :username => nil, # :password => nil, # :auth_type => :oauth, # :proxy_address => nil, # :proxy_port => nil, + # :proxy_username => nil, + # :proxy_password => nil, + # :use_cookies => nil, # :additional_cookies => nil, - # :default_headers => {} + # :default_headers => {}, + # :use_client_cert => false, + # :read_timeout => nil, + # :max_retries => nil, + # :http_debug => false, + # :shared_secret => nil, + # :cert_path => nil, + # :key_path => nil, + # :ssl_client_cert => nil, + # :ssl_client_key => nil + # :ca_file => nil # # See the JIRA::Base class methods for all of the available methods on these accessor # objects. @@ -45,6 +60,44 @@ class Client def_delegators :@request_client, :init_access_token, :set_access_token, :set_request_token, :request_token, :access_token, :authenticated? + DEFINED_OPTIONS = [ + :site, + :context_path, + :signature_method, + :request_token_path, + :authorize_path, + :access_token_path, + :private_key, + :private_key_file, + :rest_base_path, + :consumer_key, + :consumer_secret, + :ssl_verify_mode, + :ssl_version, + :use_ssl, + :username, + :password, + :auth_type, + :proxy_address, + :proxy_port, + :proxy_username, + :proxy_password, + :use_cookies, + :additional_cookies, + :default_headers, + :use_client_cert, + :read_timeout, + :max_retries, + :http_debug, + :issuer, + :base_url, + :shared_secret, + :cert_path, + :key_path, + :ssl_client_cert, + :ssl_client_key + ].freeze + DEFAULT_OPTIONS = { site: 'http://localhost:2990', context_path: '/jira', @@ -62,11 +115,15 @@ def initialize(options = {}) @options = options @options[:rest_base_path] = @options[:context_path] + @options[:rest_base_path] + unknown_options = options.keys.reject { |o| DEFINED_OPTIONS.include?(o) } + raise ArgumentError, "Unknown option(s) given: #{unknown_options}" unless unknown_options.empty? + if options[:use_client_cert] - raise ArgumentError, 'Options: :cert_path must be set when :use_client_cert is true' unless @options[:cert_path] - raise ArgumentError, 'Options: :key_path must be set when :use_client_cert is true' unless @options[:key_path] - @options[:cert] = OpenSSL::X509::Certificate.new(File.read(@options[:cert_path])) - @options[:key] = OpenSSL::PKey::RSA.new(File.read(@options[:key_path])) + @options[:ssl_client_cert] = OpenSSL::X509::Certificate.new(File.read(@options[:cert_path])) if @options[:cert_path] + @options[:ssl_client_key] = OpenSSL::PKey::RSA.new(File.read(@options[:key_path])) if @options[:key_path] + + raise ArgumentError, 'Options: :cert_path or :ssl_client_cert must be set when :use_client_cert is true' unless @options[:ssl_client_cert] + raise ArgumentError, 'Options: :key_path or :ssl_client_key must be set when :use_client_cert is true' unless @options[:ssl_client_key] end case options[:auth_type] @@ -127,6 +184,10 @@ def Status # :nodoc: JIRA::Resource::StatusFactory.new(self) end + def StatusCategory # :nodoc: + JIRA::Resource::StatusCategoryFactory.new(self) + end + def Resolution # :nodoc: JIRA::Resource::ResolutionFactory.new(self) end @@ -159,6 +220,10 @@ def Board JIRA::Resource::BoardFactory.new(self) end + def BoardConfiguration + JIRA::Resource::BoardConfigurationFactory.new(self) + end + def RapidView JIRA::Resource::RapidViewFactory.new(self) end @@ -167,10 +232,6 @@ def Sprint JIRA::Resource::SprintFactory.new(self) end - def SprintReport - JIRA::Resource::SprintReportFactory.new(self) - end - def ServerInfo JIRA::Resource::ServerInfoFactory.new(self) end @@ -199,12 +260,12 @@ def Issuelinktype JIRA::Resource::IssuelinktypeFactory.new(self) end - def Remotelink - JIRA::Resource::RemotelinkFactory.new(self) + def IssuePickerSuggestions + JIRA::Resource::IssuePickerSuggestionsFactory.new(self) end - def Sprint - JIRA::Resource::SprintFactory.new(self) + def Remotelink + JIRA::Resource::RemotelinkFactory.new(self) end def Agile @@ -230,6 +291,11 @@ def post(path, body = '', headers = {}) request(:post, path, body, merge_default_headers(headers)) end + def post_multipart(path, file, headers = {}) + puts "post multipart: #{path} - [#{file}]" if @http_debug + @request_client.request_multipart(path, file, merge_default_headers(headers)) + end + def put(path, body = '', headers = {}) headers = { 'Content-Type' => 'application/json' }.merge(headers) request(:put, path, body, merge_default_headers(headers)) @@ -242,6 +308,11 @@ def request(http_method, path, body = '', headers = {}) @request_client.request(http_method, path, body, headers) end + # Stops sensitive client information from being displayed in logs + def inspect + "#" + end + protected def merge_default_headers(headers) diff --git a/lib/jira/http_client.rb b/lib/jira/http_client.rb index d99c6f59..6a659322 100644 --- a/lib/jira/http_client.rb +++ b/lib/jira/http_client.rb @@ -6,8 +6,8 @@ module JIRA class HttpClient < RequestClient DEFAULT_OPTIONS = { - username: '', - password: '' + username: nil, + password: nil }.freeze attr_reader :options @@ -18,7 +18,7 @@ def initialize(options) end def make_cookie_auth_request - body = { username: @options[:username], password: @options[:password] }.to_json + body = { username: @options[:username].to_s, password: @options[:password].to_s }.to_json @options.delete(:username) @options.delete(:password) make_request(:post, @options[:context_path] + '/rest/auth/1/session', body, 'Content-Type' => 'application/json') @@ -29,12 +29,15 @@ def make_request(http_method, url, body = '', headers = {}) path = request_path(url) request = Net::HTTP.const_get(http_method.to_s.capitalize).new(path, headers) request.body = body unless body.nil? - add_cookies(request) if options[:use_cookies] - request.basic_auth(@options[:username], @options[:password]) if @options[:username] && @options[:password] - response = basic_auth_http_conn.request(request) - @authenticated = response.is_a? Net::HTTPOK - store_cookies(response) if options[:use_cookies] - response + + execute_request(request) + end + + def make_multipart_request(url, body, headers = {}) + path = request_path(url) + request = Net::HTTP::Post::Multipart.new(path, body, headers) + + execute_request(request) end def basic_auth_http_conn @@ -42,24 +45,27 @@ def basic_auth_http_conn end def http_conn(uri) - if @options[:proxy_address] - http_class = Net::HTTP::Proxy(@options[:proxy_address], @options[:proxy_port] || 80) - else - http_class = Net::HTTP - end - http_conn = http_class.new(uri.host, uri.port) + http_conn = + if @options[:proxy_address] + Net::HTTP.new(uri.host, uri.port, @options[:proxy_address], @options[:proxy_port] || 80, @options[:proxy_username], @options[:proxy_password]) + else + Net::HTTP.new(uri.host, uri.port) + end http_conn.use_ssl = @options[:use_ssl] if @options[:use_client_cert] - http_conn.cert = @options[:cert] - http_conn.key = @options[:key] + http_conn.cert = @options[:ssl_client_cert] + http_conn.key = @options[:ssl_client_key] end http_conn.verify_mode = @options[:ssl_verify_mode] + http_conn.ssl_version = @options[:ssl_version] if @options[:ssl_version] http_conn.read_timeout = @options[:read_timeout] + http_conn.max_retries = @options[:max_retries] if @options[:max_retries] + http_conn.ca_file = @options[:ca_file] if @options[:ca_file] http_conn end def uri - uri = URI.parse(@options[:site]) + URI.parse(@options[:site]) end def authenticated? @@ -68,6 +74,17 @@ def authenticated? private + def execute_request(request) + add_cookies(request) if options[:use_cookies] + request.basic_auth(@options[:username], @options[:password]) if @options[:username] && @options[:password] + + response = basic_auth_http_conn.request(request) + @authenticated = response.is_a? Net::HTTPOK + store_cookies(response) if options[:use_cookies] + + response + end + def request_path(url) parsed_uri = URI(url) diff --git a/lib/jira/http_error.rb b/lib/jira/http_error.rb index e48cada5..9733531f 100644 --- a/lib/jira/http_error.rb +++ b/lib/jira/http_error.rb @@ -1,4 +1,6 @@ require 'forwardable' +require 'active_support/core_ext/object' + module JIRA class HTTPError < StandardError extend Forwardable @@ -8,7 +10,7 @@ class HTTPError < StandardError def initialize(response) @response = response - @message = response.try(:message) || response.try(:body) + @message = response.try(:message).presence || response.try(:body) end end end diff --git a/lib/jira/jwt_client.rb b/lib/jira/jwt_client.rb index 9ae3e50f..843d810a 100644 --- a/lib/jira/jwt_client.rb +++ b/lib/jira/jwt_client.rb @@ -3,30 +3,39 @@ module JIRA class JwtClient < HttpClient def make_request(http_method, url, body = '', headers = {}) - # When a proxy is enabled, Net::HTTP expects that the request path omits the domain name - path = request_path(url) + "?jwt=#{jwt_header(http_method, url)}" + @http_method = http_method + jwt_header = build_jwt_header(url) - request = Net::HTTP.const_get(http_method.to_s.capitalize).new(path, headers) - request.body = body unless body.nil? + super(http_method, url, body, headers.merge(jwt_header)) + end + + def make_multipart_request(url, data, headers = {}) + @http_method = :post + jwt_header = build_jwt_header(url) - response = basic_auth_http_conn.request(request) - @authenticated = response.is_a? Net::HTTPOK - store_cookies(response) if options[:use_cookies] - response + super(url, data, headers.merge(jwt_header)) end private - def jwt_header(http_method, url) + attr_reader :http_method + + def build_jwt_header(url) + jwt = build_jwt(url) + + {'Authorization' => "JWT #{jwt}"} + end + + def build_jwt(url) claim = Atlassian::Jwt.build_claims \ @options[:issuer], url, http_method.to_s, @options[:site], (Time.now - 60).to_i, - (Time.now + (86400)).to_i + (Time.now + 86_400).to_i JWT.encode claim, @options[:shared_secret] end end -end +end \ No newline at end of file diff --git a/lib/jira/oauth_client.rb b/lib/jira/oauth_client.rb index 4a047810..bcbed350 100644 --- a/lib/jira/oauth_client.rb +++ b/lib/jira/oauth_client.rb @@ -38,13 +38,15 @@ def init_oauth_consumer(_options) @options[:request_token_path] = @options[:context_path] + @options[:request_token_path] @options[:authorize_path] = @options[:context_path] + @options[:authorize_path] @options[:access_token_path] = @options[:context_path] + @options[:access_token_path] + # proxy_address does not exist in oauth's gem context but proxy does + @options[:proxy] = @options[:proxy_address] if @options[:proxy_address] OAuth::Consumer.new(@options[:consumer_key], @options[:consumer_secret], @options) end # Returns the current request token if it is set, else it creates # and sets a new token. def request_token(options = {}, *arguments, &block) - @request_token ||= get_request_token(options, *arguments, block) + @request_token ||= get_request_token(options, *arguments, &block) end # Sets the request token from a given token and secret. @@ -72,29 +74,39 @@ def access_token @access_token end - def make_request(http_method, path, body = '', headers = {}) + def make_request(http_method, url, body = '', headers = {}) # When using oauth_2legged we need to add an empty oauth_token parameter to every request. if @options[:auth_type] == :oauth_2legged oauth_params_str = 'oauth_token=' - uri = URI.parse(path) + uri = URI.parse(url) uri.query = if uri.query.to_s == '' oauth_params_str else uri.query + '&' + oauth_params_str end - path = uri.to_s + url = uri.to_s end case http_method when :delete, :get, :head - response = access_token.send http_method, path, headers + response = access_token.send http_method, url, headers when :post, :put - response = access_token.send http_method, path, body, headers + response = access_token.send http_method, url, body, headers end @authenticated = true response end + def make_multipart_request(url, data, headers = {}) + request = Net::HTTP::Post::Multipart.new url, data, headers + + access_token.sign! request + + response = consumer.http.request(request) + @authenticated = true + response + end + def authenticated? @authenticated end diff --git a/lib/jira/request_client.rb b/lib/jira/request_client.rb index de5b875c..d90ae9d6 100644 --- a/lib/jira/request_client.rb +++ b/lib/jira/request_client.rb @@ -1,7 +1,6 @@ require 'oauth' require 'json' require 'net/https' -# require 'pry' module JIRA class RequestClient @@ -11,9 +10,22 @@ class RequestClient def request(*args) response = make_request(*args) - # binding.pry unless response.kind_of?(Net::HTTPSuccess) raise HTTPError, response unless response.is_a?(Net::HTTPSuccess) response end + + def request_multipart(*args) + response = make_multipart_request(*args) + raise HTTPError, response unless response.is_a?(Net::HTTPSuccess) + response + end + + def make_request(*args) + raise NotImplementedError + end + + def make_multipart_request(*args) + raise NotImplementedError + end end -end +end \ No newline at end of file diff --git a/lib/jira/resource/attachment.rb b/lib/jira/resource/attachment.rb index 2f2e5983..9e58b132 100644 --- a/lib/jira/resource/attachment.rb +++ b/lib/jira/resource/attachment.rb @@ -1,4 +1,5 @@ require 'net/http/post/multipart' +require 'open-uri' module JIRA module Resource @@ -19,27 +20,71 @@ def self.meta(client) parse_json(response.body) end - def save!(attrs) - headers = { 'X-Atlassian-Token' => 'nocheck' } - data = { 'file' => UploadIO.new(attrs['file'], 'application/binary', attrs['file']) } + # Opens a file streaming the download of the attachment. + # @example Write file contents to a file. + # File.open('some-filename', 'wb') do |output| + # download_file do |file| + # IO.copy_stream(file, output) + # end + # end + # @example Stream file contents for an HTTP response. + # response.headers[ "Content-Type" ] = "application/octet-stream" + # download_file do |file| + # chunk = file.read(8000) + # while chunk.present? do + # response.stream.write(chunk) + # chunk = file.read(8000) + # end + # end + # response.stream.close + # @param [Hash] headers Any additional headers to call Jira. + # @yield |file| + # @yieldparam [IO] file The IO object streaming the download. + def download_file(headers = {}, &block) + default_headers = client.options[:default_headers] + URI.open(content, default_headers.merge(headers), &block) + end - request = Net::HTTP::Post::Multipart.new url, data, headers - request.basic_auth(client.request_client.options[:username], - client.request_client.options[:password]) + # Downloads the file contents as a string object. + # + # Note that this reads the contents into a ruby string in memory. + # A file might be very large so it is recommend to avoid this unless you are certain about doing so. + # Use the download_file method instead and avoid calling the read method without a limit. + # + # @param [Hash] headers Any additional headers to call Jira. + # @return [String,NilClass] The file contents. + def download_contents(headers = {}) + download_file(headers) do |file| + file.read + end + end - response = client.request_client.basic_auth_http_conn.request(request) + def save!(attrs, path = url) + file = attrs['file'] || attrs[:file] # Keep supporting 'file' parameter as a string for backward compatibility + mime_type = attrs[:mimeType] || 'application/binary' - set_attrs(attrs, false) - unless response.body.nil? || response.body.length < 2 - json = self.class.parse_json(response.body) - attachment = json[0] + headers = { 'X-Atlassian-Token' => 'nocheck' } + data = { 'file' => Multipart::Post::UploadIO.new(file, mime_type, file) } - set_attrs(attachment) - end + response = client.post_multipart(path, data , headers) + + set_attributes(attrs, response) @expanded = false true end + + private + + def set_attributes(attributes, response) + set_attrs(attributes, false) + return if response.body.nil? || response.body.length < 2 + + json = self.class.parse_json(response.body) + attachment = json[0] + + set_attrs(attachment) + end end end end diff --git a/lib/jira/resource/board.rb b/lib/jira/resource/board.rb index 8459560b..d78ec68a 100644 --- a/lib/jira/resource/board.rb +++ b/lib/jira/resource/board.rb @@ -46,6 +46,13 @@ def issues(params = {}) results.map { |issue| client.Issue.build(issue) } end + def configuration(params = {}) + path = path_base(client) + "/board/#{id}/configuration" + response = client.get(url_with_query_params(path, params)) + json = self.class.parse_json(response.body) + client.BoardConfiguration.build(json) + end + # options # - state ~ future, active, closed, you can define multiple states separated by commas, e.g. state=active,closed # - maxResults ~ default: 50 (JIRA API), 1000 (this library) diff --git a/lib/jira/resource/board_configuration.rb b/lib/jira/resource/board_configuration.rb new file mode 100644 index 00000000..fa152f8b --- /dev/null +++ b/lib/jira/resource/board_configuration.rb @@ -0,0 +1,9 @@ +module JIRA + module Resource + class BoardConfigurationFactory < JIRA::BaseFactory # :nodoc: + end + + class BoardConfiguration < JIRA::Base + end + end +end diff --git a/lib/jira/resource/issue.rb b/lib/jira/resource/issue.rb index a4e7a169..2d9e75b8 100644 --- a/lib/jira/resource/issue.rb +++ b/lib/jira/resource/issue.rb @@ -1,6 +1,7 @@ require 'cgi' require 'json' + module JIRA module Resource class IssueFactory < JIRA::BaseFactory # :nodoc: @@ -19,6 +20,8 @@ class Issue < JIRA::Base has_one :status, nested_under: 'fields' + has_one :resolution, nested_under: 'fields' + has_many :transitions has_many :components, nested_under: 'fields' diff --git a/lib/jira/resource/issue_picker_suggestions.rb b/lib/jira/resource/issue_picker_suggestions.rb new file mode 100644 index 00000000..2834c16a --- /dev/null +++ b/lib/jira/resource/issue_picker_suggestions.rb @@ -0,0 +1,24 @@ +module JIRA + module Resource + class IssuePickerSuggestionsFactory < JIRA::BaseFactory # :nodoc: + end + + class IssuePickerSuggestions < JIRA::Base + has_many :sections, class: JIRA::Resource::IssuePickerSuggestionsIssue + + def self.all(client, query = '', options = { current_jql: nil, current_issue_key: nil, current_project_id: nil, show_sub_tasks: nil, show_sub_tasks_parent: nil }) + url = client.options[:rest_base_path] + "/issue/picker?query=#{CGI.escape(query)}" + + url << "¤tJQL=#{CGI.escape(options[:current_jql])}" if options[:current_jql] + url << "¤tIssueKey=#{CGI.escape(options[:current_issue_key])}" if options[:current_issue_key] + url << "¤tProjectId=#{CGI.escape(options[:current_project_id])}" if options[:current_project_id] + url << "&showSubTasks=#{options[:show_sub_tasks]}" if options[:show_sub_tasks] + url << "&showSubTaskParent=#{options[:show_sub_task_parent]}" if options[:show_sub_task_parent] + + response = client.get(url) + json = parse_json(response.body) + client.IssuePickerSuggestions.build(json) + end + end + end +end diff --git a/lib/jira/resource/issue_picker_suggestions_issue.rb b/lib/jira/resource/issue_picker_suggestions_issue.rb new file mode 100644 index 00000000..4d54c90b --- /dev/null +++ b/lib/jira/resource/issue_picker_suggestions_issue.rb @@ -0,0 +1,10 @@ +module JIRA + module Resource + class IssuePickerSuggestionsIssueFactory < JIRA::BaseFactory # :nodoc: + end + + class IssuePickerSuggestionsIssue < JIRA::Base + has_many :issues, class: JIRA::Resource::SuggestedIssue + end + end +end diff --git a/lib/jira/resource/sprint.rb b/lib/jira/resource/sprint.rb index 85c49de5..5664c44c 100644 --- a/lib/jira/resource/sprint.rb +++ b/lib/jira/resource/sprint.rb @@ -5,26 +5,27 @@ class SprintFactory < JIRA::BaseFactory # :nodoc: class Sprint < JIRA::Base def self.find(client, key) - response = client.get("#{client.options[:site]}/rest/agile/1.0/sprint/#{key}") + response = client.get(agile_path(client, key)) json = parse_json(response.body) client.Sprint.build(json) end # get all issues of sprint def issues(options = {}) - jql = 'sprint = ' + id.to_s + jql = "sprint = #{id.to_s}" jql += " and updated >= '#{options[:updated]}'" if options[:updated] Issue.jql(client, jql) end def add_issue(issue) - request_body = { issues: [issue.id] }.to_json - response = client.post(client.options[:site] + "/rest/agile/1.0/sprint/#{id}/issue", request_body) - true + add_issues([issue]) end - def sprint_report - get_sprint_details_attribute('sprint_report') + def add_issues(issues) + issue_ids = issues.map(&:id) + request_body = { issues: issue_ids }.to_json + client.post("#{agile_path}/issue", request_body) + true end def start_date @@ -42,13 +43,14 @@ def complete_date def get_sprint_details_attribute(attribute_name) attribute = instance_variable_get("@#{attribute_name}") return attribute if attribute + get_sprint_details instance_variable_get("@#{attribute_name}") end def get_sprint_details - search_url = client.options[:site] + '/rest/greenhopper/1.0/rapid/charts/sprintreport?rapidViewId=' + - rapidview_id.to_s + '&sprintId=' + id.to_s + search_url = + "#{client.options[:site]}#{client.options[:client_path]}/rest/agile/1.0/sprint/#{id}" begin response = client.get(search_url) rescue StandardError @@ -56,32 +58,19 @@ def get_sprint_details end json = self.class.parse_json(response.body) - @start_date = Date.parse(json['sprint']['startDate']) unless json['sprint']['startDate'] == 'None' - @end_date = Date.parse(json['sprint']['endDate']) unless json['sprint']['endDate'] == 'None' - @completed_date = Date.parse(json['sprint']['completeDate']) unless json['sprint']['completeDate'] == 'None' - @sprint_report = client.SprintReport.build(json['contents']) - end - - def rapidview_id - return @attrs['rapidview_id'] if @attrs['rapidview_id'] - search_url = client.options[:site] + '/secure/GHGoToBoard.jspa?sprintId=' + id.to_s - begin - response = client.get(search_url) - rescue JIRA::HTTPError => error - return unless error.response.instance_of? Net::HTTPFound - rapid_view_match = /rapidView=(\d+)&/.match(error.response['location']) - @attrs['rapidview_id'] = rapid_view_match[1] unless rapid_view_match.nil? - end + @start_date = json['startDate'] && Date.parse(json['startDate']) + @end_date = json['endDate'] && Date.parse(json['endDate']) + @complete_date = json['completeDate'] && Date.parse(json['completeDate']) end def save(attrs = {}, _path = nil) attrs = @attrs if attrs.empty? - super(attrs, agile_url) + super(attrs, agile_path) end def save!(attrs = {}, _path = nil) attrs = @attrs if attrs.empty? - super(attrs, agile_url) + super(attrs, agile_path) end # WORK IN PROGRESS @@ -93,8 +82,12 @@ def complete private - def agile_url - "#{client.options[:site]}/rest/agile/1.0/sprint/#{id}" + def agile_path + self.class.agile_path(client, id) + end + + def self.agile_path(client, key) + "#{client.options[:context_path]}/rest/agile/1.0/sprint/#{key}" end end end diff --git a/lib/jira/resource/sprint_report.rb b/lib/jira/resource/sprint_report.rb deleted file mode 100644 index 8f179229..00000000 --- a/lib/jira/resource/sprint_report.rb +++ /dev/null @@ -1,8 +0,0 @@ -module JIRA - module Resource - class SprintReportFactory < JIRA::BaseFactory # :nodoc: - end - - class SprintReport < JIRA::Base; end - end -end diff --git a/lib/jira/resource/status.rb b/lib/jira/resource/status.rb index 66c8b99b..be53507f 100644 --- a/lib/jira/resource/status.rb +++ b/lib/jira/resource/status.rb @@ -1,8 +1,12 @@ +require_relative 'status_category' + module JIRA module Resource class StatusFactory < JIRA::BaseFactory # :nodoc: end - class Status < JIRA::Base; end + class Status < JIRA::Base + has_one :status_category, class: JIRA::Resource::StatusCategory, attribute_key: 'statusCategory' + end end end diff --git a/lib/jira/resource/status_category.rb b/lib/jira/resource/status_category.rb new file mode 100644 index 00000000..c900308c --- /dev/null +++ b/lib/jira/resource/status_category.rb @@ -0,0 +1,8 @@ +module JIRA + module Resource + class StatusCategoryFactory < JIRA::BaseFactory # :nodoc: + end + + class StatusCategory < JIRA::Base; end + end +end diff --git a/lib/jira/resource/suggested_issue.rb b/lib/jira/resource/suggested_issue.rb new file mode 100644 index 00000000..7fe7d00d --- /dev/null +++ b/lib/jira/resource/suggested_issue.rb @@ -0,0 +1,9 @@ +module JIRA + module Resource + class SuggestedIssueFactory < JIRA::BaseFactory # :nodoc: + end + + class SuggestedIssue < JIRA::Base + end + end +end diff --git a/lib/jira/resource/user.rb b/lib/jira/resource/user.rb index 1529628f..fc2705d3 100644 --- a/lib/jira/resource/user.rb +++ b/lib/jira/resource/user.rb @@ -18,7 +18,7 @@ def self.singular_path(client, key, prefix = '/') # Cannot retrieve more than 1,000 users through the api, please see: https://jira.atlassian.com/browse/JRASERVER-65089 def self.all(client) - response = client.get("/rest/api/2/user/search?username=_&maxResults=#{MAX_RESULTS}") + response = client.get("/rest/api/2/users/search?username=_&maxResults=#{MAX_RESULTS}") all_users = JSON.parse(response.body) all_users.flatten.uniq.map do |user| diff --git a/lib/jira/resource/watcher.rb b/lib/jira/resource/watcher.rb index a8f7778b..be9f16da 100644 --- a/lib/jira/resource/watcher.rb +++ b/lib/jira/resource/watcher.rb @@ -23,6 +23,13 @@ def self.all(client, options = {}) issue.watchers.build(watcher) end end + + def save!(user_id, path = nil) + path ||= new_record? ? url : patched_url + response = client.post(path, user_id.to_json) + true + end + end end end diff --git a/lib/jira/version.rb b/lib/jira/version.rb index 82910e63..afd85d02 100644 --- a/lib/jira/version.rb +++ b/lib/jira/version.rb @@ -1,3 +1,3 @@ module JIRA - VERSION = '1.6.0'.freeze + VERSION = '3.0.0'.freeze end diff --git a/spec/data/files/short.txt b/spec/data/files/short.txt new file mode 100644 index 00000000..97fc24bf --- /dev/null +++ b/spec/data/files/short.txt @@ -0,0 +1 @@ +short text diff --git a/spec/integration/status_category_spec.rb b/spec/integration/status_category_spec.rb new file mode 100644 index 00000000..5453f807 --- /dev/null +++ b/spec/integration/status_category_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe JIRA::Resource::StatusCategory do + with_each_client do |site_url, client| + let(:client) { client } + let(:site_url) { site_url } + + let(:key) { 1 } + + let(:expected_attributes) do + JSON.parse(File.read('spec/mock_responses/statuscategory/1.json')) + end + + let(:expected_collection_length) { 4 } + + it_should_behave_like 'a resource' + it_should_behave_like 'a resource with a collection GET endpoint' + it_should_behave_like 'a resource with a singular GET endpoint' + end +end diff --git a/spec/integration/status_spec.rb b/spec/integration/status_spec.rb index 9af17335..3a74e764 100644 --- a/spec/integration/status_spec.rb +++ b/spec/integration/status_spec.rb @@ -8,11 +8,7 @@ let(:key) { '1' } let(:expected_attributes) do - { - 'self' => 'http://localhost:2990/jira/rest/api/2/status/1', - 'id' => key, - 'name' => 'Open' - } + JSON.parse(File.read('spec/mock_responses/status/1.json')) end let(:expected_collection_length) { 5 } diff --git a/spec/integration/user_spec.rb b/spec/integration/user_spec.rb index 930cbf7f..efe66e1f 100644 --- a/spec/integration/user_spec.rb +++ b/spec/integration/user_spec.rb @@ -21,18 +21,18 @@ describe '#all' do let(:client) do client = double(options: { rest_base_path: '/jira/rest/api/2' }) - allow(client).to receive(:get).with('/rest/api/2/user/search?username=_&maxResults=1000').and_return(JIRA::Resource::UserFactory.new(client)) + allow(client).to receive(:get).with('/rest/api/2/users/search?username=_&maxResults=1000').and_return(JIRA::Resource::UserFactory.new(client)) client end before do allow(client).to receive(:get) - .with('/rest/api/2/user/search?username=_&maxResults=1000') { OpenStruct.new(body: '["User1"]') } + .with('/rest/api/2/users/search?username=_&maxResults=1000') { OpenStruct.new(body: '["User1"]') } allow(client).to receive_message_chain(:User, :build).with('users') { [] } end it 'gets users with maxResults of 1000' do - expect(client).to receive(:get).with('/rest/api/2/user/search?username=_&maxResults=1000') + expect(client).to receive(:get).with('/rest/api/2/users/search?username=_&maxResults=1000') expect(client).to receive_message_chain(:User, :build).with('User1') JIRA::Resource::User.all(client) end diff --git a/spec/integration/watcher_spec.rb b/spec/integration/watcher_spec.rb index 20b8b82a..3edb5813 100644 --- a/spec/integration/watcher_spec.rb +++ b/spec/integration/watcher_spec.rb @@ -33,21 +33,30 @@ end describe 'watchers' do - it 'should returns all the watchers' do - stub_request(:get, - site_url + '/jira/rest/api/2/issue/10002') + before(:each) do + stub_request(:get, site_url + '/jira/rest/api/2/issue/10002') .to_return(status: 200, body: get_mock_response('issue/10002.json')) - stub_request(:get, - site_url + '/jira/rest/api/2/issue/10002/watchers') + stub_request(:get, site_url + '/jira/rest/api/2/issue/10002/watchers') .to_return(status: 200, body: get_mock_response('issue/10002/watchers.json')) + stub_request(:post, site_url + '/jira/rest/api/2/issue/10002/watchers') + .to_return(status: 204, body: nil) + end + + it 'should returns all the watchers' do issue = client.Issue.find('10002') watchers = client.Watcher.all(options = { issue: issue }) expect(watchers.length).to eq(1) end + + it 'should add a watcher' do + issue = client.Issue.find('10002') + watcher = JIRA::Resource::Watcher.new(client, issue: issue) + user_id = "tester" + watcher.save!(user_id) + end end - it_should_behave_like 'a resource' end end diff --git a/spec/jira/base_spec.rb b/spec/jira/base_spec.rb index 4876ca67..73b12783 100644 --- a/spec/jira/base_spec.rb +++ b/spec/jira/base_spec.rb @@ -311,7 +311,7 @@ class JIRA::Resource::HasManyExample < JIRA::Base # :nodoc: response = instance_double('Response', body: '{"errorMessages":["blah"]}', status: 400) allow(subject).to receive(:new_record?) { false } expect(client).to receive(:put).with('/foo/bar', '{"invalid_field":"foobar"}').and_raise(JIRA::HTTPError.new(response)) - expect(-> { subject.save!('invalid_field' => 'foobar') }).to raise_error(JIRA::HTTPError) + expect{ subject.save!('invalid_field' => 'foobar') }.to raise_error(JIRA::HTTPError) end end @@ -379,6 +379,18 @@ class JIRA::Resource::HasManyExample < JIRA::Base # :nodoc: expect(subject.url).to eq('http://foo/bar') end + it 'returns path as the URL if set and site options is specified' do + allow(client).to receive(:options) { { site: 'http://foo' } } + attrs['self'] = 'http://foo/bar' + expect(subject.url).to eq('/bar') + end + + it 'returns path as the URL if set and site options is specified and ends with a slash' do + allow(client).to receive(:options) { { site: 'http://foo/' } } + attrs['self'] = 'http://foo/bar' + expect(subject.url).to eq('/bar') + end + it 'generates the URL from id if self not set' do attrs['self'] = nil attrs['id'] = '98765' @@ -426,7 +438,7 @@ class JIRA::Resource::HasManyExample < JIRA::Base # :nodoc: h = { 'key' => subject } h_attrs = { 'key' => subject.attrs } - expect(h.to_json).to eq(h_attrs.to_json) + expect(h['key'].to_json).to eq(h_attrs['key'].to_json) end describe 'extract attrs from response' do @@ -577,9 +589,9 @@ class JIRA::Resource::BelongsToExample < JIRA::Base end it 'raises an exception when initialized without a belongs_to instance' do - expect(lambda { + expect{ JIRA::Resource::BelongsToExample.new(client, attrs: { 'id' => '123' }) - }).to raise_exception(ArgumentError, 'Required option :deadbeef missing') + }.to raise_exception(ArgumentError, 'Required option :deadbeef missing') end it 'returns the right url' do diff --git a/spec/jira/client_spec.rb b/spec/jira/client_spec.rb index 51a16c20..dfd04948 100644 --- a/spec/jira/client_spec.rb +++ b/spec/jira/client_spec.rb @@ -59,6 +59,19 @@ expect(subject.Project.find('123')).to eq(find_result) end end + + describe 'SSL client options' do + context 'without certificate and key' do + let(:options) { { use_client_cert: true } } + subject { JIRA::Client.new(options) } + + it 'raises an ArgumentError' do + expect { subject }.to raise_exception(ArgumentError, 'Options: :cert_path or :ssl_client_cert must be set when :use_client_cert is true') + options[:ssl_client_cert] = '' + expect { subject }.to raise_exception(ArgumentError, 'Options: :key_path or :ssl_client_key must be set when :use_client_cert is true') + end + end + end end RSpec.shared_examples 'HttpClient tests' do @@ -127,10 +140,12 @@ subject { JIRA::Client.new(username: 'foo', password: 'bar', auth_type: :basic) } before(:each) do - stub_request(:get, 'https://foo:bar@localhost:2990/jira/rest/api/2/project') + stub_request(:get, 'https://localhost:2990/jira/rest/api/2/project') + .with(headers: { 'Authorization' => "Basic #{Base64.strict_encode64('foo:bar').chomp}" }) .to_return(status: 200, body: '[]', headers: {}) - stub_request(:get, 'https://foo:badpassword@localhost:2990/jira/rest/api/2/project') + stub_request(:get, 'https://localhost:2990/jira/rest/api/2/project') + .with(headers: { 'Authorization' => "Basic #{Base64.strict_encode64('foo:badpassword').chomp}" }) .to_return(status: 401, headers: {}) end @@ -144,17 +159,17 @@ expect(subject.options[:password]).to eq('bar') end + it 'only returns a true for #authenticated? once we have requested some data' do + expect(subject.authenticated?).to be_nil + expect(subject.Project.all).to be_empty + expect(subject.authenticated?).to be_truthy + end + it 'fails with wrong user name and password' do bad_login = JIRA::Client.new(username: 'foo', password: 'badpassword', auth_type: :basic) expect(bad_login.authenticated?).to be_falsey expect { bad_login.Project.all }.to raise_error JIRA::HTTPError end - - it 'only returns a true for #authenticated? once we have requested some data' do - expect(subject.authenticated?).to be_falsey - expect(subject.Project.all).to be_empty - expect(subject.authenticated?).to be_truthy - end end context 'with cookie authentication' do @@ -219,7 +234,7 @@ before(:each) do stub_request(:get, 'https://localhost:2990/jira/rest/api/2/project') - .with(query: hash_including(:jwt)) + .with(headers: {"Authorization" => /JWT .+/}) .to_return(status: 200, body: '[]', headers: {}) end @@ -235,7 +250,7 @@ context 'with a incorrect jwt key' do before do stub_request(:get, 'https://localhost:2990/jira/rest/api/2/project') - .with(query: hash_including(:jwt)) + .with(headers: {"Authorization" => /JWT .+/}) .to_return(status: 401, body: '[]', headers: {}) end @@ -266,4 +281,13 @@ include_examples 'OAuth Common Tests' end + + context 'with unknown options' do + let(:options) { { 'username' => 'foo', 'password' => 'bar', auth_type: :basic } } + subject { JIRA::Client.new(options) } + + it 'raises an ArgumentError' do + expect { subject }.to raise_exception(ArgumentError, 'Unknown option(s) given: ["username", "password"]') + end + end end diff --git a/spec/jira/http_client_spec.rb b/spec/jira/http_client_spec.rb index 4c81150e..9a0a96da 100644 --- a/spec/jira/http_client_spec.rb +++ b/spec/jira/http_client_spec.rb @@ -2,12 +2,22 @@ describe JIRA::HttpClient do let(:basic_client) do - options = JIRA::Client::DEFAULT_OPTIONS.merge(JIRA::HttpClient::DEFAULT_OPTIONS) + options = JIRA::Client::DEFAULT_OPTIONS + .merge(JIRA::HttpClient::DEFAULT_OPTIONS) + .merge(basic_auth_credentials) JIRA::HttpClient.new(options) end let(:basic_cookie_client) do - options = JIRA::Client::DEFAULT_OPTIONS.merge(JIRA::HttpClient::DEFAULT_OPTIONS).merge(use_cookies: true) + options = JIRA::Client::DEFAULT_OPTIONS + .merge(JIRA::HttpClient::DEFAULT_OPTIONS) + .merge(use_cookies: true) + .merge(basic_auth_credentials) + JIRA::HttpClient.new(options) + end + + let(:custom_ssl_version_client) do + options = JIRA::Client::DEFAULT_OPTIONS.merge(JIRA::HttpClient::DEFAULT_OPTIONS).merge(ssl_version: :TLSv1_2) JIRA::HttpClient.new(options) end @@ -20,10 +30,13 @@ end let(:basic_cookie_client_with_additional_cookies) do - options = JIRA::Client::DEFAULT_OPTIONS.merge(JIRA::HttpClient::DEFAULT_OPTIONS).merge( - use_cookies: true, - additional_cookies: ['sessionToken=abc123', 'internal=true'] - ) + options = JIRA::Client::DEFAULT_OPTIONS + .merge(JIRA::HttpClient::DEFAULT_OPTIONS) + .merge( + use_cookies: true, + additional_cookies: ['sessionToken=abc123', 'internal=true'] + ) + .merge(basic_auth_credentials) JIRA::HttpClient.new(options) end @@ -36,6 +49,33 @@ JIRA::HttpClient.new(options) end + let(:basic_client_with_no_auth_credentials) do + options = JIRA::Client::DEFAULT_OPTIONS + .merge(JIRA::HttpClient::DEFAULT_OPTIONS) + JIRA::HttpClient.new(options) + end + + let(:basic_auth_credentials) do + { username: 'donaldduck', password: 'supersecret' } + end + + let(:proxy_client) do + options = JIRA::Client::DEFAULT_OPTIONS.merge(JIRA::HttpClient::DEFAULT_OPTIONS).merge( + proxy_address: 'proxyAddress', + proxy_port: 42, + proxy_username: 'proxyUsername', + proxy_password: 'proxyPassword' + ) + JIRA::HttpClient.new(options) + end + + let(:basic_client_with_max_retries) do + options = JIRA::Client::DEFAULT_OPTIONS.merge(JIRA::HttpClient::DEFAULT_OPTIONS).merge( + max_retries: 2 + ) + JIRA::HttpClient.new(options) + end + let(:response) do response = double('response') allow(response).to receive(:kind_of?).with(Net::HTTPSuccess).and_return(true) @@ -48,6 +88,37 @@ response end + context 'simple client' do + let(:client) do + options_local = JIRA::Client::DEFAULT_OPTIONS.merge(JIRA::HttpClient::DEFAULT_OPTIONS).merge( + proxy_address: 'proxyAddress', + proxy_port: 42, + proxy_username: 'proxyUsername', + proxy_password: 'proxyPassword' + ) + JIRA::HttpClient.new(options_local) + end + + describe 'HttpClient#basic_auth_http_conn' do + subject(:http_conn) { basic_client.basic_auth_http_conn } + + it 'creates an instance of Net:HTTP for a basic auth client' do + + expect(http_conn.class).to eq(Net::HTTP) + end + + it 'the connection created has no proxy' do + + http_conn + + expect(http_conn.proxy_address).to be_nil + expect(http_conn.proxy_port).to be_nil + expect(http_conn.proxy_user).to be_nil + expect(http_conn.proxy_pass).to be_nil + end + end + end + it 'creates an instance of Net:HTTP for a basic auth client' do expect(basic_client.basic_auth_http_conn.class).to eq(Net::HTTP) end @@ -159,6 +230,19 @@ basic_client.make_request(:get, 'http://mydomain.com/foo', body, headers) end + it 'does not try to use basic auth if the credentials are not set' do + body = nil + headers = double + basic_auth_http_conn = double + http_request = double + expect(Net::HTTP::Get).to receive(:new).with('/foo', headers).and_return(http_request) + + expect(basic_auth_http_conn).to receive(:request).with(http_request).and_return(response) + expect(http_request).not_to receive(:basic_auth) + allow(basic_client_with_no_auth_credentials).to receive(:basic_auth_http_conn).and_return(basic_auth_http_conn) + basic_client_with_no_auth_credentials.make_request(:get, '/foo', body, headers) + end + it 'returns a URI' do uri = URI.parse(basic_client.options[:site]) expect(basic_client.uri).to eq(uri) @@ -178,6 +262,61 @@ expect(basic_client.http_conn(uri)).to eq(http_conn) end + it 'sets the SSL version when one is provided' do + http_conn = double + uri = double + host = double + port = double + expect(uri).to receive(:host).and_return(host) + expect(uri).to receive(:port).and_return(port) + expect(Net::HTTP).to receive(:new).with(host, port).and_return(http_conn) + expect(http_conn).to receive(:use_ssl=).with(basic_client.options[:use_ssl]).and_return(http_conn) + expect(http_conn).to receive(:verify_mode=).with(basic_client.options[:ssl_verify_mode]).and_return(http_conn) + expect(http_conn).to receive(:ssl_version=).with(custom_ssl_version_client.options[:ssl_version]).and_return(http_conn) + expect(http_conn).to receive(:read_timeout=).with(basic_client.options[:read_timeout]).and_return(http_conn) + expect(custom_ssl_version_client.http_conn(uri)).to eq(http_conn) + end + + it 'sets up a non-proxied http connection by default' do + uri = double + host = double + port = double + + expect(uri).to receive(:host).and_return(host) + expect(uri).to receive(:port).and_return(port) + + proxy_configuration = basic_client.http_conn(uri).class + expect(proxy_configuration.proxy_address).to be_nil + expect(proxy_configuration.proxy_port).to be_nil + expect(proxy_configuration.proxy_user).to be_nil + expect(proxy_configuration.proxy_pass).to be_nil + end + + context 'client has proxy settings' do + let(:proxy_client) do + options_local = JIRA::Client::DEFAULT_OPTIONS.merge(JIRA::HttpClient::DEFAULT_OPTIONS).merge( + proxy_address: 'proxyAddress', + proxy_port: 42, + proxy_username: 'proxyUsername', + proxy_password: 'proxyPassword' + ) + JIRA::HttpClient.new(options_local) + end + subject(:proxy_conn) { proxy_client.basic_auth_http_conn } + + describe 'HttpClient#basic_auth_http_conn' do + it 'creates a Net:HTTP instance for a basic auth client setting up a proxied http connection' do + + expect(proxy_conn.class).to eq(Net::HTTP) + + expect(proxy_conn.proxy_address).to eq(proxy_client.options[:proxy_address]) + expect(proxy_conn.proxy_port).to eq(proxy_client.options[:proxy_port]) + expect(proxy_conn.proxy_user).to eq(proxy_client.options[:proxy_username]) + expect(proxy_conn.proxy_pass).to eq(proxy_client.options[:proxy_password]) + end + end + end + it 'can use client certificates' do http_conn = double uri = double @@ -189,11 +328,31 @@ expect(http_conn).to receive(:use_ssl=).with(basic_client.options[:use_ssl]) expect(http_conn).to receive(:verify_mode=).with(basic_client.options[:ssl_verify_mode]) expect(http_conn).to receive(:read_timeout=).with(basic_client.options[:read_timeout]) - expect(http_conn).to receive(:cert=).with(basic_client_cert_client.options[:cert]) - expect(http_conn).to receive(:key=).with(basic_client_cert_client.options[:key]) + expect(http_conn).to receive(:cert=).with(basic_client_cert_client.options[:ssl_client_cert]) + expect(http_conn).to receive(:key=).with(basic_client_cert_client.options[:ssl_client_key]) expect(basic_client_cert_client.http_conn(uri)).to eq(http_conn) end + it 'can use a certificate authority file' do + client = JIRA::HttpClient.new(JIRA::Client::DEFAULT_OPTIONS.merge(ca_file: '/opt/custom.ca.pem')) + expect(client.http_conn(client.uri).ca_file).to eql('/opt/custom.ca.pem') + end + + it 'allows overriding max_retries' do + http_conn = double + uri = double + host = double + port = double + expect(uri).to receive(:host).and_return(host) + expect(uri).to receive(:port).and_return(port) + expect(Net::HTTP).to receive(:new).with(host, port).and_return(http_conn) + expect(http_conn).to receive(:use_ssl=).with(basic_client.options[:use_ssl]).and_return(http_conn) + expect(http_conn).to receive(:verify_mode=).with(basic_client.options[:ssl_verify_mode]).and_return(http_conn) + expect(http_conn).to receive(:read_timeout=).with(basic_client.options[:read_timeout]).and_return(http_conn) + expect(http_conn).to receive(:max_retries=).with(basic_client_with_max_retries.options[:max_retries]).and_return(http_conn) + expect(basic_client_with_max_retries.http_conn(uri)).to eq(http_conn) + end + it 'returns a http connection' do http_conn = double uri = double @@ -201,4 +360,37 @@ expect(basic_client).to receive(:http_conn).and_return(http_conn) expect(basic_client.basic_auth_http_conn).to eq(http_conn) end + + describe '#make_multipart_request' do + subject do + basic_client.make_multipart_request(path, data, headers) + end + + let(:path) { '/foo' } + let(:data) { {} } + let(:headers) { { 'X-Atlassian-Token' => 'no-check' } } + let(:basic_auth_http_conn) { double } + let(:request) { double('Http Request', path: path) } + let(:response) { double('response') } + + before do + allow(request).to receive(:basic_auth) + allow(Net::HTTP::Post::Multipart).to receive(:new).with(path, data, headers).and_return(request) + allow(basic_client).to receive(:basic_auth_http_conn).and_return(basic_auth_http_conn) + allow(basic_auth_http_conn).to receive(:request).with(request).and_return(response) + end + + it 'performs a basic http client request' do + expect(request).to receive(:basic_auth).with(basic_client.options[:username], basic_client.options[:password]).and_return(request) + + subject + end + + it 'makes a correct HTTP request' do + expect(basic_auth_http_conn).to receive(:request).with(request).and_return(response) + expect(response).to receive(:is_a?).with(Net::HTTPOK) + + subject + end + end end diff --git a/spec/jira/oauth_client_spec.rb b/spec/jira/oauth_client_spec.rb index 82355b85..165443e8 100644 --- a/spec/jira/oauth_client_spec.rb +++ b/spec/jira/oauth_client_spec.rb @@ -35,6 +35,26 @@ expect(oauth_client.get_request_token).to eq(request_token) end + it 'could pre-process the response body in a block' do + response = Net::HTTPSuccess.new(1.0, '200', 'OK') + allow_any_instance_of(OAuth::Consumer).to receive(:request).and_return(response) + allow(response).to receive(:body).and_return('&oauth_token=token&oauth_token_secret=secret&password=top_secret') + + result = oauth_client.request_token do |response_body| + CGI.parse(response_body).each_with_object({}) do |(k, v), h| + next if k == 'password' + + h[k.strip.to_sym] = v.first + end + end + + expect(result).to be_an_instance_of(OAuth::RequestToken) + expect(result.consumer).to eql(oauth_client.consumer) + expect(result.params[:oauth_token]).to eql('token') + expect(result.params[:oauth_token_secret]).to eql('secret') + expect(result.params[:password]).to be_falsey + end + it 'allows setting the request token' do token = double expect(OAuth::RequestToken).to receive(:new).with(oauth_client.consumer, 'foo', 'bar').and_return(token) @@ -58,7 +78,7 @@ request_token = OAuth::RequestToken.new(oauth_client.consumer) allow(oauth_client).to receive(:get_request_token).and_return(request_token) mock_access_token = double - expect(request_token).to receive(:get_access_token).with(oauth_verifier: 'abc123').and_return(mock_access_token) + expect(request_token).to receive(:get_access_token).with({ oauth_verifier: 'abc123' }).and_return(mock_access_token) oauth_client.init_access_token(oauth_verifier: 'abc123') expect(oauth_client.access_token).to eq(mock_access_token) end @@ -82,28 +102,45 @@ end describe 'http' do + let(:headers) { double } + let(:access_token) { double } + let(:body) { nil } + + before do + allow(oauth_client).to receive(:access_token).and_return(access_token) + end + it 'responds to the http methods' do - headers = double - mock_access_token = double - allow(oauth_client).to receive(:access_token).and_return(mock_access_token) %i[delete get head].each do |method| - expect(mock_access_token).to receive(method).with('/path', headers).and_return(response) + expect(access_token).to receive(method).with('/path', headers).and_return(response) oauth_client.make_request(method, '/path', '', headers) end %i[post put].each do |method| - expect(mock_access_token).to receive(method).with('/path', '', headers).and_return(response) + expect(access_token).to receive(method).with('/path', '', headers).and_return(response) oauth_client.make_request(method, '/path', '', headers) end end it 'performs a request' do - body = nil - headers = double - access_token = double expect(access_token).to receive(:send).with(:get, '/foo', headers).and_return(response) - allow(oauth_client).to receive(:access_token).and_return(access_token) + + oauth_client.request(:get, '/foo', body, headers) end + + context 'for a multipart request' do + subject { oauth_client.make_multipart_request('/path', data, headers) } + + let(:data) { {} } + let(:headers) { {} } + + it 'signs the access_token and performs the request' do + expect(access_token).to receive(:sign!).with(an_instance_of(Net::HTTP::Post::Multipart)) + expect(oauth_client.consumer).to receive_message_chain(:http, :request).with(an_instance_of(Net::HTTP::Post::Multipart)) + + subject + end + end end describe 'auth type is oauth_2legged' do diff --git a/spec/jira/request_client_spec.rb b/spec/jira/request_client_spec.rb index 5453e216..1de1fcee 100644 --- a/spec/jira/request_client_spec.rb +++ b/spec/jira/request_client_spec.rb @@ -1,14 +1,41 @@ require 'spec_helper' describe JIRA::RequestClient do - it 'raises an exception for non success responses' do - response = double - allow(response).to receive(:kind_of?).with(Net::HTTPSuccess).and_return(false) - rc = JIRA::RequestClient.new - expect(rc).to receive(:make_request).with(:get, '/foo', '', {}).and_return(response) - - expect do - rc.request(:get, '/foo', '', {}) - end.to raise_exception(JIRA::HTTPError) + let(:request_client) { JIRA::RequestClient.new } + + describe '#request' do + subject(:request) { request_client.request(:get, '/foo', '', {}) } + + context 'when doing a request fails' do + let(:response) { double } + + before do + allow(response).to receive(:kind_of?).with(Net::HTTPSuccess).and_return(false) + allow(request_client).to receive(:make_request).with(:get, '/foo', '', {}).and_return(response) + end + + it 'raises an exception' do + expect{ subject }.to raise_exception(JIRA::HTTPError) + end + end + end + + describe '#request_multipart' do + subject(:request) { request_client.request_multipart('/foo', data, {}) } + + let(:data) { double } + + context 'when doing a request fails' do + let(:response) { double } + + before do + allow(response).to receive(:kind_of?).with(Net::HTTPSuccess).and_return(false) + allow(request_client).to receive(:make_multipart_request).with('/foo', data, {}).and_return(response) + end + + it 'raises an exception' do + expect{ subject }.to raise_exception(JIRA::HTTPError) + end + end end -end +end \ No newline at end of file diff --git a/spec/jira/resource/attachment_spec.rb b/spec/jira/resource/attachment_spec.rb index a8526c56..13cf249d 100644 --- a/spec/jira/resource/attachment_spec.rb +++ b/spec/jira/resource/attachment_spec.rb @@ -1,6 +1,14 @@ require 'spec_helper' describe JIRA::Resource::Attachment do + subject(:attachment) do + JIRA::Resource::Attachment.new( + client, + issue: JIRA::Resource::Issue.new(client), + attrs: { 'author' => { 'foo' => 'bar' } } + ) + end + let(:client) do double( 'client', @@ -17,65 +25,190 @@ end describe 'relationships' do - subject do - JIRA::Resource::Attachment.new(client, - issue: JIRA::Resource::Issue.new(client), - attrs: { 'author' => { 'foo' => 'bar' } }) + it 'has an author' do + expect(subject).to have_one(:author, JIRA::Resource::User) end - it 'has the correct relationships' do - expect(subject).to have_one(:author, JIRA::Resource::User) + it 'has the correct author name' do expect(subject.author.foo).to eq('bar') end end - describe '#meta' do + describe '.meta' do + subject { JIRA::Resource::Attachment.meta(client) } + let(:response) do double( - 'response', - body: '{"enabled":true,"uploadLimit":10485760}' + 'response', + body: '{"enabled":true,"uploadLimit":10485760}' ) end it 'returns meta information about attachment upload' do expect(client).to receive(:get).with('/jira/rest/api/2/attachment/meta').and_return(response) - JIRA::Resource::Attachment.meta(client) + + subject + end + + context 'the factory delegates correctly' do + subject { JIRA::Resource::AttachmentFactory.new(client) } + + it 'delegates #meta to to target class' do + expect(subject).to respond_to(:meta) + end end + end + + context 'there is an attachment on an issue' do + let(:client) do + JIRA::Client.new(username: 'username', password: 'password', auth_type: :basic, use_ssl: false ) + end + let(:attachment_file_contents) { 'file contents' } + let(:file_target) { double(read: :attachment_file_contents) } + let(:attachment_url) { "https:jirahost/secure/attachment/32323/myfile.txt" } + subject(:attachment) do + JIRA::Resource::Attachment.new( + client, + issue: JIRA::Resource::Issue.new(client), + attrs: { 'author' => { 'foo' => 'bar' }, 'content' => attachment_url } + ) + end + + describe '.download_file' do + it 'passes file object to block' do + expect(URI).to receive(:open).with(attachment_url, anything).and_yield(file_target) + + attachment.download_file do |file| + expect(file).to eq(file_target) + end + + end + end + + describe '.download_contents' do + it 'downloads the file contents as a string' do + expect(URI).to receive(:open).with(attachment_url, anything).and_return(attachment_file_contents) - subject { JIRA::Resource::AttachmentFactory.new(client) } + result_str = attachment.download_contents - it 'delegates #meta to to target class' do - expect(subject).to respond_to(:meta) + expect(result_str).to eq(attachment_file_contents) + end end end - describe '#save!' do - it 'successfully update the attachment' do - basic_auth_http_conn = double - response = double( + context 'when there is a local file' do + let(:file_name) { 'short.txt' } + let(:file_size) { 11 } + let(:file_mime_type) { 'text/plain' } + let(:path_to_file) { "./spec/data/files/#{file_name}" } + let(:response) do + double( body: [ { "id": 10_001, "self": 'http://www.example.com/jira/rest/api/2.0/attachments/10000', - "filename": 'picture.jpg', + "filename": file_name, "created": '2017-07-19T12:23:06.572+0000', - "size": 23_123, - "mimeType": 'image/jpeg' + "size": file_size, + "mimeType": file_mime_type } ].to_json ) + end + let(:issue) { JIRA::Resource::Issue.new(client) } + + describe '#save' do + subject { attachment.save('file' => path_to_file) } + + before do + allow(client).to receive(:post_multipart).and_return(response) + end + + it 'successfully update the attachment' do + subject + + expect(attachment.filename).to eq file_name + expect(attachment.mimeType).to eq file_mime_type + expect(attachment.size).to eq file_size + end + context 'when using custom client headers' do + subject(:bearer_attachment) do + JIRA::Resource::Attachment.new( + bearer_client, + issue: JIRA::Resource::Issue.new(bearer_client), + attrs: { 'author' => { 'foo' => 'bar' } } + ) + end + let(:default_headers_given) { { 'authorization' => "Bearer 83CF8B609DE60036A8277BD0E96135751BBC07EB234256D4B65B893360651BF2" } } + let(:bearer_client) do + JIRA::Client.new(username: 'username', password: 'password', auth_type: :basic, use_ssl: false, + default_headers: default_headers_given ) + end + let(:merged_headers) do + {"Accept"=>"application/json", "X-Atlassian-Token"=>"nocheck"}.merge(default_headers_given) + end + + it 'passes the custom headers' do + expect(bearer_client.request_client).to receive(:request_multipart).with(anything, anything, merged_headers).and_return(response) + + bearer_attachment.save('file' => path_to_file) + + end + end + + end + + describe '#save!' do + subject { attachment.save!('file' => path_to_file) } + + before do + allow(client).to receive(:post_multipart).and_return(response) + end + + it 'successfully update the attachment' do + subject + + expect(attachment.filename).to eq file_name + expect(attachment.mimeType).to eq file_mime_type + expect(attachment.size).to eq file_size + end + + context 'when passing in a symbol as file key' do + subject { attachment.save!(file: path_to_file) } + + it 'successfully update the attachment' do + subject + + expect(attachment.filename).to eq file_name + expect(attachment.mimeType).to eq file_mime_type + expect(attachment.size).to eq file_size + end + end + + context 'when using custom client headers' do + subject(:bearer_attachment) do + JIRA::Resource::Attachment.new( + bearer_client, + issue: JIRA::Resource::Issue.new(bearer_client), + attrs: { 'author' => { 'foo' => 'bar' } } + ) + end + let(:default_headers_given) { { 'authorization' => "Bearer 83CF8B609DE60036A8277BD0E96135751BBC07EB234256D4B65B893360651BF2" } } + let(:bearer_client) do + JIRA::Client.new(username: 'username', password: 'password', auth_type: :basic, use_ssl: false, + default_headers: default_headers_given ) + end + let(:merged_headers) do + {"Accept"=>"application/json", "X-Atlassian-Token"=>"nocheck"}.merge(default_headers_given) + end - allow(client.request_client).to receive(:basic_auth_http_conn).and_return(basic_auth_http_conn) - allow(basic_auth_http_conn).to receive(:request).and_return(response) + it 'passes the custom headers' do + expect(bearer_client.request_client).to receive(:request_multipart).with(anything, anything, merged_headers).and_return(response) - issue = JIRA::Resource::Issue.new(client) - path_to_file = './spec/mock_responses/issue.json' - attachment = JIRA::Resource::Attachment.new(client, issue: issue) - attachment.save!('file' => path_to_file) + bearer_attachment.save!('file' => path_to_file) - expect(attachment.filename).to eq 'picture.jpg' - expect(attachment.mimeType).to eq 'image/jpeg' - expect(attachment.size).to eq 23_123 + end + end end end end diff --git a/spec/jira/resource/board_spec.rb b/spec/jira/resource/board_spec.rb index eaefe655..288889e6 100644 --- a/spec/jira/resource/board_spec.rb +++ b/spec/jira/resource/board_spec.rb @@ -172,4 +172,53 @@ class JIRAResourceDelegation < SimpleDelegator # :nodoc: expect(client).to receive(:Sprint).twice.and_return(JIRA::Resource::SprintFactory.new(client)) expect(board.sprints.size).to be(2) end + + it 'should get board configuration for a board' do + response = double + + api_json = <<-eos + { + "id":1, + "name":"My Board", + "type":"kanban", + "self":"https://mycompany.atlassian.net/rest/agile/1.0/board/1/configuration", + "location":{ + "type":"project", + "key":"MYPROJ", + "id":"10000", + "self":"https://mycompany.atlassian.net/rest/api/2/project/10000", + "name":"My Project" + }, + "filter":{ + "id":"10000", + "self":"https://mycompany.atlassian.net/rest/api/2/filter/10000" + }, + "subQuery":{ + "query":"resolution = EMPTY OR resolution != EMPTY AND resolutiondate >= -5d" + }, + "columnConfig":{ + "columns":[ + { + "name":"Backlog", + "statuses":[ + { + "id":"10000", + "self":"https://mycompany.atlassian.net/rest/api/2/status/10000" + } + ] + } + ], + "constraintType":"issueCount" + }, + "ranking":{ + "rankCustomFieldId":10011 + } + } + eos + allow(response).to receive(:body).and_return(api_json) + allow(board).to receive(:id).and_return(84) + expect(client).to receive(:get).with('/rest/agile/1.0/board/84/configuration').and_return(response) + expect(client).to receive(:BoardConfiguration).and_return(JIRA::Resource::BoardConfigurationFactory.new(client)) + expect(board.configuration).not_to be(nil) + end end diff --git a/spec/jira/resource/issue_picker_suggestions_spec.rb b/spec/jira/resource/issue_picker_suggestions_spec.rb new file mode 100644 index 00000000..6cbc3512 --- /dev/null +++ b/spec/jira/resource/issue_picker_suggestions_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +describe JIRA::Resource::IssuePickerSuggestions do + let(:client) do + double('client', options: { + rest_base_path: '/jira/rest/api/2' + }) + end + + describe 'relationships' do + subject do + JIRA::Resource::IssuePickerSuggestions.new(client, attrs: { + 'sections' => [{ 'id' => 'hs'}, { 'id' => 'cs' }] + }) + end + + it 'has the correct relationships' do + expect(subject).to have_many(:sections, JIRA::Resource::IssuePickerSuggestionsIssue) + expect(subject.sections.length).to eq(2) + end + end + + describe '#all' do + let(:response) { double } + let(:issue_picker_suggestions) { double } + + before do + allow(response).to receive(:body).and_return('{"sections":[{"id": "cs"}]}') + allow(client).to receive(:IssuePickerSuggestions).and_return(issue_picker_suggestions) + allow(issue_picker_suggestions).to receive(:build) + end + + it 'should autocomplete issues' do + allow(response).to receive(:body).and_return('{"sections":[{"id": "cs"}]}') + expect(client).to receive(:get).with('/jira/rest/api/2/issue/picker?query=query') + .and_return(response) + + expect(client).to receive(:IssuePickerSuggestions).and_return(issue_picker_suggestions) + expect(issue_picker_suggestions).to receive(:build).with({ 'sections' => [{ 'id' => 'cs' }] }) + + JIRA::Resource::IssuePickerSuggestions.all(client, 'query') + end + + it 'should autocomplete issues with current jql' do + expect(client).to receive(:get).with('/jira/rest/api/2/issue/picker?query=query¤tJQL=project+%3D+PR') + .and_return(response) + + JIRA::Resource::IssuePickerSuggestions.all(client, 'query', current_jql: 'project = PR') + end + + it 'should autocomplete issues with current issue jey' do + expect(client).to receive(:get).with('/jira/rest/api/2/issue/picker?query=query¤tIssueKey=PR-42') + .and_return(response) + + JIRA::Resource::IssuePickerSuggestions.all(client, 'query', current_issue_key: 'PR-42') + end + + it 'should autocomplete issues with current project id' do + expect(client).to receive(:get).with('/jira/rest/api/2/issue/picker?query=query¤tProjectId=PR') + .and_return(response) + + JIRA::Resource::IssuePickerSuggestions.all(client, 'query', current_project_id: 'PR') + end + + it 'should autocomplete issues with show sub tasks' do + expect(client).to receive(:get).with('/jira/rest/api/2/issue/picker?query=query&showSubTasks=true') + .and_return(response) + + JIRA::Resource::IssuePickerSuggestions.all(client, 'query', show_sub_tasks: true) + end + + it 'should autocomplete issues with show sub tasks parent' do + expect(client).to receive(:get).with('/jira/rest/api/2/issue/picker?query=query&showSubTaskParent=true') + .and_return(response) + + JIRA::Resource::IssuePickerSuggestions.all(client, 'query', show_sub_task_parent: true) + end + end +end diff --git a/spec/jira/resource/issue_spec.rb b/spec/jira/resource/issue_spec.rb index 585200c2..9fbcb5d2 100644 --- a/spec/jira/resource/issue_spec.rb +++ b/spec/jira/resource/issue_spec.rb @@ -47,7 +47,7 @@ class JIRAResourceDelegation < SimpleDelegator # :nodoc: .and_return(empty_response) expect(client).to receive(:Issue).and_return(issue) - expect(issue).to receive(:build).with('id' => '1', 'summary' => 'Bugs Everywhere') + expect(issue).to receive(:build).with({ 'id' => '1', 'summary' => 'Bugs Everywhere' }) issues = JIRA::Resource::Issue.all(client) end @@ -180,6 +180,7 @@ class JIRAResourceDelegation < SimpleDelegator # :nodoc: 'priority' => { 'foo' => 'bar' }, 'issuetype' => { 'foo' => 'bar' }, 'status' => { 'foo' => 'bar' }, + 'resolution' => { 'foo' => 'bar' }, 'components' => [{ 'foo' => 'bar' }, { 'baz' => 'flum' }], 'versions' => [{ 'foo' => 'bar' }, { 'baz' => 'flum' }], 'comment' => { 'comments' => [{ 'foo' => 'bar' }, { 'baz' => 'flum' }] }, @@ -208,6 +209,9 @@ class JIRAResourceDelegation < SimpleDelegator # :nodoc: expect(subject).to have_one(:status, JIRA::Resource::Status) expect(subject.status.foo).to eq('bar') + expect(subject).to have_one(:resolution, JIRA::Resource::Resolution) + expect(subject.resolution.foo).to eq('bar') + expect(subject).to have_many(:components, JIRA::Resource::Component) expect(subject.components.length).to eq(2) diff --git a/spec/jira/resource/jira_picker_suggestions_issue_spec.rb b/spec/jira/resource/jira_picker_suggestions_issue_spec.rb new file mode 100644 index 00000000..c584b87a --- /dev/null +++ b/spec/jira/resource/jira_picker_suggestions_issue_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe JIRA::Resource::IssuePickerSuggestionsIssue do + let(:client) { double('client') } + + describe 'relationships' do + subject do + JIRA::Resource::IssuePickerSuggestionsIssue.new(client, attrs: { + 'issues' => [{ 'id' => '1'}, { 'id' => '2' }] + }) + end + + it 'has the correct relationships' do + expect(subject).to have_many(:issues, JIRA::Resource::SuggestedIssue) + expect(subject.issues.length).to eq(2) + end + end +end diff --git a/spec/jira/resource/sprint_spec.rb b/spec/jira/resource/sprint_spec.rb index 05904389..ca57fd3c 100644 --- a/spec/jira/resource/sprint_spec.rb +++ b/spec/jira/resource/sprint_spec.rb @@ -1,12 +1,37 @@ require 'spec_helper' describe JIRA::Resource::Sprint do - describe 'peristence' do - let(:sprint) { described_class.new(client) } - let(:client) { double('Client', options: { site: 'https://foo.bar.com' }) } + let(:client) do + client = double(options: { rest_base_path: '/jira/rest/api/2', context_path: '/jira' }) + allow(client).to receive(:Sprint).and_return(JIRA::Resource::SprintFactory.new(client)) + client + end + let(:sprint) { described_class.new(client) } + let(:agile_sprint_path) { "/jira/rest/agile/1.0/sprint/#{sprint.id}" } + let(:response) { double } + + describe 'get_sprint_details' do + let(:sprint) { JIRA::Resource::Sprint.find(client, '1') } + it 'check each of the date attributes' do + allow(client).to receive(:get).and_return(double(body: get_mock_response('sprint/1.json'))) + + expect(sprint.start_date).to eq Date.parse('2024-01-01T03:20:00.000Z') + expect(sprint.end_date).to eq Date.parse('2024-01-15T03:20:00.000Z') + expect(sprint.complete_date).to eq Date.parse('2024-01-16T03:48:00.000Z') + end + end + + describe '::find' do + let(:response) { double('Response', body: '{"some_detail":"some detail"}') } + + it 'fetches the sprint from JIRA' do + expect(client).to receive(:get).with('/jira/rest/agile/1.0/sprint/111').and_return(response) + expect(JIRA::Resource::Sprint.find(client, '111')).to be_a(JIRA::Resource::Sprint) + end + end + describe 'peristence' do describe '#save' do - let(:agile_sprint_url) { "#{sprint.client.options[:site]}/rest/agile/1.0/sprint/#{sprint.id}" } let(:instance_attrs) { { start_date: '2016-06-01' } } before do @@ -17,7 +42,7 @@ let(:given_attrs) { { start_date: '2016-06-10' } } it 'calls save on the super class with the given attributes & agile url' do - expect_any_instance_of(JIRA::Base).to receive(:save).with(given_attrs, agile_sprint_url) + expect_any_instance_of(JIRA::Base).to receive(:save).with(given_attrs, agile_sprint_path) sprint.save(given_attrs) end @@ -25,7 +50,7 @@ context 'when attributes are not specified' do it 'calls save on the super class with the instance attributes & agile url' do - expect_any_instance_of(JIRA::Base).to receive(:save).with(instance_attrs, agile_sprint_url) + expect_any_instance_of(JIRA::Base).to receive(:save).with(instance_attrs, agile_sprint_path) sprint.save end @@ -33,7 +58,7 @@ context 'when providing the path argument' do it 'ignores it' do - expect_any_instance_of(JIRA::Base).to receive(:save).with(instance_attrs, agile_sprint_url) + expect_any_instance_of(JIRA::Base).to receive(:save).with(instance_attrs, agile_sprint_path) sprint.save({}, 'mavenlink.com') end @@ -41,7 +66,6 @@ end describe '#save!' do - let(:agile_sprint_url) { "#{sprint.client.options[:site]}/rest/agile/1.0/sprint/#{sprint.id}" } let(:instance_attrs) { { start_date: '2016-06-01' } } before do @@ -52,7 +76,7 @@ let(:given_attrs) { { start_date: '2016-06-10' } } it 'calls save! on the super class with the given attributes & agile url' do - expect_any_instance_of(JIRA::Base).to receive(:save!).with(given_attrs, agile_sprint_url) + expect_any_instance_of(JIRA::Base).to receive(:save!).with(given_attrs, agile_sprint_path) sprint.save!(given_attrs) end @@ -60,7 +84,7 @@ context 'when attributes are not specified' do it 'calls save! on the super class with the instance attributes & agile url' do - expect_any_instance_of(JIRA::Base).to receive(:save!).with(instance_attrs, agile_sprint_url) + expect_any_instance_of(JIRA::Base).to receive(:save!).with(instance_attrs, agile_sprint_path) sprint.save! end @@ -68,11 +92,65 @@ context 'when providing the path argument' do it 'ignores it' do - expect_any_instance_of(JIRA::Base).to receive(:save!).with(instance_attrs, agile_sprint_url) + expect_any_instance_of(JIRA::Base).to receive(:save!).with(instance_attrs, agile_sprint_path) sprint.save!({}, 'mavenlink.com') end end end + + context 'an issue exists' do + let(:issue_id) { 1001 } + let(:post_issue_path) do + described_class.agile_path(client, sprint.id) + '/jira/rest/agile/1.0/sprint//issue' + end + let(:issue) do + issue = double + allow(issue).to receive(:id).and_return(issue_id) + issue + end + let(:post_issue_input) do + { "issues": [issue.id] } + end + + describe '#add_issu' do + context 'when an issue is passed' do + it 'posts with the issue id' do + expect(client).to receive(:post).with(post_issue_path, post_issue_input.to_json) + + sprint.add_issue(issue) + end + end + end + end + + context 'multiple issues exists' do + let(:issue_ids) { [ 1001, 1012 ] } + let(:post_issue_path) do + described_class.agile_path(client, sprint.id) + '/jira/rest/agile/1.0/sprint//issue' + end + let(:issues) do + issue_ids.map do |issue_id| + issue = double + allow(issue).to receive(:id).and_return(issue_id) + issue + end + end + let(:post_issue_input) do + { "issues": issue_ids } + end + + describe '#add_issues' do + context 'when an issue is passed' do + it 'posts with the issue id' do + expect(client).to receive(:post).with(post_issue_path, post_issue_input.to_json) + + sprint.add_issues(issues) + end + end + end + end end end diff --git a/spec/jira/resource/status_spec.rb b/spec/jira/resource/status_spec.rb new file mode 100644 index 00000000..e3023dc6 --- /dev/null +++ b/spec/jira/resource/status_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe JIRA::Resource::Status do + + let(:client) do + client = double(options: { rest_base_path: '/jira/rest/api/2' }) + allow(client).to receive(:Field).and_return(JIRA::Resource::FieldFactory.new(client)) + allow(client).to receive(:cache).and_return(OpenStruct.new) + client + end + + describe '#status_category' do + subject do + JIRA::Resource::Status.new(client, attrs: JSON.parse(File.read('spec/mock_responses/status/1.json'))) + end + + it 'has a status_category relationship' do + expect(subject).to have_one(:status_category, JIRA::Resource::StatusCategory) + expect(subject.status_category.name).to eq('To Do') + end + end +end \ No newline at end of file diff --git a/spec/mock_responses/sprint/1.json b/spec/mock_responses/sprint/1.json new file mode 100644 index 00000000..4fcb02df --- /dev/null +++ b/spec/mock_responses/sprint/1.json @@ -0,0 +1,13 @@ +{ + "id": 1, + "self": "https://localhost:2990/jira/rest/agile/1.0/sprint/1", + "state": "closed", + "name": "SP Sprint 1", + "startDate": "2024-01-01T03:20:00.000Z", + "endDate": "2024-01-15T03:20:00.000Z", + "completeDate": "2024-01-16T03:48:00.000Z", + "createdDate": "2024-01-01T00:00:00.000Z", + "originBoardId": 1, + "goal": "", + "rapidview_id": 1 +} \ No newline at end of file diff --git a/spec/mock_responses/status.json b/spec/mock_responses/status.json index 835ad825..30f85e40 100644 --- a/spec/mock_responses/status.json +++ b/spec/mock_responses/status.json @@ -4,34 +4,69 @@ "description": "The issue is open and ready for the assignee to start work on it.", "iconUrl": "http://localhost:2990/jira/images/icons/status_open.gif", "name": "Open", - "id": "1" + "id": "1", + "statusCategory": { + "self": "http://localhost:2990/jira/rest/api/2/statuscategory/2", + "id": 2, + "key": "new", + "colorName": "blue-gray", + "name": "To Do" + } }, { "self": "http://localhost:2990/jira/rest/api/2/status/3", "description": "This issue is being actively worked on at the moment by the assignee.", "iconUrl": "http://localhost:2990/jira/images/icons/status_inprogress.gif", "name": "In Progress", - "id": "3" + "id": "3", + "statusCategory": { + "self": "http://localhost:2990/jira/rest/api/2/statuscategory/4", + "id": 4, + "key": "indeterminate", + "colorName": "yellow", + "name": "In Progress" + } }, { "self": "http://localhost:2990/jira/rest/api/2/status/4", "description": "This issue was once resolved, but the resolution was deemed incorrect. From here issues are either marked assigned or resolved.", "iconUrl": "http://localhost:2990/jira/images/icons/status_reopened.gif", "name": "Reopened", - "id": "4" + "id": "4", + "statusCategory": { + "self": "http://localhost:2990/jira/rest/api/2/statuscategory/2", + "id": 2, + "key": "new", + "colorName": "blue-gray", + "name": "To Do" + } }, { "self": "http://localhost:2990/jira/rest/api/2/status/5", "description": "A resolution has been taken, and it is awaiting verification by reporter. From here issues are either reopened, or are closed.", "iconUrl": "http://localhost:2990/jira/images/icons/status_resolved.gif", "name": "Resolved", - "id": "5" + "id": "5", + "statusCategory": { + "self": "http://localhost:2990/jira/rest/api/2/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "green", + "name": "Done" + } }, { "self": "http://localhost:2990/jira/rest/api/2/status/6", "description": "The issue is considered finished, the resolution is correct. Issues which are closed can be reopened.", "iconUrl": "http://localhost:2990/jira/images/icons/status_closed.gif", "name": "Closed", - "id": "6" + "id": "6", + "statusCategory": { + "self": "http://localhost:2990/jira/rest/api/2/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "green", + "name": "Done" + } } ] diff --git a/spec/mock_responses/status/1.json b/spec/mock_responses/status/1.json index af3f17b1..63de85cc 100644 --- a/spec/mock_responses/status/1.json +++ b/spec/mock_responses/status/1.json @@ -3,5 +3,12 @@ "description": "The issue is open and ready for the assignee to start work on it.", "iconUrl": "http://localhost:2990/jira/images/icons/status_open.gif", "name": "Open", - "id": "1" + "id": "1", + "statusCategory": { + "self": "http://localhost:2990/jira/rest/api/2/statuscategory/2", + "id": 2, + "key": "new", + "colorName": "blue-gray", + "name": "To Do" + } } diff --git a/spec/mock_responses/statuscategory.json b/spec/mock_responses/statuscategory.json new file mode 100644 index 00000000..24ef8f59 --- /dev/null +++ b/spec/mock_responses/statuscategory.json @@ -0,0 +1,30 @@ +[ + { + "self": "http://localhost:2990/jira/rest/api/2/statuscategory/1", + "id": 1, + "key": "undefined", + "colorName": "medium-gray", + "name": "No Category" + }, + { + "self": "http://localhost:2990/jira/rest/api/2/statuscategory/2", + "id": 2, + "key": "new", + "colorName": "blue-gray", + "name": "To Do" + }, + { + "self": "http://localhost:2990/jira/rest/api/2/statuscategory/4", + "id": 4, + "key": "indeterminate", + "colorName": "yellow", + "name": "In Progress" + }, + { + "self": "http://localhost:2990/jira/rest/api/2/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "green", + "name": "Done" + } +] \ No newline at end of file diff --git a/spec/mock_responses/statuscategory/1.json b/spec/mock_responses/statuscategory/1.json new file mode 100644 index 00000000..ba03e5e0 --- /dev/null +++ b/spec/mock_responses/statuscategory/1.json @@ -0,0 +1,7 @@ +{ + "self": "http://localhost:2990/jira/rest/api/2/statuscategory/1", + "id": 1, + "key": "undefined", + "colorName": "medium-gray", + "name": "No Category" +} \ No newline at end of file diff --git a/spec/support/clients_helper.rb b/spec/support/clients_helper.rb index a9df0477..c022b0fe 100644 --- a/spec/support/clients_helper.rb +++ b/spec/support/clients_helper.rb @@ -7,7 +7,7 @@ def with_each_client clients['http://localhost:2990'] = oauth_client basic_client = JIRA::Client.new(username: 'foo', password: 'bar', auth_type: :basic, use_ssl: false) - clients['http://foo:bar@localhost:2990'] = basic_client + clients['http://localhost:2990'] = basic_client clients.each do |site_url, client| yield site_url, client diff --git a/spec/support/shared_examples/integration.rb b/spec/support/shared_examples/integration.rb index 36910beb..1c3e0a17 100644 --- a/spec/support/shared_examples/integration.rb +++ b/spec/support/shared_examples/integration.rb @@ -55,9 +55,9 @@ def build_receiver stub_request(:put, site_url + subject.url) .to_return(status: 405, body: 'Some HTML') expect(subject.save('foo' => 'bar')).to be_falsey - expect(lambda do + expect do expect(subject.save!('foo' => 'bar')).to be_falsey - end).to raise_error(JIRA::HTTPError) + end.to raise_error(JIRA::HTTPError) end end @@ -115,9 +115,9 @@ def build_receiver it 'handles a 404' do stub_request(:get, site_url + described_class.singular_path(client, '99999', prefix)) .to_return(status: 404, body: '{"errorMessages":["' + class_basename + ' Does Not Exist"],"errors": {}}') - expect(lambda do + expect do client.send(class_basename).find('99999', options) - end).to raise_exception(JIRA::HTTPError) + end.to raise_exception(JIRA::HTTPError) end end @@ -170,8 +170,8 @@ def build_receiver subject.fetch expect(subject.save('fields' => { 'invalid' => 'field' })).to be_falsey - expect(lambda do + expect do subject.save!('fields' => { 'invalid' => 'field' }) - end).to raise_error(JIRA::HTTPError) + end.to raise_error(JIRA::HTTPError) end end