diff --git a/app/graphql/resolvers/projects/services_resolver.rb b/app/graphql/resolvers/projects/services_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..40c64c24513f3707bb931627d542d29091041d53 --- /dev/null +++ b/app/graphql/resolvers/projects/services_resolver.rb @@ -0,0 +1,39 @@ +# 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 diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index d82feffe441f54a0ed3f18dd2fcc9809cdcc9a57..3115a53e053dd5aa1097a2379ec35921e53e0597 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -199,6 +199,12 @@ class ProjectType < BaseObject 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 diff --git a/app/graphql/types/projects/service_type.rb b/app/graphql/types/projects/service_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..55dd828d4b8862eb7d73f97762d31369a9156952 --- /dev/null +++ b/app/graphql/types/projects/service_type.rb @@ -0,0 +1,29 @@ +# 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 diff --git a/app/graphql/types/projects/service_type_enum.rb b/app/graphql/types/projects/service_type_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..340fdff6b86aec761adf4112f70d93ce318e87a0 --- /dev/null +++ b/app/graphql/types/projects/service_type_enum.rb @@ -0,0 +1,13 @@ +# 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 diff --git a/app/graphql/types/projects/services/base_service_type.rb b/app/graphql/types/projects/services/base_service_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..5341ae2a864daa69d99e2e54992e329385a87e6b --- /dev/null +++ b/app/graphql/types/projects/services/base_service_type.rb @@ -0,0 +1,15 @@ +# 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 diff --git a/app/graphql/types/projects/services/jira_service_type.rb b/app/graphql/types/projects/services/jira_service_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..4fd9e61f5a4cb85a47323210e18eff6501319e60 --- /dev/null +++ b/app/graphql/types/projects/services/jira_service_type.rb @@ -0,0 +1,18 @@ +# 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 diff --git a/app/models/service.rb b/app/models/service.rb index 017c15468a28429e52893cc7c5415611f9ddc559..543869c71d6f3471ac9f85e6caa42c96dd7c4704 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -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 @@ def self.find_or_initialize_instances 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 diff --git a/changelogs/unreleased/graphql-expose-project-services.yml b/changelogs/unreleased/graphql-expose-project-services.yml new file mode 100644 index 0000000000000000000000000000000000000000..43edd33decf70ac1c89dde5dc4247e2e078e5e0e --- /dev/null +++ b/changelogs/unreleased/graphql-expose-project-services.yml @@ -0,0 +1,5 @@ +--- +title: Expose basic project services attributes through GraphQL +merge_request: 28234 +author: +type: added diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 50d5eeaf9ab09f6ca8ffafd46d62e21a83219695..6398d326007ed4d157612cf6e06c49e653e63ce2 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -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 @@ -6405,6 +6429,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 """ @@ -7632,6 +7691,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 """ diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index c7281fcc6387b3257783cd947fa1c01b9259818c..032b607dac9f0dc6f79df548aa863af3bd73c61a 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -408,6 +408,51 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "BaseService", + "description": null, + "fields": [ + { + "name": "active", + "description": "Indicates if the service is active", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": "Class name of the service", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Service", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Blob", @@ -12061,6 +12106,51 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "JiraService", + "description": null, + "fields": [ + { + "name": "active", + "description": "Indicates if the service is active", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": "Class name of the service", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Service", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Label", @@ -19123,6 +19213,79 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "services", + "description": "Project services", + "args": [ + { + "name": "active", + "description": "Indicates if the service is active", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "type", + "description": "Class name of the service", + "type": { + "kind": "ENUM", + "name": "ServiceType", + "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": "ServiceConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "sharedRunnersEnabled", "description": "Indicates if Shared Runners are enabled for the project", @@ -23000,6 +23163,383 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INTERFACE", + "name": "Service", + "description": null, + "fields": [ + { + "name": "active", + "description": "Indicates if the service is active", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": "Class name of the service", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "BaseService", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "JiraService", + "ofType": null + } + ] + }, + { + "kind": "OBJECT", + "name": "ServiceConnection", + "description": "The connection type for Service.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ServiceEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "INTERFACE", + "name": "Service", + "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": "ServiceEdge", + "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": "INTERFACE", + "name": "Service", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "ServiceType", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "ALERTS_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ASANA_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ASSEMBLA_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BAMBOO_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BUGZILLA_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BUILDKITE_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CAMPFIRE_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CUSTOM_ISSUE_TRACKER_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DISCORD_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DRONE_CI_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "EMAILS_ON_PUSH_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "EXTERNAL_WIKI_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FLOWDOCK_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "HANGOUTS_CHAT_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "HIPCHAT_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "IRKER_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "JIRA_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MATTERMOST_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MATTERMOST_SLASH_COMMANDS_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MICROSOFT_TEAMS_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PACKAGIST_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PIPELINES_EMAIL_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PIVOTALTRACKER_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PROMETHEUS_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PUSHOVER_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "REDMINE_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SLACK_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SLACK_SLASH_COMMANDS_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TEAMCITY_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UNIFY_CIRCUIT_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "YOUTRACK_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GITHUB_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "JENKINS_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "JENKINS_DEPRECATED_SERVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Snippet", diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index d1ddcb6435b47751c55d67f3342fb7e053648d9e..dbe98639d23492a77911c7b8b0d217f8d36ef22f 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -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 | diff --git a/ee/app/models/ee/service.rb b/ee/app/models/ee/service.rb index e6753d8cbf6d2601483b31128035864d859b0da7..37b4335c6d99c8ce0033b089dba345affdf5419c 100644 --- a/ee/app/models/ee/service.rb +++ b/ee/app/models/ee/service.rb @@ -4,22 +4,39 @@ module EE module Service extend ActiveSupport::Concern + 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 :available_services_names - def available_services_names - ee_service_names = %w[ - github - jenkins - jenkins_deprecated - ] + 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? - if ::Gitlab.dev_env_or_com? - ee_service_names.push('gitlab_slack_application') - end + super + ee_dev_services_names + end + + private + + 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 diff --git a/spec/graphql/resolvers/projects/services_resolver_spec.rb b/spec/graphql/resolvers/projects/services_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..00045442ea090f213850d6002420d8a37c9702d2 --- /dev/null +++ b/spec/graphql/resolvers/projects/services_resolver_spec.rb @@ -0,0 +1,72 @@ +# 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 diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb index 0c8be50ed90aec7a29c4b6dab9efc4d36ec167ce..6ea852190c984d603cd5510f72fb141b7eb17c6f 100644 --- a/spec/graphql/types/project_type_spec.rb +++ b/spec/graphql/types/project_type_spec.rb @@ -24,7 +24,7 @@ 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 @@ 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 diff --git a/spec/graphql/types/projects/base_service_type_spec.rb b/spec/graphql/types/projects/base_service_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..bda6022bf797fadc06a973289022b1b5eebcd9bd --- /dev/null +++ b/spec/graphql/types/projects/base_service_type_spec.rb @@ -0,0 +1,13 @@ +# 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 diff --git a/spec/graphql/types/projects/jira_service_type_spec.rb b/spec/graphql/types/projects/jira_service_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7f8fa6538e99f6d0283015c5b6890d7a168bf5eb --- /dev/null +++ b/spec/graphql/types/projects/jira_service_type_spec.rb @@ -0,0 +1,13 @@ +# 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 diff --git a/spec/graphql/types/projects/service_type_spec.rb b/spec/graphql/types/projects/service_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ad30a4008bc835436adcf84656d91ec0e3bef892 --- /dev/null +++ b/spec/graphql/types/projects/service_type_spec.rb @@ -0,0 +1,16 @@ +# 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 diff --git a/spec/graphql/types/projects/services_enum_spec.rb b/spec/graphql/types/projects/services_enum_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..aac4aae4f69a453821cacf8dd6df9277e977525b --- /dev/null +++ b/spec/graphql/types/projects/services_enum_spec.rb @@ -0,0 +1,15 @@ +# 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 diff --git a/spec/requests/api/graphql/project/base_service_spec.rb b/spec/requests/api/graphql/project/base_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8199f331fbf4d776222ddbcdaf89de8346d916a0 --- /dev/null +++ b/spec/requests/api/graphql/project/base_service_spec.rb @@ -0,0 +1,47 @@ +# 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 diff --git a/spec/requests/api/graphql/project/jira_service_spec.rb b/spec/requests/api/graphql/project/jira_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4ac598b789fc24852ab232ed174d5e727e74e425 --- /dev/null +++ b/spec/requests/api/graphql/project/jira_service_spec.rb @@ -0,0 +1,44 @@ +# 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 diff --git a/spec/requests/api/graphql/project_query_spec.rb b/spec/requests/api/graphql/project_query_spec.rb index fbb22958d5145b07b06889c890d28244e18fc9e2..035894c802244d380c4202f91e926d30c741b210 100644 --- a/spec/requests/api/graphql/project_query_spec.rb +++ b/spec/requests/api/graphql/project_query_spec.rb @@ -9,7 +9,27 @@ let(:current_user) { create(:user) } let(:query) do - graphql_query_for('project', 'fullPath' => project.full_path) + 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 diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index 1bb942ff39b3931a53b229f1ae8106994f8470c5..539aced9f30da889e44aa936de8725eb63b01e7c 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -149,7 +149,7 @@ def wrap_fields(fields) 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 @@ def all_graphql_fields_for(class_name, parent_types = Set.new, max_depth: 3) 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) diff --git a/spec/support/shared_examples/graphql/projects/services_resolver_shared_examples.rb b/spec/support/shared_examples/graphql/projects/services_resolver_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..4bed322564a3145371262c018c9c789d1a06ad9b --- /dev/null +++ b/spec/support/shared_examples/graphql/projects/services_resolver_shared_examples.rb @@ -0,0 +1,15 @@ +# 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 diff --git a/spec/support/shared_examples/requests/api/graphql/projects/services_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/projects/services_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..246f1850c3cd72aea16ec2315693993c0d5364a3 --- /dev/null +++ b/spec/support/shared_examples/requests/api/graphql/projects/services_shared_examples.rb @@ -0,0 +1,21 @@ +# 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