Commit 93b1d300 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'jira-projects-api-wrapper' into 'master'

Add a GraphQL wrapper for Jira projects REST API

See merge request gitlab-org/gitlab!28190
parents 041f190f f66a7bb5
# 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
# 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
......@@ -9,9 +9,14 @@ module Types
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
......
......@@ -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 @@ class JiraService < IssueTrackerService
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?
......
......@@ -7,6 +7,7 @@ module Gitlab
included do
include ApplicationWorker
include ProjectImportOptions
include Gitlab::JiraImport::QueueOptions
end
......
---
title: Add a GraphQL endpoint to fetch Jira projects through its REST API
merge_request: 28190
author:
type: changed
......@@ -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
"""
......
......@@ -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",
......@@ -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
......
# 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
# 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
......@@ -6,7 +6,7 @@ describe GitlabSchema.types['JiraService'] do
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) }
......
......@@ -816,4 +816,85 @@ describe JiraService do
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
# 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
# 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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment