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/.travis.yml b/.travis.yml deleted file mode 100644 index 6f17a8a2..00000000 --- a/.travis.yml +++ /dev/null @@ -1,9 +0,0 @@ -language: ruby -rvm: - - 2.4 - - 2.5 - - 2.6 - - 2.7 -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 66231eb0..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 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/jira-ruby.gemspec b/jira-ruby.gemspec index 02f01703..70ce734b 100644 --- a/jira-ruby.gemspec +++ b/jira-ruby.gemspec @@ -11,7 +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.required_ruby_version = '>= 3.1.0' s.files = `git ls-files`.split("\n") s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") @@ -22,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 b4250cf2..ae464f8e 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,14 +27,17 @@ 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' diff --git a/lib/jira/base.rb b/lib/jira/base.rb index 8f98cf05..52c6dbf3 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. diff --git a/lib/jira/client.rb b/lib/jira/client.rb index 522e8d80..fa631050 100644 --- a/lib/jira/client.rb +++ b/lib/jira/client.rb @@ -34,12 +34,14 @@ module JIRA # :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. @@ -85,6 +87,7 @@ class Client :default_headers, :use_client_cert, :read_timeout, + :max_retries, :http_debug, :issuer, :base_url, @@ -181,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 @@ -257,6 +264,10 @@ def Issuelinktype JIRA::Resource::IssuelinktypeFactory.new(self) end + def IssuePickerSuggestions + JIRA::Resource::IssuePickerSuggestionsFactory.new(self) + end + def Remotelink JIRA::Resource::RemotelinkFactory.new(self) end @@ -286,7 +297,7 @@ def post(path, body = '', headers = {}) def post_multipart(path, file, headers = {}) puts "post multipart: #{path} - [#{file}]" if @http_debug - @request_client.request_multipart(path, file, headers) + @request_client.request_multipart(path, file, merge_default_headers(headers)) end def put(path, body = '', headers = {}) diff --git a/lib/jira/http_client.rb b/lib/jira/http_client.rb index e68a5371..6a659322 100644 --- a/lib/jira/http_client.rb +++ b/lib/jira/http_client.rb @@ -45,12 +45,12 @@ 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, @options[:proxy_username], @options[:proxy_password]) - 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[:ssl_client_cert] @@ -59,6 +59,8 @@ def http_conn(uri) 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 diff --git a/lib/jira/http_error.rb b/lib/jira/http_error.rb index 1612d78e..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 diff --git a/lib/jira/jwt_client.rb b/lib/jira/jwt_client.rb index 033301ff..843d810a 100644 --- a/lib/jira/jwt_client.rb +++ b/lib/jira/jwt_client.rb @@ -4,64 +4,38 @@ module JIRA class JwtClient < HttpClient def make_request(http_method, url, body = '', headers = {}) @http_method = http_method + jwt_header = build_jwt_header(url) - super(http_method, url, body, headers) + 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) - super(url, data, headers) - end - - class JwtUriBuilder - attr_reader :request_url, :http_method, :shared_secret, :site, :issuer - - def initialize(request_url, http_method, shared_secret, site, issuer) - @request_url = request_url - @http_method = http_method - @shared_secret = shared_secret - @site = site - @issuer = issuer - end - - def build - uri = URI.parse(request_url) - new_query = URI.decode_www_form(String(uri.query)) << ['jwt', jwt_header] - uri.query = URI.encode_www_form(new_query) - - return uri.to_s unless uri.is_a?(URI::HTTP) - - uri.request_uri - end - - private - - def jwt_header - claim = Atlassian::Jwt.build_claims \ - issuer, - request_url, - http_method.to_s, - site, - (Time.now - 60).to_i, - (Time.now + 86_400).to_i - - JWT.encode claim, shared_secret - end + super(url, data, headers.merge(jwt_header)) end private attr_reader :http_method - def request_path(url) - JwtUriBuilder.new( + 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[:shared_secret], @options[:site], - @options[:issuer] - ).build + (Time.now - 60).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 fcfface8..bcbed350 100644 --- a/lib/jira/oauth_client.rb +++ b/lib/jira/oauth_client.rb @@ -46,7 +46,7 @@ def init_oauth_consumer(_options) # 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. diff --git a/lib/jira/resource/attachment.rb b/lib/jira/resource/attachment.rb index b416e69b..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,12 +20,51 @@ def self.meta(client) parse_json(response.body) end + # 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 + + # 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 + 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' headers = { 'X-Atlassian-Token' => 'nocheck' } - data = { 'file' => UploadIO.new(file, mime_type, file) } + data = { 'file' => Multipart::Post::UploadIO.new(file, mime_type, file) } response = client.post_multipart(path, data , headers) 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 74beffd4..ee50a7a7 100644 --- a/lib/jira/resource/sprint.rb +++ b/lib/jira/resource/sprint.rb @@ -18,8 +18,13 @@ def issues(options = {}) end def add_issue(issue) - request_body = { issues: [issue.id] }.to_json - response = client.post("#{agile_path}/issue", request_body) + add_issues( [ issue ]) + end + + def add_issues(issues) + issue_ids = issues.map{ |issue| issue.id } + request_body = { issues: issue_ids }.to_json + client.post("#{agile_path}/issue", request_body) true 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/version.rb b/lib/jira/version.rb index bd3da133..09231406 100644 --- a/lib/jira/version.rb +++ b/lib/jira/version.rb @@ -1,3 +1,3 @@ module JIRA - VERSION = '2.1.5'.freeze + VERSION = '2.3.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/jira/base_spec.rb b/spec/jira/base_spec.rb index 26749bd5..0833a940 100644 --- a/spec/jira/base_spec.rb +++ b/spec/jira/base_spec.rb @@ -301,7 +301,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 @@ -428,7 +428,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 @@ -579,9 +579,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 f8f44dee..dfd04948 100644 --- a/spec/jira/client_spec.rb +++ b/spec/jira/client_spec.rb @@ -140,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 @@ -157,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 @@ -232,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 @@ -248,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 diff --git a/spec/jira/http_client_spec.rb b/spec/jira/http_client_spec.rb index 11b22943..9a0a96da 100644 --- a/spec/jira/http_client_spec.rb +++ b/spec/jira/http_client_spec.rb @@ -69,6 +69,13 @@ 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) @@ -81,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 @@ -254,19 +292,29 @@ expect(proxy_configuration.proxy_pass).to be_nil end - it 'sets up a proxied http connection when using proxy options' do - uri = double - host = double - port = double + 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 } - expect(uri).to receive(:host).and_return(host) - expect(uri).to receive(:port).and_return(port) + 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) - proxy_configuration = proxy_client.http_conn(uri).class - expect(proxy_configuration.proxy_address).to eq(proxy_client.options[:proxy_address]) - expect(proxy_configuration.proxy_port).to eq(proxy_client.options[:proxy_port]) - expect(proxy_configuration.proxy_user).to eq(proxy_client.options[:proxy_username]) - expect(proxy_configuration.proxy_pass).to eq(proxy_client.options[:proxy_password]) + 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 @@ -285,6 +333,26 @@ 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 diff --git a/spec/jira/jwt_uri_builder_spec.rb b/spec/jira/jwt_uri_builder_spec.rb deleted file mode 100644 index 0e210d12..00000000 --- a/spec/jira/jwt_uri_builder_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -require 'spec_helper' - -describe JIRA::JwtClient::JwtUriBuilder do - subject(:url_builder) do - JIRA::JwtClient::JwtUriBuilder.new(url, http_method, shared_secret, site, issuer) - end - - let(:url) { '/foo' } - let(:http_method) { :get } - let(:shared_secret) { 'shared_secret' } - let(:site) { 'http://localhost:2990' } - let(:issuer) { nil } - - describe '#build' do - subject { url_builder.build } - - it 'includes the jwt param' do - expect(subject).to include('?jwt=') - end - - context 'when the url already contains params' do - let(:url) { '/foo?expand=projects.issuetypes.fields' } - - it 'includes the jwt param' do - expect(subject).to include('&jwt=') - end - end - - context 'with a complete url' do - let(:url) { 'http://localhost:2990/rest/api/2/issue/createmeta' } - - it 'includes the jwt param' do - expect(subject).to include('?jwt=') - end - - it { is_expected.to start_with('/') } - - it 'contains only one ?' do - expect(subject.count('?')).to eq(1) - end - end - - context 'with a complete url containing a param' do - let(:url) do - 'http://localhost:2990/rest/api/2/issue/createmeta?expand=projects.issuetypes.fields' - end - - it 'includes the jwt param' do - expect(subject).to include('&jwt=') - end - - it { is_expected.to start_with('/') } - - it 'contains only one ?' do - expect(subject.count('?')).to eq(1) - end - end - end -end diff --git a/spec/jira/oauth_client_spec.rb b/spec/jira/oauth_client_spec.rb index 048b1d68..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 @@ -159,4 +179,4 @@ end end end -end \ No newline at end of file +end diff --git a/spec/jira/resource/attachment_spec.rb b/spec/jira/resource/attachment_spec.rb index 03e0c722..13cf249d 100644 --- a/spec/jira/resource/attachment_spec.rb +++ b/spec/jira/resource/attachment_spec.rb @@ -59,79 +59,155 @@ end end - describe '#save' do - subject { attachment.save('file' => path_to_file) } - let(:path_to_file) { './spec/mock_responses/issue.json' } - let(:response) do - double( - body: [ - { - "id": 10_001, - "self": 'http://www.example.com/jira/rest/api/2.0/attachments/10000', - "filename": 'picture.jpg', - "created": '2017-07-19T12:23:06.572+0000', - "size": 23_123, - "mimeType": 'image/jpeg' - } - ].to_json + 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 - let(:issue) { JIRA::Resource::Issue.new(client) } - before do - allow(client).to receive(:post_multipart).and_return(response) + 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 - it 'successfully update the attachment' do - subject + 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) + + result_str = attachment.download_contents - expect(attachment.filename).to eq 'picture.jpg' - expect(attachment.mimeType).to eq 'image/jpeg' - expect(attachment.size).to eq 23_123 + expect(result_str).to eq(attachment_file_contents) + end end end - describe '#save!' do - subject { attachment.save!('file' => path_to_file) } - - let(:path_to_file) { './spec/mock_responses/issue.json' } + 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) } - before do - allow(client).to receive(:post_multipart).and_return(response) - end + describe '#save' do + subject { attachment.save('file' => path_to_file) } - it 'successfully update the attachment' do - subject + 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 - expect(attachment.filename).to eq 'picture.jpg' - expect(attachment.mimeType).to eq 'image/jpeg' - expect(attachment.size).to eq 23_123 end - context 'when passing in a symbol as file key' do - subject { attachment.save!(file: path_to_file) } + 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 'picture.jpg' - expect(attachment.mimeType).to eq 'image/jpeg' - expect(attachment.size).to eq 23_123 + 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 + + 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 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 d3f4c3c0..b766abb8 100644 --- a/spec/jira/resource/sprint_spec.rb +++ b/spec/jira/resource/sprint_spec.rb @@ -86,5 +86,62 @@ 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/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