Commit 5775affe authored by charlie ablett's avatar charlie ablett

Merge branch 'graphql-expose-project-services' into 'master'

Expose project services through GraphQL

See merge request gitlab-org/gitlab!28234
parents 60249e7d d64de9e4
# frozen_string_literal: true
module Resolvers
module Projects
class ServicesResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
argument :active,
GraphQL::BOOLEAN_TYPE,
required: false,
description: 'Indicates if the service is active'
argument :type,
Types::Projects::ServiceTypeEnum,
required: false,
description: 'Class name of the service'
alias_method :project, :object
def resolve(**args)
authorize!(project)
services(args[:active], args[:type])
end
def authorized_resource?(project)
Ability.allowed?(context[:current_user], :admin_project, project)
end
private
def services(active, type)
servs = project.services
servs = servs.by_active_flag(active) unless active.nil?
servs = servs.by_type(type) unless type.blank?
servs
end
end
end
end
......@@ -199,6 +199,12 @@ module Types
null: true,
description: 'Jira imports into the project',
resolver: Resolvers::Projects::JiraImportsResolver
field :services,
Types::Projects::ServiceType.connection_type,
null: true,
description: 'Project services',
resolver: Resolvers::Projects::ServicesResolver
end
end
......
# frozen_string_literal: true
module Types
module Projects
module ServiceType
include Types::BaseInterface
graphql_name 'Service'
# TODO: Add all the fields that we want to expose for the project services intergrations
# https://gitlab.com/gitlab-org/gitlab/-/issues/213088
field :type, GraphQL::STRING_TYPE, null: true,
description: 'Class name of the service'
field :active, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if the service is active'
definition_methods do
def resolve_type(object, context)
if object.is_a?(::JiraService)
Types::Projects::Services::JiraServiceType
else
Types::Projects::Services::BaseServiceType
end
end
end
orphan_types Types::Projects::Services::BaseServiceType, Types::Projects::Services::JiraServiceType
end
end
end
# frozen_string_literal: true
module Types
module Projects
class ServiceTypeEnum < BaseEnum
graphql_name 'ServiceType'
::Service.services_types.each do |service_type|
value service_type.underscore.upcase, value: service_type
end
end
end
end
# frozen_string_literal: true
module Types
module Projects
module Services
class BaseServiceType < BaseObject
graphql_name 'BaseService'
implements(Types::Projects::ServiceType)
authorize :admin_project
end
end
end
end
# frozen_string_literal: true
module Types
module Projects
module Services
class JiraServiceType < BaseObject
graphql_name 'JiraService'
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
end
end
end
end
......@@ -8,6 +8,17 @@ class Service < ApplicationRecord
include ProjectServicesLoggable
include DataFields
SERVICE_NAMES = %w[
alerts asana assembla bamboo bugzilla buildkite campfire custom_issue_tracker discord
drone_ci emails_on_push external_wiki flowdock hangouts_chat hipchat irker jira
mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email
pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit youtrack
].freeze
DEV_SERVICE_NAMES = %w[
mock_ci mock_deployment mock_monitoring
].freeze
serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize
default_value_for :active, false
......@@ -46,6 +57,7 @@ class Service < ApplicationRecord
scope :active, -> { where(active: true) }
scope :without_defaults, -> { where(default: false) }
scope :by_type, -> (type) { where(type: type) }
scope :by_active_flag, -> (flag) { where(active: flag) }
scope :templates, -> { where(template: true, type: available_services_types) }
scope :instances, -> { where(instance: true, type: available_services_types) }
......@@ -295,51 +307,30 @@ class Service < ApplicationRecord
end
def self.available_services_names
service_names = %w[
alerts
asana
assembla
bamboo
bugzilla
buildkite
campfire
custom_issue_tracker
discord
drone_ci
emails_on_push
external_wiki
flowdock
hangouts_chat
hipchat
irker
jira
mattermost
mattermost_slash_commands
microsoft_teams
packagist
pipelines_email
pivotaltracker
prometheus
pushover
redmine
slack
slack_slash_commands
teamcity
unify_circuit
youtrack
]
if Rails.env.development?
service_names += %w[mock_ci mock_deployment mock_monitoring]
end
service_names = services_names
service_names += dev_services_names
service_names.sort_by(&:downcase)
end
def self.services_names
SERVICE_NAMES
end
def self.dev_services_names
return [] unless Rails.env.development?
DEV_SERVICE_NAMES
end
def self.available_services_types
available_services_names.map { |service_name| "#{service_name}_service".camelize }
end
def self.services_types
services_names.map { |service_name| "#{service_name}_service".camelize }
end
def self.build_from_template(project_id, template)
service = template.dup
......
---
title: Expose basic project services attributes through GraphQL
merge_request: 28234
author:
type: added
......@@ -138,6 +138,18 @@ type AwardEmoji {
user: User!
}
type BaseService implements Service {
"""
Indicates if the service is active
"""
active: Boolean
"""
Class name of the service
"""
type: String
}
type Blob implements Entry {
"""
Flat path of the entry
......@@ -4246,6 +4258,18 @@ type JiraImportStartPayload {
jiraImport: JiraImport
}
type JiraService implements Service {
"""
Indicates if the service is active
"""
active: Boolean
"""
Class name of the service
"""
type: String
}
type Label {
"""
Background color of the label
......@@ -6410,6 +6434,41 @@ type Project {
"""
serviceDeskEnabled: Boolean
"""
Project services
"""
services(
"""
Indicates if the service is active
"""
active: Boolean
"""
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
"""
Class name of the service
"""
type: ServiceType
): ServiceConnection
"""
Indicates if Shared Runners are enabled for the project
"""
......@@ -7637,6 +7696,90 @@ type SentryErrorTags {
logger: String
}
interface Service {
"""
Indicates if the service is active
"""
active: Boolean
"""
Class name of the service
"""
type: String
}
"""
The connection type for Service.
"""
type ServiceConnection {
"""
A list of edges.
"""
edges: [ServiceEdge]
"""
A list of nodes.
"""
nodes: [Service]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type ServiceEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: Service
}
enum ServiceType {
ALERTS_SERVICE
ASANA_SERVICE
ASSEMBLA_SERVICE
BAMBOO_SERVICE
BUGZILLA_SERVICE
BUILDKITE_SERVICE
CAMPFIRE_SERVICE
CUSTOM_ISSUE_TRACKER_SERVICE
DISCORD_SERVICE
DRONE_CI_SERVICE
EMAILS_ON_PUSH_SERVICE
EXTERNAL_WIKI_SERVICE
FLOWDOCK_SERVICE
GITHUB_SERVICE
HANGOUTS_CHAT_SERVICE
HIPCHAT_SERVICE
IRKER_SERVICE
JENKINS_DEPRECATED_SERVICE
JENKINS_SERVICE
JIRA_SERVICE
MATTERMOST_SERVICE
MATTERMOST_SLASH_COMMANDS_SERVICE
MICROSOFT_TEAMS_SERVICE
PACKAGIST_SERVICE
PIPELINES_EMAIL_SERVICE
PIVOTALTRACKER_SERVICE
PROMETHEUS_SERVICE
PUSHOVER_SERVICE
REDMINE_SERVICE
SLACK_SERVICE
SLACK_SLASH_COMMANDS_SERVICE
TEAMCITY_SERVICE
UNIFY_CIRCUIT_SERVICE
YOUTRACK_SERVICE
}
"""
Represents a snippet entry
"""
......
......@@ -49,6 +49,13 @@ An emoji awarded by a user.
| `unicodeVersion` | String! | The unicode version for this emoji |
| `user` | User! | The user who awarded the emoji |
## BaseService
| Name | Type | Description |
| --- | ---- | ---------- |
| `active` | Boolean | Indicates if the service is active |
| `type` | String | Class name of the service |
## Blob
| Name | Type | Description |
......@@ -624,6 +631,13 @@ Autogenerated return type of JiraImportStart
| `errors` | String! => Array | Reasons why the mutation failed. |
| `jiraImport` | JiraImport | The Jira import data after mutation |
## JiraService
| Name | Type | Description |
| --- | ---- | ---------- |
| `active` | Boolean | Indicates if the service is active |
| `type` | String | Class name of the service |
## Label
| Name | Type | Description |
......
......@@ -4,22 +4,39 @@ module EE
module Service
extend ActiveSupport::Concern
class_methods do
extend ::Gitlab::Utils::Override
override :available_services_names
def available_services_names
ee_service_names = %w[
EE_SERVICE_NAMES = %w[
github
jenkins
jenkins_deprecated
]
].freeze
EE_DEV_SERVICE_NAMES = %w[
gitlab_slack_application
].freeze
class_methods do
extend ::Gitlab::Utils::Override
override :services_names
def services_names
super + ee_services_names
end
override :dev_services_names
def dev_services_names
return [] unless ::Gitlab.dev_env_or_com?
super + ee_dev_services_names
end
private
if ::Gitlab.dev_env_or_com?
ee_service_names.push('gitlab_slack_application')
def ee_services_names
EE_SERVICE_NAMES
end
(super + ee_service_names).sort_by(&:downcase)
def ee_dev_services_names
EE_DEV_SERVICE_NAMES
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::Projects::ServicesResolver do
include GraphqlHelpers
describe '#resolve' do
let_it_be(:user) { create(:user) }
context 'when project does not have services' do
let_it_be(:project) { create(:project, :private) }
context 'when user cannot access services' do
context 'when anonymous user' do
it_behaves_like 'cannot access project services'
end
context 'when user developer' do
before do
project.add_developer(user)
end
it_behaves_like 'cannot access project services'
end
end
context 'when user can read project services' do
before do
project.add_maintainer(user)
end
it_behaves_like 'no project services'
end
end
context 'when project has services' do
let_it_be(:project) { create(:project, :private) }
let_it_be(:jira_service) { create(:jira_service, project: project) }
context 'when user cannot access services' do
context 'when anonymous user' do
it_behaves_like 'cannot access project services'
end
context 'when user developer' do
before do
project.add_developer(user)
end
it_behaves_like 'cannot access project services'
end
end
context 'when user can read project services' do
before do
project.add_maintainer(user)
end
it 'returns project services' do
services = resolve_services
expect(services.size).to eq 1
end
end
end
end
def resolve_services(args = {}, context = { current_user: user })
resolve(described_class, obj: project, args: args, ctx: context)
end
end
......@@ -24,7 +24,7 @@ describe GitlabSchema.types['Project'] do
namespace group statistics repository merge_requests merge_request issues
issue pipelines removeSourceBranchAfterMerge sentryDetailedError snippets
grafanaIntegration autocloseReferencedIssues suggestion_commit_message environments
boards jira_import_status jira_imports
boards jira_import_status jira_imports services
]
expect(described_class).to include_graphql_fields(*expected_fields)
......@@ -84,4 +84,16 @@ describe GitlabSchema.types['Project'] do
it { is_expected.to have_graphql_type(Types::BoardType.connection_type) }
end
describe 'jira_imports field' do
subject { described_class.fields['jiraImports'] }
it { is_expected.to have_graphql_type(Types::JiraImportType.connection_type) }
end
describe 'services field' do
subject { described_class.fields['services'] }
it { is_expected.to have_graphql_type(Types::Projects::ServiceType.connection_type) }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['BaseService'] do
it { expect(described_class.graphql_name).to eq('BaseService') }
it 'has basic expected fields' do
expect(described_class).to have_graphql_fields(:type, :active)
end
it { expect(described_class).to require_graphql_authorizations(:admin_project) }
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['JiraService'] do
it { expect(described_class.graphql_name).to eq('JiraService') }
it 'has basic expected fields' do
expect(described_class).to have_graphql_fields(:type, :active)
end
it { expect(described_class).to require_graphql_authorizations(:admin_project) }
end
# frozen_string_literal: true
require 'spec_helper'
describe Types::Projects::ServiceType do
it { expect(described_class).to have_graphql_fields(:type, :active) }
describe ".resolve_type" do
it 'resolves the corresponding type for objects' do
expect(described_class.resolve_type(build(:jira_service), {})).to eq(Types::Projects::Services::JiraServiceType)
expect(described_class.resolve_type(build(:service), {})).to eq(Types::Projects::Services::BaseServiceType)
expect(described_class.resolve_type(build(:alerts_service), {})).to eq(Types::Projects::Services::BaseServiceType)
expect(described_class.resolve_type(build(:custom_issue_tracker_service), {})).to eq(Types::Projects::Services::BaseServiceType)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['ServiceType'] do
it { expect(described_class.graphql_name).to eq('ServiceType') }
it 'exposes all the existing project services' do
expect(described_class.values.keys).to match_array(available_services_enum)
end
end
def available_services_enum
::Service.services_types.map(&:underscore).map(&:upcase)
end
# frozen_string_literal: true
require 'spec_helper'
describe 'query Jira service' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:jira_service) { create(:jira_service, project: project) }
let_it_be(:bugzilla_service) { create(:bugzilla_service, project: project) }
let_it_be(:redmine_service) { create(:redmine_service, project: project) }
let(:query) do
%(
query {
project(fullPath: "#{project.full_path}") {
services {
nodes {
type
active
}
}
}
}
)
end
let(:services) { graphql_data.dig('project', 'services', 'nodes')}
it_behaves_like 'unauthorized users cannot read services'
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 imports' do
service_types = services.map { |s| s['type'] }
expect(service_types).to match_array(%w(BugzillaService JiraService RedmineService))
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'query Jira service' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:jira_service) { create(:jira_service, project: project) }
let(:query) do
%(
query {
project(fullPath: "#{project.full_path}") {
services(active: true, type: JIRA_SERVICE) {
nodes {
type
}
}
}
}
)
end
let(:services) { graphql_data.dig('project', 'services', 'nodes')}
it_behaves_like 'unauthorized users cannot read services'
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 imports' do
service = services.first
expect(service['type']).to eq('JiraService')
end
end
end
......@@ -9,9 +9,29 @@ describe 'getting project information' do
let(:current_user) { create(:user) }
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
all_graphql_fields_for('project'.to_s.classify, excluded: %w(jiraImports services))
)
end
context 'when the user has full access to the project' do
let(:full_access_query) do
graphql_query_for('project', 'fullPath' => project.full_path)
end
before do
project.add_maintainer(current_user)
end
it 'includes the project' do
post_graphql(query, current_user: current_user)
expect(graphql_data['project']).not_to be_nil
end
end
context 'when the user has access to the project' do
before do
project.add_developer(current_user)
......
......@@ -149,7 +149,7 @@ module GraphqlHelpers
FIELDS
end
def all_graphql_fields_for(class_name, parent_types = Set.new, max_depth: 3)
def all_graphql_fields_for(class_name, parent_types = Set.new, max_depth: 3, excluded: [])
# pulling _all_ fields can generate a _huge_ query (like complexity 180,000),
# and significantly increase spec runtime. so limit the depth by default
return if max_depth <= 0
......@@ -165,6 +165,7 @@ module GraphqlHelpers
type.fields.map do |name, field|
# We can't guess arguments, so skip fields that require them
next if required_arguments?(field)
next if excluded.include?(name)
singular_field_type = field_type(field)
......
# frozen_string_literal: true
shared_examples 'no project services' do
it 'returns empty collection' do
expect(resolve_services).to eq []
end
end
shared_examples 'cannot access project services' do
it 'raises error' do
expect do
resolve_services
end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
# frozen_string_literal: true
shared_examples 'unauthorized users cannot read services' do
before do
post_graphql(query, current_user: current_user)
end
context 'when anonymous user' do
let(:current_user) { nil }
it { expect(services).to be nil }
end
context 'when user developer' do
before do
project.add_developer(current_user)
end
it { expect(services).to be nil }
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