Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
1e1012d3d2
commit
b6bf52d3e2
|
|
@ -5231,7 +5231,6 @@ RSpec/FeatureCategory:
|
|||
- 'spec/rubocop/cop/migration/schema_addition_methods_no_post_spec.rb'
|
||||
- 'spec/rubocop/cop/migration/sidekiq_queue_migrate_spec.rb'
|
||||
- 'spec/rubocop/cop/migration/timestamps_spec.rb'
|
||||
- 'spec/rubocop/cop/migration/with_lock_retries_disallowed_method_spec.rb'
|
||||
- 'spec/rubocop/cop/migration/with_lock_retries_with_change_spec.rb'
|
||||
- 'spec/rubocop/cop/performance/active_record_subtransaction_methods_spec.rb'
|
||||
- 'spec/rubocop/cop/performance/active_record_subtransactions_spec.rb'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Mutations
|
||||
module Ci
|
||||
module Catalog
|
||||
module Resources
|
||||
class Create < BaseMutation
|
||||
graphql_name 'CatalogResourcesCreate'
|
||||
|
||||
argument :project_path, GraphQL::Types::ID,
|
||||
required: true,
|
||||
description: 'Project to convert to a catalog resource.'
|
||||
|
||||
authorize :add_catalog_resource
|
||||
|
||||
def resolve(project_path:)
|
||||
project = authorized_find!(project_path: project_path)
|
||||
response = ::Ci::Catalog::AddResourceService.new(project, current_user).execute
|
||||
|
||||
errors = response.success? ? [] : [response.message]
|
||||
|
||||
{
|
||||
errors: errors
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_object(project_path:)
|
||||
Project.find_by_full_path(project_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Resolvers
|
||||
module Ci
|
||||
module Catalog
|
||||
class ResourceResolver < BaseResolver
|
||||
include Gitlab::Graphql::Authorize::AuthorizeResource
|
||||
|
||||
authorize :read_code
|
||||
|
||||
type ::Types::Ci::Catalog::ResourceType, null: true
|
||||
|
||||
argument :id, ::Types::GlobalIDType[::Ci::Catalog::Resource],
|
||||
required: true,
|
||||
description: 'CI/CD Catalog resource global ID.'
|
||||
|
||||
def resolve(id:)
|
||||
catalog_resource = ::Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(id))
|
||||
|
||||
authorize!(catalog_resource&.project)
|
||||
|
||||
catalog_resource
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Resolvers
|
||||
module Ci
|
||||
module Catalog
|
||||
class ResourcesResolver < BaseResolver
|
||||
include LooksAhead
|
||||
|
||||
type ::Types::Ci::Catalog::ResourceType.connection_type, null: true
|
||||
|
||||
argument :sort, ::Types::Ci::Catalog::ResourceSortEnum,
|
||||
required: false,
|
||||
description: 'Sort Catalog Resources by given criteria.'
|
||||
|
||||
argument :project_path, GraphQL::Types::ID,
|
||||
required: false,
|
||||
description: 'Project with the namespace catalog.'
|
||||
|
||||
def resolve_with_lookahead(project_path:, sort: nil)
|
||||
project = Project.find_by_full_path(project_path)
|
||||
|
||||
apply_lookahead(
|
||||
::Ci::Catalog::Listing.new(project.root_namespace, context[:current_user]).resources(sort: sort)
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def preloads
|
||||
{
|
||||
web_path: { project: { namespace: :route } },
|
||||
readme_html: { project: :route }
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Resolvers
|
||||
module Ci
|
||||
module Catalog
|
||||
class VersionsResolver < ::Resolvers::ReleasesResolver
|
||||
type Types::ReleaseType.connection_type, null: true
|
||||
|
||||
# This allows a maximum of 1 call to the field that uses this resolver. If the
|
||||
# field is evaluated on more than one node, it causes performance degradation.
|
||||
extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1
|
||||
|
||||
private
|
||||
|
||||
def get_project
|
||||
object.respond_to?(:project) ? object.project : object
|
||||
end
|
||||
|
||||
# Override the aliased method in ReleasesResolver
|
||||
alias_method :project, :get_project
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Types
|
||||
module Ci
|
||||
module Catalog
|
||||
class ResourceSortEnum < SortEnum
|
||||
graphql_name 'CiCatalogResourceSort'
|
||||
description 'Values for sorting catalog resources'
|
||||
|
||||
value 'NAME_ASC', 'Name by ascending order.', value: :name_asc
|
||||
value 'NAME_DESC', 'Name by descending order.', value: :name_desc
|
||||
value 'LATEST_RELEASED_AT_ASC', 'Latest release date by ascending order.', value: :latest_released_at_asc
|
||||
value 'LATEST_RELEASED_AT_DESC', 'Latest release date by descending order.', value: :latest_released_at_desc
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Types
|
||||
module Ci
|
||||
module Catalog
|
||||
# rubocop: disable Graphql/AuthorizeTypes
|
||||
class ResourceType < BaseObject
|
||||
graphql_name 'CiCatalogResource'
|
||||
|
||||
connection_type_class Types::CountableConnectionType
|
||||
|
||||
field :open_issues_count, GraphQL::Types::Int, null: false,
|
||||
description: 'Count of open issues that belong to the the catalog resource.',
|
||||
alpha: { milestone: '16.3' }
|
||||
|
||||
field :open_merge_requests_count, GraphQL::Types::Int, null: false,
|
||||
description: 'Count of open merge requests that belong to the the catalog resource.',
|
||||
alpha: { milestone: '16.3' }
|
||||
|
||||
field :id, GraphQL::Types::ID, null: false, description: 'ID of the catalog resource.',
|
||||
alpha: { milestone: '15.11' }
|
||||
|
||||
field :name, GraphQL::Types::String, null: true, description: 'Name of the catalog resource.',
|
||||
alpha: { milestone: '15.11' }
|
||||
|
||||
field :description, GraphQL::Types::String, null: true, description: 'Description of the catalog resource.',
|
||||
alpha: { milestone: '15.11' }
|
||||
|
||||
field :icon, GraphQL::Types::String, null: true, description: 'Icon for the catalog resource.',
|
||||
method: :avatar_path, alpha: { milestone: '15.11' }
|
||||
|
||||
field :web_path, GraphQL::Types::String, null: true, description: 'Web path of the catalog resource.',
|
||||
alpha: { milestone: '16.1' }
|
||||
|
||||
field :versions, Types::ReleaseType.connection_type, null: true,
|
||||
description: 'Versions of the catalog resource. This field can only be ' \
|
||||
'resolved for one catalog resource in any single request.',
|
||||
resolver: Resolvers::Ci::Catalog::VersionsResolver,
|
||||
alpha: { milestone: '16.2' }
|
||||
|
||||
field :latest_version, Types::ReleaseType, null: true, description: 'Latest version of the catalog resource.',
|
||||
alpha: { milestone: '16.1' }
|
||||
|
||||
field :latest_released_at, Types::TimeType, null: true,
|
||||
description: "Release date of the catalog resource's latest version.",
|
||||
alpha: { milestone: '16.5' }
|
||||
|
||||
field :star_count, GraphQL::Types::Int, null: false,
|
||||
description: 'Number of times the catalog resource has been starred.',
|
||||
alpha: { milestone: '16.1' }
|
||||
|
||||
field :forks_count, GraphQL::Types::Int, null: false, calls_gitaly: true,
|
||||
description: 'Number of times the catalog resource has been forked.',
|
||||
alpha: { milestone: '16.1' }
|
||||
|
||||
field :root_namespace, Types::NamespaceType, null: true,
|
||||
description: 'Root namespace of the catalog resource.',
|
||||
alpha: { milestone: '16.1' }
|
||||
|
||||
markdown_field :readme_html, null: false,
|
||||
alpha: { milestone: '16.1' }
|
||||
|
||||
def open_issues_count
|
||||
BatchLoader::GraphQL.wrap(object.project.open_issues_count)
|
||||
end
|
||||
|
||||
def open_merge_requests_count
|
||||
BatchLoader::GraphQL.wrap(object.project.open_merge_requests_count)
|
||||
end
|
||||
|
||||
def web_path
|
||||
::Gitlab::Routing.url_helpers.project_path(object.project)
|
||||
end
|
||||
|
||||
def latest_version
|
||||
BatchLoader::GraphQL.for(object.project).batch do |projects, loader|
|
||||
latest_releases = ReleasesFinder.new(projects, current_user, latest: true).execute
|
||||
|
||||
latest_releases.index_by(&:project).each do |project, latest_release|
|
||||
loader.call(project, latest_release)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def forks_count
|
||||
BatchLoader::GraphQL.wrap(object.forks_count)
|
||||
end
|
||||
|
||||
def root_namespace
|
||||
BatchLoader::GraphQL.for(object.project_id).batch do |project_ids, loader|
|
||||
projects = Project.id_in(project_ids)
|
||||
|
||||
# This preloader uses traversal_ids to obtain Group-type root namespaces.
|
||||
# It also preloads each project's immediate parent namespace, which effectively
|
||||
# preloads the User-type root namespaces since they cannot be nested (parent == root).
|
||||
Preloaders::ProjectRootAncestorPreloader.new(projects, :group).execute
|
||||
root_namespaces = projects.map(&:root_ancestor)
|
||||
|
||||
# NamespaceType requires the `:read_namespace` ability. We must preload the policy for
|
||||
# Group-type namespaces to avoid N+1 queries caused by the authorization requests.
|
||||
group_root_namespaces = root_namespaces.select { |n| n.type == ::Group.sti_name }
|
||||
Preloaders::GroupPolicyPreloader.new(group_root_namespaces, current_user).execute
|
||||
|
||||
# For User-type namespaces, the authorization request requires preloading the owner objects.
|
||||
user_root_namespaces = root_namespaces.select { |n| n.type == ::Namespaces::UserNamespace.sti_name }
|
||||
ActiveRecord::Associations::Preloader.new(records: user_root_namespaces, associations: :owner).call
|
||||
|
||||
projects.each { |project| loader.call(project.id, project.root_ancestor) }
|
||||
end
|
||||
end
|
||||
|
||||
def readme_html_resolver
|
||||
markdown_context = context.to_h.dup.merge(project: object.project)
|
||||
::MarkupHelper.markdown(object.project.repository.readme&.data, markdown_context)
|
||||
end
|
||||
end
|
||||
# rubocop: enable Graphql/AuthorizeTypes
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -39,6 +39,7 @@ module Types
|
|||
mount_mutation Mutations::Boards::Lists::Update
|
||||
mount_mutation Mutations::Boards::Lists::Destroy
|
||||
mount_mutation Mutations::Branches::Create, calls_gitaly: true
|
||||
mount_mutation Mutations::Ci::Catalog::Resources::Create, alpha: { milestone: '15.11' }
|
||||
mount_mutation Mutations::Clusters::Agents::Create
|
||||
mount_mutation Mutations::Clusters::Agents::Delete
|
||||
mount_mutation Mutations::Clusters::AgentTokens::Create
|
||||
|
|
|
|||
|
|
@ -21,6 +21,20 @@ module Types
|
|||
required: true, description: 'Global ID of the CI stage.'
|
||||
end
|
||||
|
||||
field :ci_catalog_resources,
|
||||
::Types::Ci::Catalog::ResourceType.connection_type,
|
||||
null: true,
|
||||
alpha: { milestone: '15.11' },
|
||||
description: 'All CI/CD Catalog resources under a common namespace, visible to an authorized user',
|
||||
resolver: ::Resolvers::Ci::Catalog::ResourcesResolver
|
||||
|
||||
field :ci_catalog_resource,
|
||||
::Types::Ci::Catalog::ResourceType,
|
||||
null: true,
|
||||
alpha: { milestone: '16.1' },
|
||||
description: 'A single CI/CD Catalog resource visible to an authorized user',
|
||||
resolver: ::Resolvers::Ci::Catalog::ResourceResolver
|
||||
|
||||
field :ci_variables,
|
||||
Types::Ci::InstanceVariableType.connection_type,
|
||||
null: true,
|
||||
|
|
|
|||
|
|
@ -303,6 +303,8 @@ class ProjectPolicy < BasePolicy
|
|||
enable :set_show_diff_preview_in_email
|
||||
enable :set_warn_about_potentially_unwanted_characters
|
||||
enable :manage_owners
|
||||
|
||||
enable :add_catalog_resource
|
||||
end
|
||||
|
||||
rule { can?(:guest_access) }.policy do
|
||||
|
|
@ -908,10 +910,6 @@ class ProjectPolicy < BasePolicy
|
|||
enable :read_namespace_catalog
|
||||
end
|
||||
|
||||
rule { can?(:owner_access) & namespace_catalog_available }.policy do
|
||||
enable :add_catalog_resource
|
||||
end
|
||||
|
||||
rule { model_registry_enabled }.policy do
|
||||
enable :read_model_registry
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Ci
|
||||
module Catalog
|
||||
class AddResourceService
|
||||
include Gitlab::Allowable
|
||||
|
||||
attr_reader :project, :current_user
|
||||
|
||||
def initialize(project, user)
|
||||
@current_user = user
|
||||
@project = project
|
||||
end
|
||||
|
||||
def execute
|
||||
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :add_catalog_resource, project)
|
||||
|
||||
validation_response = Ci::Catalog::Resources::ValidateService.new(project, project.default_branch).execute
|
||||
|
||||
if validation_response.success?
|
||||
create_catalog_resource
|
||||
else
|
||||
ServiceResponse.error(message: validation_response.message)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_catalog_resource
|
||||
catalog_resource = Ci::Catalog::Resource.new(project: project)
|
||||
|
||||
if catalog_resource.valid?
|
||||
catalog_resource.save!
|
||||
ServiceResponse.success(payload: catalog_resource)
|
||||
else
|
||||
ServiceResponse.error(message: catalog_resource.errors.full_messages.join(', '))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -2142,9 +2142,14 @@ Example response:
|
|||
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/131923) in GitLab 16.5.
|
||||
|
||||
Use this API to create a new personal access token for the currently authenticated user.
|
||||
For security purposes, the scopes are limited to only `k8s_proxy` and by default the token will expire by
|
||||
the end of the day it was created at.
|
||||
Token values are returned once so, make sure you save it as you can't access it again.
|
||||
For security purposes, the token:
|
||||
|
||||
- Is limited to the [`k8s_proxy` scope](../user/profile/personal_access_tokens.md#personal-access-token-scopes).
|
||||
This scope grants permission to perform Kubernetes API calls using the agent for Kubernetes.
|
||||
- By default, expires at the end of the day it was created on.
|
||||
|
||||
Token values are returned once, so make sure you save the token as you cannot access
|
||||
it again.
|
||||
|
||||
```plaintext
|
||||
POST /user/personal_access_tokens
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ The following GitLab projects use TypeScript:
|
|||
- [`gitlab-web-ide`](https://gitlab.com/gitlab-org/gitlab-web-ide/)
|
||||
- [`gitlab-vscode-extension`](https://gitlab.com/gitlab-org/gitlab-vscode-extension/)
|
||||
- [`gitlab-language-server-for-code-suggestions`](https://gitlab.com/gitlab-org/editor-extensions/gitlab-language-server-for-code-suggestions)
|
||||
- [`gitlab-org/cluster-integration/javascript-client`](https://gitlab.com/gitlab-org/cluster-integration/javascript-client)
|
||||
|
||||
## Recommended configurations
|
||||
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
---
|
||||
stage: none
|
||||
group: unassigned
|
||||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
|
||||
description: 'Learn how to use and administer GitLab, the most scalable Git-based fully integrated platform for software development.'
|
||||
---
|
||||
|
||||
# GitLab: The DevSecOps platform
|
||||
|
||||
DevSecOps is a combination of development, security, and operations.
|
||||
It is an approach to software development that integrates security throughout the development lifecycle.
|
||||
|
||||
## DevSecOps compared to DevOps
|
||||
|
||||
DevOps combines development and operations, with the intent to increase the efficiency,
|
||||
speed, and security of software development and delivery.
|
||||
|
||||
DevOps means working together to conceive, build, and deliver secure software at top speed.
|
||||
DevOps practices include automation, collaboration, fast feedback, and iterative improvement.
|
||||
|
||||
DevSecOps is an evolution of DevOps. DevSecOps includes application security practices in every stage of software development.
|
||||
|
||||
Throughout the development process, tools and methods protect and monitor your live applications.
|
||||
New attack surfaces, like containers and orchestrators, must also be monitored and protected.
|
||||
DevSecOps tools automate security workflows to create an adaptable process for your development
|
||||
and security teams, improving collaboration and breaking down silos.
|
||||
By embedding security into the software development lifecycle, you can consistently secure fast-moving
|
||||
and iterative processes, improving efficiency without sacrificing quality.
|
||||
|
||||
## DevSecOps fundamentals
|
||||
|
||||
DevSecOps fundamentals include:
|
||||
|
||||
- Automation
|
||||
- Collaboration
|
||||
- Policy guardrails
|
||||
- Visibility
|
||||
|
||||
For details, see [this article about DevSecOps](https://about.gitlab.com/topics/devsecops/).
|
||||
|
||||
## Is DevSecOps right for you?
|
||||
|
||||
If your organization is facing any of the following challenges, a DevSecOps approach might be for you.
|
||||
|
||||
- **Development, security, and operations teams are siloed.**
|
||||
If development and operations are isolated from security issues,
|
||||
they can't build secure software. And if security teams aren't part of the development process,
|
||||
they can't identify risks proactively. DevSecOps brings teams together to improve workflows
|
||||
and share ideas. Organizations might even see improved employee morale and retention.
|
||||
|
||||
- **Long development cycles are making it difficult to meet customer or stakeholder demands.**
|
||||
One reason for the struggle could be security. DevSecOps implements security at every step of
|
||||
the development lifecycle, meaning that solid security doesn’t require the whole process to come to a halt.
|
||||
|
||||
- **You’re migrating to the cloud (or considering it).**
|
||||
Moving to the cloud often means bringing on new development processes, tools, and systems.
|
||||
It’s a great time to make processes faster and more secure — and DevSecOps could make that a lot easier.
|
||||
|
||||
To get started with DevSecOps,
|
||||
[learn more, and try GitLab Ultimate for free](https://about.gitlab.com/solutions/security-compliance/).
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
variables:
|
||||
DAST_AUTO_DEPLOY_IMAGE_VERSION: 'v2.59.1'
|
||||
DAST_AUTO_DEPLOY_IMAGE_VERSION: 'v2.60.0'
|
||||
|
||||
.dast-auto-deploy:
|
||||
image: "${CI_TEMPLATE_REGISTRY_HOST}/gitlab-org/cluster-integration/auto-deploy-image:${DAST_AUTO_DEPLOY_IMAGE_VERSION}"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
variables:
|
||||
AUTO_DEPLOY_IMAGE_VERSION: 'v2.59.1'
|
||||
AUTO_DEPLOY_IMAGE_VERSION: 'v2.60.0'
|
||||
|
||||
.auto-deploy:
|
||||
image: "${CI_TEMPLATE_REGISTRY_HOST}/gitlab-org/cluster-integration/auto-deploy-image:${AUTO_DEPLOY_IMAGE_VERSION}"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
variables:
|
||||
AUTO_DEPLOY_IMAGE_VERSION: 'v2.59.1'
|
||||
AUTO_DEPLOY_IMAGE_VERSION: 'v2.60.0'
|
||||
|
||||
.auto-deploy:
|
||||
image: "${CI_TEMPLATE_REGISTRY_HOST}/gitlab-org/cluster-integration/auto-deploy-image:${AUTO_DEPLOY_IMAGE_VERSION}"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ module RuboCop
|
|||
index_exists?
|
||||
column_exists?
|
||||
create_trigger_to_sync_tables
|
||||
lock_tables
|
||||
swap_columns
|
||||
swap_columns_default
|
||||
swap_foreign_keys
|
||||
swap_indexes
|
||||
reset_trigger_function
|
||||
].sort.freeze
|
||||
|
||||
MSG = "The method is not allowed to be called within the `with_lock_retries` block, the only allowed methods are: #{ALLOWED_MIGRATION_METHODS.join(', ')}".freeze
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Resolvers::Ci::Catalog::ResourceResolver, feature_category: :pipeline_composition do
|
||||
include GraphqlHelpers
|
||||
|
||||
let_it_be(:namespace) { create(:group) }
|
||||
let_it_be(:project) { create(:project, :private, namespace: namespace) }
|
||||
let_it_be(:resource) { create(:ci_catalog_resource, project: project) }
|
||||
let_it_be(:user) { create(:user) }
|
||||
|
||||
describe '#resolve' do
|
||||
context 'when the user can read code on the catalog resource project' do
|
||||
before_all do
|
||||
namespace.add_developer(user)
|
||||
end
|
||||
|
||||
context 'when resource is found' do
|
||||
it 'returns a single CI/CD Catalog resource' do
|
||||
result = resolve(described_class, ctx: { current_user: user },
|
||||
args: { id: resource.to_global_id.to_s })
|
||||
|
||||
expect(result.id).to eq(resource.id)
|
||||
expect(result.class).to eq(Ci::Catalog::Resource)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when resource does not exist' do
|
||||
it 'raises ResourceNotAvailable error' do
|
||||
result = resolve(described_class, ctx: { current_user: user },
|
||||
args: { id: "gid://gitlab/Ci::Catalog::Resource/not-a-real-id" })
|
||||
|
||||
expect(result).to be_a(::Gitlab::Graphql::Errors::ResourceNotAvailable)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user cannot read code on the catalog resource project' do
|
||||
it 'raises ResourceNotAvailable error' do
|
||||
result = resolve(described_class, ctx: { current_user: user },
|
||||
args: { id: resource.to_global_id.to_s })
|
||||
|
||||
expect(result).to be_a(::Gitlab::Graphql::Errors::ResourceNotAvailable)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Resolvers::Ci::Catalog::ResourcesResolver, feature_category: :pipeline_composition do
|
||||
include GraphqlHelpers
|
||||
|
||||
let_it_be(:namespace) { create(:group) }
|
||||
let_it_be(:project_1) { create(:project, name: 'Z', namespace: namespace) }
|
||||
let_it_be(:project_2) { create(:project, name: 'A', namespace: namespace) }
|
||||
let_it_be(:project_3) { create(:project, name: 'L', namespace: namespace) }
|
||||
let_it_be(:resource_1) { create(:ci_catalog_resource, project: project_1) }
|
||||
let_it_be(:resource_2) { create(:ci_catalog_resource, project: project_2) }
|
||||
let_it_be(:resource_3) { create(:ci_catalog_resource, project: project_3) }
|
||||
let_it_be(:user) { create(:user) }
|
||||
|
||||
describe '#resolve' do
|
||||
context 'with an authorized user' do
|
||||
before_all do
|
||||
namespace.add_owner(user)
|
||||
end
|
||||
|
||||
before do
|
||||
stub_licensed_features(ci_namespace_catalog: true)
|
||||
end
|
||||
|
||||
it 'returns all CI Catalog resources visible to the current user in the namespace' do
|
||||
result = resolve(described_class, ctx: { current_user: user }, args: { project_path: project_1.full_path })
|
||||
|
||||
expect(result.items.count).to be(3)
|
||||
expect(result.items.pluck(:name)).to contain_exactly('Z', 'A', 'L')
|
||||
end
|
||||
|
||||
it 'returns all resources sorted by descending created date when given no sort param' do
|
||||
result = resolve(described_class, ctx: { current_user: user }, args: { project_path: project_1.full_path })
|
||||
|
||||
expect(result.items.pluck(:name)).to eq(%w[L A Z])
|
||||
end
|
||||
|
||||
it 'returns all CI Catalog resources sorted by descending name when there is a sort parameter' do
|
||||
result = resolve(described_class, ctx: { current_user: user }, args: { project_path: project_1.full_path, sort:
|
||||
'NAME_DESC' })
|
||||
|
||||
expect(result.items.pluck(:name)).to eq(%w[Z L A])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the current user cannot read the namespace catalog' do
|
||||
it 'raises ResourceNotAvailable' do
|
||||
stub_licensed_features(ci_namespace_catalog: true)
|
||||
namespace.add_guest(user)
|
||||
|
||||
result = resolve(described_class, ctx: { current_user: user }, args: { project_path: project_1.full_path })
|
||||
|
||||
expect(result).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
# In this context, a `version` is equivalent to a `release`
|
||||
RSpec.describe Resolvers::Ci::Catalog::VersionsResolver, feature_category: :pipeline_composition do
|
||||
include GraphqlHelpers
|
||||
|
||||
let_it_be(:today) { Time.now }
|
||||
let_it_be(:yesterday) { today - 1.day }
|
||||
let_it_be(:tomorrow) { today + 1.day }
|
||||
|
||||
let_it_be(:project) { create(:project, :private) }
|
||||
# rubocop: disable Layout/LineLength
|
||||
let_it_be(:version1) { create(:release, project: project, tag: 'v1.0.0', released_at: yesterday, created_at: tomorrow) }
|
||||
let_it_be(:version2) { create(:release, project: project, tag: 'v2.0.0', released_at: today, created_at: yesterday) }
|
||||
let_it_be(:version3) { create(:release, project: project, tag: 'v3.0.0', released_at: tomorrow, created_at: today) }
|
||||
# rubocop: enable Layout/LineLength
|
||||
let_it_be(:developer) { create(:user) }
|
||||
let_it_be(:public_user) { create(:user) }
|
||||
|
||||
let(:args) { { sort: :released_at_desc } }
|
||||
let(:all_releases) { [version1, version2, version3] }
|
||||
|
||||
before_all do
|
||||
project.add_developer(developer)
|
||||
end
|
||||
|
||||
describe '#resolve' do
|
||||
it_behaves_like 'releases and group releases resolver'
|
||||
|
||||
describe 'when order_by is created_at' do
|
||||
let(:current_user) { developer }
|
||||
|
||||
context 'with sort: desc' do
|
||||
let(:args) { { sort: :created_desc } }
|
||||
|
||||
it 'returns the releases ordered by created_at in descending order' do
|
||||
expect(resolve_releases.to_a)
|
||||
.to match_array(all_releases)
|
||||
.and be_sorted(:created_at, :desc)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with sort: asc' do
|
||||
let(:args) { { sort: :created_asc } }
|
||||
|
||||
it 'returns the releases ordered by created_at in ascending order' do
|
||||
expect(resolve_releases.to_a)
|
||||
.to match_array(all_releases)
|
||||
.and be_sorted(:created_at, :asc)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_versions
|
||||
context = { current_user: current_user }
|
||||
resolve(described_class, obj: project, args: args, ctx: context, arg_style: :internal)
|
||||
end
|
||||
|
||||
# Required for shared examples
|
||||
alias_method :resolve_releases, :resolve_versions
|
||||
end
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe GitlabSchema.types['CiCatalogResourceSort'], feature_category: :pipeline_composition do
|
||||
it { expect(described_class.graphql_name).to eq('CiCatalogResourceSort') }
|
||||
|
||||
it 'exposes all the existing catalog resource sort orders' do
|
||||
expect(described_class.values.keys).to include(
|
||||
*%w[NAME_ASC NAME_DESC LATEST_RELEASED_AT_ASC LATEST_RELEASED_AT_DESC]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Types::Ci::Catalog::ResourceType, feature_category: :pipeline_composition do
|
||||
specify { expect(described_class.graphql_name).to eq('CiCatalogResource') }
|
||||
|
||||
it 'exposes the expected fields' do
|
||||
expected_fields = %i[
|
||||
id
|
||||
name
|
||||
description
|
||||
icon
|
||||
web_path
|
||||
versions
|
||||
latest_version
|
||||
latest_released_at
|
||||
star_count
|
||||
forks_count
|
||||
root_namespace
|
||||
readme_html
|
||||
open_issues_count
|
||||
open_merge_requests_count
|
||||
]
|
||||
|
||||
expect(described_class).to have_graphql_fields(*expected_fields)
|
||||
end
|
||||
end
|
||||
|
|
@ -3212,9 +3212,23 @@ RSpec.describe ProjectPolicy, feature_category: :system_access do
|
|||
end
|
||||
|
||||
describe 'add_catalog_resource' do
|
||||
let(:current_user) { owner }
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
specify { is_expected.to be_disallowed(:read_namespace_catalog) }
|
||||
let(:current_user) { public_send(role) }
|
||||
|
||||
where(:role, :allowed) do
|
||||
:owner | true
|
||||
:maintainer | false
|
||||
:developer | false
|
||||
:reporter | false
|
||||
:guest | false
|
||||
end
|
||||
|
||||
with_them do
|
||||
it do
|
||||
expect(subject.can?(:add_catalog_resource)).to be(allowed)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'read_model_registry' do
|
||||
|
|
|
|||
|
|
@ -0,0 +1,341 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'Query.ciCatalogResource', feature_category: :pipeline_composition do
|
||||
include GraphqlHelpers
|
||||
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:namespace) { create(:group) }
|
||||
|
||||
let_it_be(:project) do
|
||||
create(
|
||||
:project, :with_avatar, :custom_repo,
|
||||
name: 'Component Repository',
|
||||
description: 'A simple component',
|
||||
namespace: namespace,
|
||||
star_count: 1,
|
||||
files: { 'README.md' => '[link](README.md)' }
|
||||
)
|
||||
end
|
||||
|
||||
let_it_be(:resource) { create(:ci_catalog_resource, project: project) }
|
||||
|
||||
let(:query) do
|
||||
<<~GQL
|
||||
query {
|
||||
ciCatalogResource(id: "#{resource.to_global_id}") {
|
||||
#{all_graphql_fields_for('CiCatalogResource', max_depth: 1)}
|
||||
}
|
||||
}
|
||||
GQL
|
||||
end
|
||||
|
||||
subject(:post_query) { post_graphql(query, current_user: user) }
|
||||
|
||||
context 'when the current user has permission to read the namespace catalog' do
|
||||
it 'returns the resource with the expected data' do
|
||||
namespace.add_developer(user)
|
||||
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResource)).to match(
|
||||
a_graphql_entity_for(
|
||||
resource, :name, :description,
|
||||
icon: project.avatar_path,
|
||||
webPath: "/#{project.full_path}",
|
||||
starCount: project.star_count,
|
||||
forksCount: project.forks_count,
|
||||
readmeHtml: a_string_including(
|
||||
"#{project.full_path}/-/blob/#{project.default_branch}/README.md"
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the current user does not have permission to read the namespace catalog' do
|
||||
it 'returns nil' do
|
||||
namespace.add_guest(user)
|
||||
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResource)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe 'versions' do
|
||||
before_all do
|
||||
namespace.add_developer(user)
|
||||
end
|
||||
|
||||
before do
|
||||
stub_licensed_features(ci_namespace_catalog: true)
|
||||
end
|
||||
|
||||
let(:query) do
|
||||
<<~GQL
|
||||
query {
|
||||
ciCatalogResource(id: "#{resource.to_global_id}") {
|
||||
id
|
||||
versions {
|
||||
nodes {
|
||||
id
|
||||
tagName
|
||||
releasedAt
|
||||
author {
|
||||
id
|
||||
name
|
||||
webUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GQL
|
||||
end
|
||||
|
||||
context 'when the resource has versions' do
|
||||
let_it_be(:author) { create(:user, name: 'author') }
|
||||
|
||||
let_it_be(:version1) do
|
||||
create(:release, project: project, released_at: '2023-01-01T00:00:00Z', author: author)
|
||||
end
|
||||
|
||||
let_it_be(:version2) do
|
||||
create(:release, project: project, released_at: '2023-02-01T00:00:00Z', author: author)
|
||||
end
|
||||
|
||||
it 'returns the resource with the versions data' do
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResource)).to match(
|
||||
a_graphql_entity_for(resource)
|
||||
)
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResource, :versions, :nodes)).to contain_exactly(
|
||||
a_graphql_entity_for(
|
||||
version1,
|
||||
tagName: version1.tag,
|
||||
releasedAt: version1.released_at,
|
||||
author: a_graphql_entity_for(author, :name)
|
||||
),
|
||||
a_graphql_entity_for(
|
||||
version2,
|
||||
tagName: version2.tag,
|
||||
releasedAt: version2.released_at,
|
||||
author: a_graphql_entity_for(author, :name)
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the resource does not have a version' do
|
||||
it 'returns versions as an empty array' do
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResource)).to match(
|
||||
a_graphql_entity_for(resource, versions: { 'nodes' => [] })
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'latestVersion' do
|
||||
before_all do
|
||||
namespace.add_developer(user)
|
||||
end
|
||||
|
||||
before do
|
||||
stub_licensed_features(ci_namespace_catalog: true)
|
||||
end
|
||||
|
||||
let(:query) do
|
||||
<<~GQL
|
||||
query {
|
||||
ciCatalogResource(id: "#{resource.to_global_id}") {
|
||||
id
|
||||
latestVersion {
|
||||
id
|
||||
tagName
|
||||
releasedAt
|
||||
author {
|
||||
id
|
||||
name
|
||||
webUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GQL
|
||||
end
|
||||
|
||||
context 'when the resource has versions' do
|
||||
let_it_be(:author) { create(:user, name: 'author') }
|
||||
|
||||
let_it_be(:latest_version) do
|
||||
create(:release, project: project, released_at: '2023-02-01T00:00:00Z', author: author)
|
||||
end
|
||||
|
||||
before_all do
|
||||
# Previous version of the project
|
||||
create(:release, project: project, released_at: '2023-01-01T00:00:00Z', author: author)
|
||||
end
|
||||
|
||||
it 'returns the resource with the latest version data' do
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResource)).to match(
|
||||
a_graphql_entity_for(
|
||||
resource,
|
||||
latestVersion: a_graphql_entity_for(
|
||||
latest_version,
|
||||
tagName: latest_version.tag,
|
||||
releasedAt: latest_version.released_at,
|
||||
author: a_graphql_entity_for(author, :name)
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the resource does not have a version' do
|
||||
it 'returns nil' do
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResource)).to match(
|
||||
a_graphql_entity_for(resource, latestVersion: nil)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'rootNamespace' do
|
||||
before_all do
|
||||
namespace.add_developer(user)
|
||||
end
|
||||
|
||||
before do
|
||||
stub_licensed_features(ci_namespace_catalog: true)
|
||||
end
|
||||
|
||||
let(:query) do
|
||||
<<~GQL
|
||||
query {
|
||||
ciCatalogResource(id: "#{resource.to_global_id}") {
|
||||
id
|
||||
rootNamespace {
|
||||
id
|
||||
name
|
||||
path
|
||||
}
|
||||
}
|
||||
}
|
||||
GQL
|
||||
end
|
||||
|
||||
it 'returns the correct root namespace data' do
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResource)).to match(
|
||||
a_graphql_entity_for(
|
||||
resource,
|
||||
rootNamespace: a_graphql_entity_for(namespace, :name, :path)
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'openIssuesCount' do
|
||||
before do
|
||||
stub_licensed_features(ci_namespace_catalog: true)
|
||||
end
|
||||
|
||||
context 'when open_issue_count is requested' do
|
||||
let(:query) do
|
||||
<<~GQL
|
||||
query {
|
||||
ciCatalogResource(id: "#{resource.to_global_id}") {
|
||||
openIssuesCount
|
||||
}
|
||||
}
|
||||
GQL
|
||||
end
|
||||
|
||||
it 'returns the correct count' do
|
||||
create(:issue, :opened, project: project)
|
||||
create(:issue, :opened, project: project)
|
||||
|
||||
namespace.add_developer(user)
|
||||
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResource)).to match(
|
||||
a_graphql_entity_for(
|
||||
open_issues_count: 2
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
context 'when open_issue_count is zero' do
|
||||
it 'returns zero' do
|
||||
namespace.add_developer(user)
|
||||
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResource)).to match(
|
||||
a_graphql_entity_for(
|
||||
open_issues_count: 0
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'openMergeRequestsCount' do
|
||||
before do
|
||||
stub_licensed_features(ci_namespace_catalog: true)
|
||||
end
|
||||
|
||||
context 'when merge_requests_count is requested' do
|
||||
let(:query) do
|
||||
<<~GQL
|
||||
query {
|
||||
ciCatalogResource(id: "#{resource.to_global_id}") {
|
||||
openMergeRequestsCount
|
||||
}
|
||||
}
|
||||
GQL
|
||||
end
|
||||
|
||||
it 'returns the correct count' do
|
||||
create(:merge_request, :opened, source_project: project)
|
||||
|
||||
namespace.add_developer(user)
|
||||
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResource)).to match(
|
||||
a_graphql_entity_for(
|
||||
open_merge_requests_count: 1
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
context 'when open merge_requests_count is zero' do
|
||||
it 'returns zero' do
|
||||
namespace.add_developer(user)
|
||||
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResource)).to match(
|
||||
a_graphql_entity_for(
|
||||
open_merge_requests_count: 0
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,533 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'Query.ciCatalogResources', feature_category: :pipeline_composition do
|
||||
include GraphqlHelpers
|
||||
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:namespace) { create(:group) }
|
||||
let_it_be(:project2) { create(:project, namespace: namespace) }
|
||||
|
||||
let_it_be(:project1) do
|
||||
create(
|
||||
:project, :with_avatar, :custom_repo,
|
||||
name: 'Component Repository',
|
||||
description: 'A simple component',
|
||||
namespace: namespace,
|
||||
star_count: 1,
|
||||
files: { 'README.md' => '**Test**' }
|
||||
)
|
||||
end
|
||||
|
||||
let_it_be(:resource1) { create(:ci_catalog_resource, project: project1, latest_released_at: '2023-01-01T00:00:00Z') }
|
||||
|
||||
let(:query) do
|
||||
<<~GQL
|
||||
query {
|
||||
ciCatalogResources(projectPath: "#{project1.full_path}") {
|
||||
nodes {
|
||||
#{all_graphql_fields_for('CiCatalogResource', max_depth: 1)}
|
||||
}
|
||||
}
|
||||
}
|
||||
GQL
|
||||
end
|
||||
|
||||
subject(:post_query) { post_graphql(query, current_user: user) }
|
||||
|
||||
shared_examples 'avoids N+1 queries' do
|
||||
it do
|
||||
ctx = { current_user: user }
|
||||
|
||||
control_count = ActiveRecord::QueryRecorder.new do
|
||||
run_with_clean_state(query, context: ctx)
|
||||
end
|
||||
|
||||
create(:ci_catalog_resource, project: project2)
|
||||
|
||||
expect do
|
||||
run_with_clean_state(query, context: ctx)
|
||||
end.not_to exceed_query_limit(control_count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the current user has permission to read the namespace catalog' do
|
||||
before_all do
|
||||
namespace.add_developer(user)
|
||||
end
|
||||
|
||||
it 'returns the resource with the expected data' do
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResources, :nodes)).to contain_exactly(
|
||||
a_graphql_entity_for(
|
||||
resource1, :name, :description,
|
||||
icon: project1.avatar_path,
|
||||
webPath: "/#{project1.full_path}",
|
||||
starCount: project1.star_count,
|
||||
forksCount: project1.forks_count,
|
||||
readmeHtml: a_string_including('Test</strong>'),
|
||||
latestReleasedAt: resource1.latest_released_at
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
context 'when there are two resources visible to the current user in the namespace' do
|
||||
it 'returns both resources with the expected data' do
|
||||
resource2 = create(:ci_catalog_resource, project: project2)
|
||||
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResources, :nodes)).to contain_exactly(
|
||||
a_graphql_entity_for(resource1),
|
||||
a_graphql_entity_for(
|
||||
resource2, :name, :description,
|
||||
icon: project2.avatar_path,
|
||||
webPath: "/#{project2.full_path}",
|
||||
starCount: project2.star_count,
|
||||
forksCount: project2.forks_count,
|
||||
readmeHtml: '',
|
||||
latestReleasedAt: resource2.latest_released_at
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
it_behaves_like 'avoids N+1 queries'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the current user does not have permission to read the namespace catalog' do
|
||||
it 'returns no resources' do
|
||||
namespace.add_guest(user)
|
||||
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResources, :nodes)).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe 'versions' do
|
||||
before_all do
|
||||
namespace.add_developer(user)
|
||||
end
|
||||
|
||||
before do
|
||||
stub_licensed_features(ci_namespace_catalog: true)
|
||||
end
|
||||
|
||||
let(:query) do
|
||||
<<~GQL
|
||||
query {
|
||||
ciCatalogResources(projectPath: "#{project1.full_path}") {
|
||||
nodes {
|
||||
id
|
||||
versions {
|
||||
nodes {
|
||||
id
|
||||
tagName
|
||||
releasedAt
|
||||
author {
|
||||
id
|
||||
name
|
||||
webUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GQL
|
||||
end
|
||||
|
||||
context 'when there is a single resource visible to the current user in the namespace' do
|
||||
context 'when the resource has versions' do
|
||||
let_it_be(:author) { create(:user, name: 'author') }
|
||||
|
||||
let_it_be(:version1) do
|
||||
create(:release, project: project1, released_at: '2023-01-01T00:00:00Z', author: author)
|
||||
end
|
||||
|
||||
let_it_be(:version2) do
|
||||
create(:release, project: project1, released_at: '2023-02-01T00:00:00Z', author: author)
|
||||
end
|
||||
|
||||
it 'returns the resource with the versions data' do
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResources, :nodes)).to contain_exactly(
|
||||
a_graphql_entity_for(resource1)
|
||||
)
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResources, :nodes, 0, :versions, :nodes)).to contain_exactly(
|
||||
a_graphql_entity_for(
|
||||
version1,
|
||||
tagName: version1.tag,
|
||||
releasedAt: version1.released_at,
|
||||
author: a_graphql_entity_for(author, :name)
|
||||
),
|
||||
a_graphql_entity_for(
|
||||
version2,
|
||||
tagName: version2.tag,
|
||||
releasedAt: version2.released_at,
|
||||
author: a_graphql_entity_for(author, :name)
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the resource does not have a version' do
|
||||
it 'returns versions as an empty array' do
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResources, :nodes)).to contain_exactly(
|
||||
a_graphql_entity_for(resource1, versions: { 'nodes' => [] })
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are multiple resources visible to the current user in the namespace' do
|
||||
it 'limits the request to 1 resource at a time' do
|
||||
create(:ci_catalog_resource, project: project2)
|
||||
|
||||
post_query
|
||||
|
||||
expect_graphql_errors_to_include \
|
||||
[/"versions" field can be requested only for 1 CiCatalogResource\(s\) at a time./]
|
||||
end
|
||||
|
||||
it_behaves_like 'avoids N+1 queries'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'latestVersion' do
|
||||
before_all do
|
||||
namespace.add_developer(user)
|
||||
end
|
||||
|
||||
before do
|
||||
stub_licensed_features(ci_namespace_catalog: true)
|
||||
end
|
||||
|
||||
let(:query) do
|
||||
<<~GQL
|
||||
query {
|
||||
ciCatalogResources(projectPath: "#{project1.full_path}") {
|
||||
nodes {
|
||||
id
|
||||
latestVersion {
|
||||
id
|
||||
tagName
|
||||
releasedAt
|
||||
author {
|
||||
id
|
||||
name
|
||||
webUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GQL
|
||||
end
|
||||
|
||||
context 'when the resource has versions' do
|
||||
let_it_be(:author1) { create(:user, name: 'author1') }
|
||||
let_it_be(:author2) { create(:user, name: 'author2') }
|
||||
|
||||
let_it_be(:latest_version1) do
|
||||
create(:release, project: project1, released_at: '2023-02-01T00:00:00Z', author: author1)
|
||||
end
|
||||
|
||||
let_it_be(:latest_version2) do
|
||||
create(:release, project: project2, released_at: '2023-02-01T00:00:00Z', author: author2)
|
||||
end
|
||||
|
||||
before_all do
|
||||
# Previous versions of the projects
|
||||
create(:release, project: project1, released_at: '2023-01-01T00:00:00Z', author: author1)
|
||||
create(:release, project: project2, released_at: '2023-01-01T00:00:00Z', author: author2)
|
||||
end
|
||||
|
||||
it 'returns the resource with the latest version data' do
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResources, :nodes)).to contain_exactly(
|
||||
a_graphql_entity_for(
|
||||
resource1,
|
||||
latestVersion: a_graphql_entity_for(
|
||||
latest_version1,
|
||||
tagName: latest_version1.tag,
|
||||
releasedAt: latest_version1.released_at,
|
||||
author: a_graphql_entity_for(author1, :name)
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
context 'when there are multiple resources visible to the current user in the namespace' do
|
||||
let_it_be(:project0) { create(:project, namespace: namespace) }
|
||||
let_it_be(:resource0) { create(:ci_catalog_resource, project: project0) }
|
||||
let_it_be(:author0) { create(:user, name: 'author0') }
|
||||
|
||||
let_it_be(:version0) do
|
||||
create(:release, project: project0, released_at: '2023-01-01T00:00:00Z', author: author0)
|
||||
end
|
||||
|
||||
it 'returns all resources with the latest version data' do
|
||||
resource2 = create(:ci_catalog_resource, project: project2)
|
||||
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResources, :nodes)).to contain_exactly(
|
||||
a_graphql_entity_for(
|
||||
resource0,
|
||||
latestVersion: a_graphql_entity_for(
|
||||
version0,
|
||||
tagName: version0.tag,
|
||||
releasedAt: version0.released_at,
|
||||
author: a_graphql_entity_for(author0, :name)
|
||||
)
|
||||
),
|
||||
a_graphql_entity_for(
|
||||
resource1,
|
||||
latestVersion: a_graphql_entity_for(
|
||||
latest_version1,
|
||||
tagName: latest_version1.tag,
|
||||
releasedAt: latest_version1.released_at,
|
||||
author: a_graphql_entity_for(author1, :name)
|
||||
)
|
||||
),
|
||||
a_graphql_entity_for(
|
||||
resource2,
|
||||
latestVersion: a_graphql_entity_for(
|
||||
latest_version2,
|
||||
tagName: latest_version2.tag,
|
||||
releasedAt: latest_version2.released_at,
|
||||
author: a_graphql_entity_for(author2, :name)
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
it_behaves_like 'avoids N+1 queries'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the resource does not have a version' do
|
||||
it 'returns nil' do
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResources, :nodes)).to contain_exactly(
|
||||
a_graphql_entity_for(resource1, latestVersion: nil)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'rootNamespace' do
|
||||
before_all do
|
||||
namespace.add_developer(user)
|
||||
end
|
||||
|
||||
before do
|
||||
stub_licensed_features(ci_namespace_catalog: true)
|
||||
end
|
||||
|
||||
let(:query) do
|
||||
<<~GQL
|
||||
query {
|
||||
ciCatalogResources(projectPath: "#{project1.full_path}") {
|
||||
nodes {
|
||||
id
|
||||
rootNamespace {
|
||||
id
|
||||
name
|
||||
path
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GQL
|
||||
end
|
||||
|
||||
it 'returns the correct root namespace data' do
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResources, :nodes)).to contain_exactly(
|
||||
a_graphql_entity_for(
|
||||
resource1,
|
||||
rootNamespace: a_graphql_entity_for(namespace, :name, :path)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
shared_examples 'returns the correct root namespace for both resources' do
|
||||
it do
|
||||
resource2 = create(:ci_catalog_resource, project: project2)
|
||||
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResources, :nodes)).to contain_exactly(
|
||||
a_graphql_entity_for(resource1, rootNamespace: a_graphql_entity_for(namespace)),
|
||||
a_graphql_entity_for(resource2, rootNamespace: a_graphql_entity_for(namespace2))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'when there are two resources visible to the current user' do
|
||||
it_behaves_like 'returns the correct root namespace for both resources'
|
||||
it_behaves_like 'avoids N+1 queries'
|
||||
|
||||
context 'when a resource is within a nested namespace' do
|
||||
let_it_be(:nested_namespace) { create(:group, parent: namespace2) }
|
||||
let_it_be(:project2) { create(:project, namespace: nested_namespace) }
|
||||
|
||||
it_behaves_like 'returns the correct root namespace for both resources'
|
||||
it_behaves_like 'avoids N+1 queries'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are multiple resources visible to the current user from the same root namespace' do
|
||||
let_it_be(:namespace2) { namespace }
|
||||
|
||||
it_behaves_like 'when there are two resources visible to the current user'
|
||||
end
|
||||
|
||||
# We expect the resources resolver will eventually support returning resources from multiple root namespaces.
|
||||
context 'when there are multiple resources visible to the current user from different root namespaces' do
|
||||
before do
|
||||
# In order to mock this scenario, we allow the resolver to return
|
||||
# all existing resources without scoping to a specific namespace.
|
||||
allow_next_instance_of(::Ci::Catalog::Listing) do |instance|
|
||||
allow(instance).to receive(:resources).and_return(::Ci::Catalog::Resource.includes(:project))
|
||||
end
|
||||
end
|
||||
|
||||
# Make the current user an Admin so it has `:read_namespace` ability on all namespaces
|
||||
let_it_be(:user) { create(:admin) }
|
||||
|
||||
let_it_be(:namespace2) { create(:group) }
|
||||
let_it_be(:project2) { create(:project, namespace: namespace2) }
|
||||
|
||||
it_behaves_like 'when there are two resources visible to the current user'
|
||||
|
||||
context 'when a resource is within a User namespace' do
|
||||
let_it_be(:namespace2) { create(:user).namespace }
|
||||
let_it_be(:project2) { create(:project, namespace: namespace2) }
|
||||
|
||||
# A response containing any number of 'User' type root namespaces will always execute 1 extra
|
||||
# query than a response with only 'Group' type root namespaces. This is due to their different
|
||||
# policies. Here we preemptively create another resource with a 'User' type root namespace so
|
||||
# that the control_count in the N+1 test includes this extra query.
|
||||
let_it_be(:namespace3) { create(:user).namespace }
|
||||
let_it_be(:resource3) { create(:ci_catalog_resource, project: create(:project, namespace: namespace3)) }
|
||||
|
||||
it 'returns the correct root namespace for all resources' do
|
||||
resource2 = create(:ci_catalog_resource, project: project2)
|
||||
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResources, :nodes)).to contain_exactly(
|
||||
a_graphql_entity_for(resource1, rootNamespace: a_graphql_entity_for(namespace)),
|
||||
a_graphql_entity_for(resource2, rootNamespace: a_graphql_entity_for(namespace2)),
|
||||
a_graphql_entity_for(resource3, rootNamespace: a_graphql_entity_for(namespace3))
|
||||
)
|
||||
end
|
||||
|
||||
it_behaves_like 'avoids N+1 queries'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'openIssuesCount' do
|
||||
before_all do
|
||||
namespace.add_developer(user)
|
||||
end
|
||||
|
||||
before do
|
||||
stub_licensed_features(ci_namespace_catalog: true)
|
||||
end
|
||||
|
||||
context 'when open_issues_count is requested' do
|
||||
before_all do
|
||||
create(:issue, :opened, project: project1)
|
||||
create(:issue, :opened, project: project1)
|
||||
|
||||
create(:issue, :opened, project: project2)
|
||||
end
|
||||
|
||||
let(:query) do
|
||||
<<~GQL
|
||||
query {
|
||||
ciCatalogResources(projectPath: "#{project1.full_path}") {
|
||||
nodes {
|
||||
openIssuesCount
|
||||
}
|
||||
}
|
||||
}
|
||||
GQL
|
||||
end
|
||||
|
||||
it 'returns the correct count' do
|
||||
create(:ci_catalog_resource, project: project2)
|
||||
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResources, :nodes)).to contain_exactly(
|
||||
a_graphql_entity_for(
|
||||
openIssuesCount: 2),
|
||||
a_graphql_entity_for(
|
||||
openIssuesCount: 1)
|
||||
)
|
||||
end
|
||||
|
||||
it_behaves_like 'avoids N+1 queries'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'openMergeRequestsCount' do
|
||||
before_all do
|
||||
namespace.add_developer(user)
|
||||
end
|
||||
|
||||
before do
|
||||
stub_licensed_features(ci_namespace_catalog: true)
|
||||
end
|
||||
|
||||
context 'when open_merge_requests_count is requested' do
|
||||
before_all do
|
||||
create(:merge_request, :opened, source_project: project1)
|
||||
create(:merge_request, :opened, source_project: project2)
|
||||
end
|
||||
|
||||
let(:query) do
|
||||
<<~GQL
|
||||
query {
|
||||
ciCatalogResources(projectPath: "#{project1.full_path}") {
|
||||
nodes {
|
||||
openMergeRequestsCount
|
||||
}
|
||||
}
|
||||
}
|
||||
GQL
|
||||
end
|
||||
|
||||
it 'returns the correct count' do
|
||||
create(:ci_catalog_resource, project: project2)
|
||||
|
||||
post_query
|
||||
|
||||
expect(graphql_data_at(:ciCatalogResources, :nodes)).to contain_exactly(
|
||||
a_graphql_entity_for(
|
||||
openMergeRequestsCount: 1),
|
||||
a_graphql_entity_for(
|
||||
openMergeRequestsCount: 1)
|
||||
)
|
||||
end
|
||||
|
||||
it_behaves_like 'avoids N+1 queries'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'CatalogResourcesCreate', feature_category: :pipeline_composition do
|
||||
include GraphqlHelpers
|
||||
|
||||
let_it_be(:current_user) { create(:user) }
|
||||
let_it_be(:project) { create(:project, :repository, description: 'our components') }
|
||||
|
||||
let(:mutation) do
|
||||
variables = {
|
||||
project_path: project.full_path
|
||||
}
|
||||
graphql_mutation(:catalog_resources_create, variables,
|
||||
<<-QL.strip_heredoc
|
||||
errors
|
||||
QL
|
||||
)
|
||||
end
|
||||
|
||||
context 'when unauthorized' do
|
||||
it_behaves_like 'a mutation that returns a top-level access error'
|
||||
end
|
||||
|
||||
context 'when authorized' do
|
||||
context 'with a valid project' do
|
||||
before_all do
|
||||
project.add_owner(current_user)
|
||||
end
|
||||
|
||||
it 'creates a catalog resource' do
|
||||
post_graphql_mutation(mutation, current_user: current_user)
|
||||
|
||||
expect(graphql_mutation_response(:catalog_resources_create)['errors']).to be_empty
|
||||
expect(response).to have_gitlab_http_status(:success)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an invalid project' do
|
||||
let_it_be(:project) { create(:project, :repository) }
|
||||
|
||||
before_all do
|
||||
project.add_owner(current_user)
|
||||
end
|
||||
|
||||
it 'returns an error' do
|
||||
post_graphql_mutation(mutation, current_user: current_user)
|
||||
|
||||
expect(graphql_mutation_response(:catalog_resources_create)['errors']).not_to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
require 'rubocop_spec_helper'
|
||||
require_relative '../../../../rubocop/cop/migration/with_lock_retries_disallowed_method'
|
||||
|
||||
RSpec.describe RuboCop::Cop::Migration::WithLockRetriesDisallowedMethod do
|
||||
RSpec.describe RuboCop::Cop::Migration::WithLockRetriesDisallowedMethod, feature_category: :database do
|
||||
context 'when in migration' do
|
||||
before do
|
||||
allow(cop).to receive(:in_migration?).and_return(true)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Ci::Catalog::AddResourceService, feature_category: :pipeline_composition do
|
||||
let_it_be(:project) { create(:project, :repository, description: 'Our components') }
|
||||
let_it_be(:user) { create(:user) }
|
||||
|
||||
let(:service) { described_class.new(project, user) }
|
||||
|
||||
before do
|
||||
stub_licensed_features(ci_namespace_catalog: true)
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'with an unauthorized user' do
|
||||
it 'raises an AccessDeniedError' do
|
||||
expect { service.execute }.to raise_error(Gitlab::Access::AccessDeniedError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an authorized user' do
|
||||
before_all do
|
||||
project.add_owner(user)
|
||||
end
|
||||
|
||||
context 'and a valid project' do
|
||||
it 'creates a catalog resource' do
|
||||
response = service.execute
|
||||
|
||||
expect(response.payload.project).to eq(project)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an invalid project' do
|
||||
let_it_be(:project) { create(:project, :repository) }
|
||||
|
||||
before_all do
|
||||
project.add_owner(user)
|
||||
end
|
||||
|
||||
it 'does not create a catalog resource' do
|
||||
response = service.execute
|
||||
|
||||
expect(response.message).to eq('Project must have a description')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an invalid catalog resource' do
|
||||
it 'does not save the catalog resource' do
|
||||
catalog_resource = instance_double(::Ci::Catalog::Resource,
|
||||
valid?: false,
|
||||
errors: instance_double(ActiveModel::Errors, full_messages: ['not valid']))
|
||||
allow(::Ci::Catalog::Resource).to receive(:new).and_return(catalog_resource)
|
||||
|
||||
response = service.execute
|
||||
|
||||
expect(response.message).to eq('not valid')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue