diff --git a/app/graphql/resolvers/projects/jira_projects_resolver.rb b/app/graphql/resolvers/projects/jira_projects_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..7004976adab10bdf36c90613a378180983efb18c --- /dev/null +++ b/app/graphql/resolvers/projects/jira_projects_resolver.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Resolvers + module Projects + class JiraProjectsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + argument :name, + GraphQL::STRING_TYPE, + required: false, + description: 'Project name or key' + + def resolve(name: nil, **args) + authorize!(project) + + response, start_cursor, end_cursor = jira_projects(name: name, **compute_pagination_params(args)) + end_cursor = nil if !!response.payload[:is_last] + + response.success? ? Gitlab::Graphql::ExternallyPaginatedArray.new(start_cursor, end_cursor, *response.payload[:projects]) : nil + end + + def authorized_resource?(project) + Feature.enabled?(:jira_issue_import, project) && Ability.allowed?(context[:current_user], :admin_project, project) + end + + private + + alias_method :jira_service, :object + + def project + jira_service&.project + end + + def compute_pagination_params(params) + after_cursor = Base64.decode64(params[:after].to_s) + before_cursor = Base64.decode64(params[:before].to_s) + + # differentiate between 0 cursor and nil or invalid cursor that decodes into zero. + after_index = after_cursor.to_i == 0 && after_cursor != "0" ? nil : after_cursor.to_i + before_index = before_cursor.to_i == 0 && before_cursor != "0" ? nil : before_cursor.to_i + + if after_index.present? && before_index.present? + if after_index >= before_index + { start_at: 0, limit: 0 } + else + { start_at: after_index + 1, limit: before_index - after_index - 1 } + end + elsif after_index.present? + { start_at: after_index + 1, limit: nil } + elsif before_index.present? + { start_at: 0, limit: before_index - 1 } + else + { start_at: 0, limit: nil } + end + end + + def jira_projects(name:, start_at:, limit:) + args = { query: name, start_at: start_at, limit: limit }.compact + + response = jira_service&.jira_projects(args) + projects = response.payload[:projects] + start_cursor = start_at == 0 ? nil : Base64.encode64((start_at - 1).to_s) + end_cursor = Base64.encode64((start_at + projects.size - 1).to_s) + + [response, start_cursor, end_cursor] + end + end + end +end diff --git a/app/graphql/types/projects/services/jira_project_type.rb b/app/graphql/types/projects/services/jira_project_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..ccf9107f39820df682e63c7d4a7a6836cf9cb67d --- /dev/null +++ b/app/graphql/types/projects/services/jira_project_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module Projects + module Services + # rubocop:disable Graphql/AuthorizeTypes + class JiraProjectType < BaseObject + graphql_name 'JiraProject' + + field :key, GraphQL::STRING_TYPE, null: false, + description: 'Key of the Jira project' + field :project_id, GraphQL::INT_TYPE, null: false, + description: 'ID of the Jira project', + method: :id + field :name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the Jira project' + end + # rubocop:enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/graphql/types/projects/services/jira_service_type.rb b/app/graphql/types/projects/services/jira_service_type.rb index 4fd9e61f5a4cb85a47323210e18eff6501319e60..e81963f752d3d2a0f45be078f4c9ccfc670e1a87 100644 --- a/app/graphql/types/projects/services/jira_service_type.rb +++ b/app/graphql/types/projects/services/jira_service_type.rb @@ -9,9 +9,14 @@ class JiraServiceType < BaseObject implements(Types::Projects::ServiceType) authorize :admin_project - # This is a placeholder for now for the actuall implementation of the JiraServiceType - # Here we will want to expose a field with jira_projects fetched through Jira Rest API - # MR implementing it https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28190 + + field :projects, + Types::Projects::Services::JiraProjectType.connection_type, + null: true, + connection: false, + extensions: [Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension], + description: 'List of Jira projects fetched through Jira REST API', + resolver: Resolvers::Projects::JiraProjectsResolver end end end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 53da874ede844468bd72bf64116b25e245ad7ef4..c71c939f452e86fa05a83897025e669d41cbffc6 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -6,6 +6,8 @@ class JiraService < IssueTrackerService include ApplicationHelper include ActionView::Helpers::AssetUrlHelper + PROJECTS_PER_PAGE = 50 + validates :url, public_url: true, presence: true, if: :activated? validates :api_url, public_url: true, allow_blank: true validates :username, presence: true, if: :activated? @@ -224,8 +226,26 @@ def support_cross_reference? true end + def jira_projects(query: '', limit: PROJECTS_PER_PAGE, start_at: 0) + return ServiceResponse.success(payload: { projects: [], is_last: true }) if limit.to_i <= 0 + + response = jira_request { client.get(projects_url(query: query, limit: limit.to_i, start_at: start_at.to_i)) } + + return ServiceResponse.error(message: @error.message) if @error.present? + return ServiceResponse.success(payload: { projects: [] }) unless response['values'].present? + + projects = response['values'].map { |v| JIRA::Resource::Project.build(client, v) } + + ServiceResponse.success(payload: { projects: projects, is_last: response['isLast'] }) + end + private + def projects_url(query:, limit:, start_at:) + '/rest/api/2/project/search?query=%{query}&maxResults=%{limit}&startAt=%{start_at}' % + { query: CGI.escape(query.to_s), limit: limit, start_at: start_at } + end + def test_settings return unless client_url.present? diff --git a/app/workers/concerns/gitlab/jira_import/import_worker.rb b/app/workers/concerns/gitlab/jira_import/import_worker.rb index 537300e6ebab598c74bfe62d59afbba143cc1ed4..7606cf76c0f628e173fbd949b5cb0dda71bddda2 100644 --- a/app/workers/concerns/gitlab/jira_import/import_worker.rb +++ b/app/workers/concerns/gitlab/jira_import/import_worker.rb @@ -7,6 +7,7 @@ module ImportWorker included do include ApplicationWorker + include ProjectImportOptions include Gitlab::JiraImport::QueueOptions end diff --git a/changelogs/unreleased/jira-projects-api-wrapper.yml b/changelogs/unreleased/jira-projects-api-wrapper.yml new file mode 100644 index 0000000000000000000000000000000000000000..4fe86aa3f4c77296d76c8fa28d1ee0612a037bc3 --- /dev/null +++ b/changelogs/unreleased/jira-projects-api-wrapper.yml @@ -0,0 +1,5 @@ +--- +title: Add a GraphQL endpoint to fetch Jira projects through its REST API +merge_request: 28190 +author: +type: changed diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 48ef6f5ae36c3faa618c656eb045f986a43858cd..9e351124126d3825fa6841b446da448adad83aa0 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -5531,12 +5531,94 @@ type JiraImportStartPayload { jiraImport: JiraImport } +type JiraProject { + """ + Key of the Jira project + """ + key: String! + + """ + Name of the Jira project + """ + name: String + + """ + ID of the Jira project + """ + projectId: Int! +} + +""" +The connection type for JiraProject. +""" +type JiraProjectConnection { + """ + A list of edges. + """ + edges: [JiraProjectEdge] + + """ + A list of nodes. + """ + nodes: [JiraProject] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type JiraProjectEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: JiraProject +} + type JiraService implements Service { """ Indicates if the service is active """ active: Boolean + """ + List of Jira projects fetched through Jira REST API + """ + projects( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Project name or key + """ + name: String + ): JiraProjectConnection + """ Class name of the service """ diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 923830512ffbb1b84f9e53c217f6efaf12bc9a8a..c1addf68b79834ad9219346293682a85ad6e506f 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -15374,6 +15374,181 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "JiraProject", + "description": null, + "fields": [ + { + "name": "key", + "description": "Key of the Jira project", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "Name of the Jira project", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "projectId", + "description": "ID of the Jira project", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "JiraProjectConnection", + "description": "The connection type for JiraProject.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "JiraProjectEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "JiraProject", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pageInfo", + "description": "Information to aid in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "JiraProjectEdge", + "description": "An edge in a connection.", + "fields": [ + { + "name": "cursor", + "description": "A cursor for use in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": "The item at the end of the edge.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "JiraProject", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "JiraService", @@ -15393,6 +15568,69 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "projects", + "description": "List of Jira projects fetched through Jira REST API", + "args": [ + { + "name": "name", + "description": "Project name or key", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "JiraProjectConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "type", "description": "Class name of the service", diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 1fe468c54c387c41d2b88b0b4d924ecd54fb5bf1..dfeb4e0f46daa0305688838bcf2e57eedd9d2520 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -818,11 +818,20 @@ Autogenerated return type of JiraImportStart | `errors` | String! => Array | Errors encountered during execution of the mutation. | | `jiraImport` | JiraImport | The Jira import data after mutation | +## JiraProject + +| Name | Type | Description | +| --- | ---- | ---------- | +| `key` | String! | Key of the Jira project | +| `name` | String | Name of the Jira project | +| `projectId` | Int! | ID of the Jira project | + ## JiraService | Name | Type | Description | | --- | ---- | ---------- | | `active` | Boolean | Indicates if the service is active | +| `projects` | JiraProjectConnection | List of Jira projects fetched through Jira REST API | | `type` | String | Class name of the service | ## Label diff --git a/spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb b/spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6251ead3954da25e8d55c0fc8ebe87615f4f2308 --- /dev/null +++ b/spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Resolvers::Projects::JiraProjectsResolver do + include GraphqlHelpers + + describe '#resolve' do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + + shared_examples 'no project service access' do + it 'raises error' do + expect do + resolve_jira_projects + end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when project has no jira service' do + let_it_be(:jira_service) { nil } + + context 'when user is a maintainer' do + before do + project.add_maintainer(user) + end + + it_behaves_like 'no project service access' + end + end + + context 'when project has jira service' do + let(:jira_service) { create(:jira_service, project: project) } + + context 'when user is a developer' do + before do + project.add_developer(user) + end + + it_behaves_like 'no project service access' + end + + context 'when user is a maintainer' do + include_context 'jira projects request context' + + before do + project.add_maintainer(user) + end + + it 'returns jira projects' do + jira_projects = resolve_jira_projects + project_keys = jira_projects.map(&:key) + project_names = jira_projects.map(&:name) + project_ids = jira_projects.map(&:id) + + expect(jira_projects.size).to eq 2 + expect(project_keys).to eq(%w(EX ABC)) + expect(project_names).to eq(%w(Example Alphabetical)) + expect(project_ids).to eq(%w(10000 10001)) + end + end + end + end + + def resolve_jira_projects(args = {}, context = { current_user: user }) + resolve(described_class, obj: jira_service, args: args, ctx: context) + end +end diff --git a/spec/graphql/types/projects/jira_project_type_spec.rb b/spec/graphql/types/projects/jira_project_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..cbb0111771708d134db0d0bb49c5313c1a643b9e --- /dev/null +++ b/spec/graphql/types/projects/jira_project_type_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GitlabSchema.types['JiraProject'] do + it { expect(described_class.graphql_name).to eq('JiraProject') } + + it 'has basic expected fields' do + expect(described_class).to have_graphql_fields(:key, :project_id, :name) + end +end diff --git a/spec/graphql/types/projects/jira_service_type_spec.rb b/spec/graphql/types/projects/jira_service_type_spec.rb index 91d7e4586cb45c16088ccd9b1b485cd8c29dc03e..fad0c91caab13c6607af848b640eb81c6e5e080d 100644 --- a/spec/graphql/types/projects/jira_service_type_spec.rb +++ b/spec/graphql/types/projects/jira_service_type_spec.rb @@ -6,7 +6,7 @@ specify { expect(described_class.graphql_name).to eq('JiraService') } it 'has basic expected fields' do - expect(described_class).to have_graphql_fields(:type, :active) + expect(described_class).to have_graphql_fields(:type, :active, :projects) end specify { expect(described_class).to require_graphql_authorizations(:admin_project) } diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb index a0d36f0a238f76881880e165cce4b74d56cd2c04..4399a722f89189e0205f647a86e6a87443eb31e6 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -816,4 +816,85 @@ def test_settings(url = 'jira.example.com') end end end + + describe '#jira_projects' do + let(:project) { create(:project) } + let(:jira_service) do + described_class.new( + project: project, + url: url, + username: username, + password: password + ) + end + + context 'when request to the jira server fails' do + it 'returns error' do + test_url = "#{url}/rest/api/2/project/search?maxResults=50&query=&startAt=0" + WebMock.stub_request(:get, test_url).with(basic_auth: [username, password]) + .to_raise(JIRA::HTTPError.new(double(message: 'random error'))) + + response = jira_service.jira_projects + + expect(response.error?).to be true + expect(response.message).to eq('random error') + end + end + + context 'with invalid params' do + it 'escapes params' do + escaped_url = "#{url}/rest/api/2/project/search?query=Test%26maxResults%3D3&maxResults=10&startAt=0" + WebMock.stub_request(:get, escaped_url).with(basic_auth: [username, password]) + .to_return(body: {}.to_json, headers: { "Content-Type": "application/json" }) + + response = jira_service.jira_projects(query: 'Test&maxResults=3', limit: 10, start_at: 'zero') + + expect(response.error?).to be false + end + end + + context 'when no jira_projects are returned' do + let(:jira_projects_json) do + '{ + "self": "https://your-domain.atlassian.net/rest/api/2/project/search?startAt=0&maxResults=2", + "nextPage": "https://your-domain.atlassian.net/rest/api/2/project/search?startAt=2&maxResults=2", + "maxResults": 2, + "startAt": 0, + "total": 7, + "isLast": false, + "values": [] + }' + end + + it 'returns empty array of jira projects' do + test_url = "#{url}/rest/api/2/project/search?maxResults=50&query=&startAt=0" + WebMock.stub_request(:get, test_url).with(basic_auth: [username, password]) + .to_return(body: jira_projects_json, headers: { "Content-Type": "application/json" }) + + response = jira_service.jira_projects + + expect(response.success?).to be true + expect(response.payload).not_to be nil + end + end + + context 'when jira_projects are returned' do + include_context 'jira projects request context' + + it 'returns array of jira projects' do + response = jira_service.jira_projects + + projects = response.payload[:projects] + project_keys = projects.map(&:key) + project_names = projects.map(&:name) + project_ids = projects.map(&:id) + + expect(response.success?).to be true + expect(projects.size).to eq(2) + expect(project_keys).to eq(%w(EX ABC)) + expect(project_names).to eq(%w(Example Alphabetical)) + expect(project_ids).to eq(%w(10000 10001)) + end + end + end end diff --git a/spec/requests/api/graphql/project/jira_projects_spec.rb b/spec/requests/api/graphql/project/jira_projects_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d67c89f18c9ef1ddad247ff22a4b801844da8ae6 --- /dev/null +++ b/spec/requests/api/graphql/project/jira_projects_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'query Jira projects' do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:project) { create(:project) } + + include_context 'jira projects request context' + + let(:services) { graphql_data_at(:project, :services, :edges) } + let(:jira_projects) { services.first.dig('node', 'projects', 'nodes') } + let(:projects_query) { 'projects' } + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + services(active: true, type: JIRA_SERVICE) { + edges { + node { + ... on JiraService { + %{projects_query} { + nodes { + key + name + projectId + } + } + } + } + } + } + } + } + ) % { projects_query: projects_query } + end + + context 'when user does not have access' do + it_behaves_like 'unauthorized users cannot read services' + end + + context 'when user can access project services' do + before do + project.add_maintainer(current_user) + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + it 'retuns list of jira projects' do + project_keys = jira_projects.map { |jp| jp['key'] } + project_names = jira_projects.map { |jp| jp['name'] } + project_ids = jira_projects.map { |jp| jp['projectId'] } + + expect(jira_projects.size).to eq(2) + expect(project_keys).to eq(%w(EX ABC)) + expect(project_names).to eq(%w(Example Alphabetical)) + expect(project_ids).to eq([10000, 10001]) + end + + context 'with pagination' do + context 'when fetching limited number of projects' do + shared_examples_for 'fetches first project' do + it 'retuns first project from list of fetched projects' do + project_keys = jira_projects.map { |jp| jp['key'] } + project_names = jira_projects.map { |jp| jp['name'] } + project_ids = jira_projects.map { |jp| jp['projectId'] } + + expect(jira_projects.size).to eq(1) + expect(project_keys).to eq(%w(EX)) + expect(project_names).to eq(%w(Example)) + expect(project_ids).to eq([10000]) + end + end + + context 'without cursor' do + let(:projects_query) { 'projects(first: 1)' } + + it_behaves_like 'fetches first project' + end + + context 'with before cursor' do + let(:projects_query) { 'projects(before: "Mg==", first: 1)' } + + it_behaves_like 'fetches first project' + end + + context 'with after cursor' do + let(:projects_query) { 'projects(after: "MA==", first: 1)' } + + it_behaves_like 'fetches first project' + end + end + + context 'with valid but inexistent after cursor' do + let(:projects_query) { 'projects(after: "MTk==")' } + + it 'retuns empty list of jira projects' do + expect(jira_projects.size).to eq(0) + end + end + + context 'with invalid after cursor' do + let(:projects_query) { 'projects(after: "invalid==")' } + + it 'treats the invalid cursor as no cursor and returns list of jira projects' do + expect(jira_projects.size).to eq(2) + end + end + end + end +end diff --git a/spec/support/shared_contexts/requests/api/graphql/jira_import/jira_projects_context.rb b/spec/support/shared_contexts/requests/api/graphql/jira_import/jira_projects_context.rb new file mode 100644 index 0000000000000000000000000000000000000000..f0722beb3ed4fef16315728931b12242c56f0d22 --- /dev/null +++ b/spec/support/shared_contexts/requests/api/graphql/jira_import/jira_projects_context.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +shared_context 'jira projects request context' do + let(:url) { 'https://jira.example.com' } + let(:username) { 'jira-username' } + let(:password) { 'jira-password' } + let!(:jira_service) do + create(:jira_service, + project: project, + url: url, + username: username, + password: password + ) + end + + let_it_be(:jira_projects_json) do + '{ + "self": "https://your-domain.atlassian.net/rest/api/2/project/search?startAt=0&maxResults=2", + "nextPage": "https://your-domain.atlassian.net/rest/api/2/project/search?startAt=2&maxResults=2", + "maxResults": 2, + "startAt": 0, + "total": 7, + "isLast": false, + "values": [ + { + "self": "https://your-domain.atlassian.net/rest/api/2/project/EX", + "id": "10000", + "key": "EX", + "name": "Example", + "avatarUrls": { + "48x48": "https://your-domain.atlassian.net/secure/projectavatar?size=large&pid=10000", + "24x24": "https://your-domain.atlassian.net/secure/projectavatar?size=small&pid=10000", + "16x16": "https://your-domain.atlassian.net/secure/projectavatar?size=xsmall&pid=10000", + "32x32": "https://your-domain.atlassian.net/secure/projectavatar?size=medium&pid=10000" + }, + "projectCategory": { + "self": "https://your-domain.atlassian.net/rest/api/2/projectCategory/10000", + "id": "10000", + "name": "FIRST", + "description": "First Project Category" + }, + "simplified": false, + "style": "classic", + "insight": { + "totalIssueCount": 100, + "lastIssueUpdateTime": "2020-03-31T05:45:24.792+0000" + } + }, + { + "self": "https://your-domain.atlassian.net/rest/api/2/project/ABC", + "id": "10001", + "key": "ABC", + "name": "Alphabetical", + "avatarUrls": { + "48x48": "https://your-domain.atlassian.net/secure/projectavatar?size=large&pid=10001", + "24x24": "https://your-domain.atlassian.net/secure/projectavatar?size=small&pid=10001", + "16x16": "https://your-domain.atlassian.net/secure/projectavatar?size=xsmall&pid=10001", + "32x32": "https://your-domain.atlassian.net/secure/projectavatar?size=medium&pid=10001" + }, + "projectCategory": { + "self": "https://your-domain.atlassian.net/rest/api/2/projectCategory/10000", + "id": "10000", + "name": "FIRST", + "description": "First Project Category" + }, + "simplified": false, + "style": "classic", + "insight": { + "totalIssueCount": 100, + "lastIssueUpdateTime": "2020-03-31T05:45:24.792+0000" + } + } + ] + }' + end + + let_it_be(:empty_jira_projects_json) do + '{ + "self": "https://your-domain.atlassian.net/rest/api/2/project/search?startAt=0&maxResults=2", + "nextPage": "https://your-domain.atlassian.net/rest/api/2/project/search?startAt=2&maxResults=2", + "maxResults": 2, + "startAt": 0, + "total": 7, + "isLast": false, + "values": [] + }' + end + + let(:test_url) { "#{url}/rest/api/2/project/search?maxResults=50&query=&startAt=0" } + let(:start_at_20_url) { "#{url}/rest/api/2/project/search?maxResults=50&query=&startAt=20" } + let(:start_at_1_url) { "#{url}/rest/api/2/project/search?maxResults=50&query=&startAt=1" } + let(:max_results_1_url) { "#{url}/rest/api/2/project/search?maxResults=1&query=&startAt=0" } + + before do + WebMock.stub_request(:get, test_url).with(basic_auth: [username, password]) + .to_return(body: jira_projects_json, headers: { "Content-Type": "application/json" }) + WebMock.stub_request(:get, start_at_20_url).with(basic_auth: [username, password]) + .to_return(body: empty_jira_projects_json, headers: { "Content-Type": "application/json" }) + WebMock.stub_request(:get, start_at_1_url).with(basic_auth: [username, password]) + .to_return(body: jira_projects_json, headers: { "Content-Type": "application/json" }) + WebMock.stub_request(:get, max_results_1_url).with(basic_auth: [username, password]) + .to_return(body: jira_projects_json, headers: { "Content-Type": "application/json" }) + end +end