diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb index 04b82ee04ec9ee1240385c7ac338846e01bf3065..e5e08d2971fa351bc18a2b86b467306d4e769297 100644 --- a/app/finders/deployments_finder.rb +++ b/app/finders/deployments_finder.rb @@ -9,8 +9,8 @@ # updated_before: DateTime # finished_after: DateTime # finished_before: DateTime -# environment: String -# status: String (see Deployment.statuses) +# environment: String (name) or Integer (ID) +# status: String or Array<String> (see Deployment.statuses) # order_by: String (see ALLOWED_SORT_VALUES constant) # sort: String (asc | desc) class DeploymentsFinder @@ -33,6 +33,7 @@ class DeploymentsFinder def initialize(params = {}) @params = params + @params[:status] = Array(@params[:status]).map(&:to_s) if @params[:status] validate! end @@ -68,16 +69,25 @@ def validate! raise error if raise_for_inefficient_updated_at_query? end - if (filter_by_finished_at? && !order_by_finished_at?) || (!filter_by_finished_at? && order_by_finished_at?) - raise InefficientQueryError, '`finished_at` filter and `finished_at` sorting must be paired' + if filter_by_finished_at? && !order_by_finished_at? + raise InefficientQueryError, '`finished_at` filter requires `finished_at` sort.' + end + + if order_by_finished_at? && !(filter_by_finished_at? || filter_by_finished_statuses?) + raise InefficientQueryError, + '`finished_at` sort requires `finished_at` filter or a filter with at least one of the finished statuses.' end if filter_by_finished_at? && !filter_by_successful_deployment? raise InefficientQueryError, '`finished_at` filter must be combined with `success` status filter.' end - if params[:environment].present? && !params[:project].present? - raise InefficientQueryError, '`environment` filter must be combined with `project` scope.' + if filter_by_environment_name? && !params[:project].present? + raise InefficientQueryError, '`environment` name filter must be combined with `project` scope.' + end + + if filter_by_finished_statuses? && filter_by_upcoming_statuses? + raise InefficientQueryError, 'finished statuses and upcoming statuses must be separately queried.' end end @@ -86,6 +96,8 @@ def init_collection params[:project].deployments elsif params[:group].present? ::Deployment.for_projects(params[:group].all_projects) + elsif filter_by_environment_id? + ::Deployment.for_environment(params[:environment]) else ::Deployment.none end @@ -112,7 +124,7 @@ def by_finished_at(items) end def by_environment(items) - if params[:project].present? && params[:environment].present? + if params[:project].present? && filter_by_environment_name? items.for_environment_name(params[:project], params[:environment]) else items @@ -122,7 +134,7 @@ def by_environment(items) def by_status(items) return items unless params[:status].present? - unless Deployment.statuses.key?(params[:status]) + unless Deployment.statuses.keys.intersection(params[:status]) == params[:status] raise ArgumentError, "The deployment status #{params[:status]} is invalid" end @@ -165,7 +177,23 @@ def filter_by_finished_at? end def filter_by_successful_deployment? - params[:status].to_s == 'success' + params[:status].present? && params[:status].count == 1 && params[:status].first.to_s == 'success' + end + + def filter_by_finished_statuses? + params[:status].present? && Deployment::FINISHED_STATUSES.map(&:to_s).intersection(params[:status]).any? + end + + def filter_by_upcoming_statuses? + params[:status].present? && Deployment::UPCOMING_STATUSES.map(&:to_s).intersection(params[:status]).any? + end + + def filter_by_environment_name? + params[:environment].present? && params[:environment].is_a?(String) + end + + def filter_by_environment_id? + params[:environment].present? && params[:environment].is_a?(Integer) end def order_by_updated_at? diff --git a/app/graphql/resolvers/deployments_resolver.rb b/app/graphql/resolvers/deployments_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..341d23c2ccb34a03ce96affb95707bea5d6a411d --- /dev/null +++ b/app/graphql/resolvers/deployments_resolver.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Resolvers + class DeploymentsResolver < BaseResolver + argument :statuses, [Types::DeploymentStatusEnum], + description: 'Statuses of the deployments.', + required: false, + as: :status + + argument :order_by, Types::DeploymentsOrderByInputType, + description: 'Order by a specified field.', + required: false + + type Types::DeploymentType, null: true + + alias_method :environment, :object + + def resolve(**args) + return unless environment.present? && environment.is_a?(::Environment) + + args = transform_args_for_finder(**args) + + # GraphQL BatchLoader shouldn't be used here because pagination query will be inefficient + # that fetches thousands of rows before limiting and offsetting. + DeploymentsFinder.new(environment: environment.id, **args).execute + end + + private + + def transform_args_for_finder(**args) + if (order_by = args.delete(:order_by)) + order_by = order_by.to_h.map { |k, v| { order_by: k.to_s, sort: v } }.first + args.merge!(order_by) + end + + args + end + end +end diff --git a/app/graphql/types/deployment_status_enum.rb b/app/graphql/types/deployment_status_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..7ef69d3f1c131c0cae7ad685d79ae35db9aab8b0 --- /dev/null +++ b/app/graphql/types/deployment_status_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + class DeploymentStatusEnum < BaseEnum + graphql_name 'DeploymentStatus' + description 'All deployment statuses.' + + ::Deployment.statuses.each_key do |status| + value status.upcase, + description: "A deployment that is #{status.tr('_', ' ')}.", + value: status + end + end +end diff --git a/app/graphql/types/deployment_type.rb b/app/graphql/types/deployment_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..863699a9cc5d436ca6d51752faaf7b96d86a9276 --- /dev/null +++ b/app/graphql/types/deployment_type.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Types + class DeploymentType < BaseObject + graphql_name 'Deployment' + description 'The deployment of an environment' + + present_using Deployments::DeploymentPresenter + + authorize :read_deployment + + field :id, + GraphQL::Types::ID, + description: 'Global ID of the deployment.' + + field :iid, + GraphQL::Types::ID, + description: 'Project-level internal ID of the deployment.' + + field :ref, + GraphQL::Types::String, + description: 'Git-Ref that the deployment ran on.' + + field :tag, + GraphQL::Types::Boolean, + description: 'True or false if the deployment ran on a Git-tag.' + + field :sha, + GraphQL::Types::String, + description: 'Git-SHA that the deployment ran on.' + + field :created_at, + Types::TimeType, + description: 'When the deployment record was created.' + + field :updated_at, + Types::TimeType, + description: 'When the deployment record was updated.' + + field :finished_at, + Types::TimeType, + description: 'When the deployment finished.' + + field :status, + Types::DeploymentStatusEnum, + description: 'Status of the deployment.' + end +end diff --git a/app/graphql/types/deployments_order_by_input_type.rb b/app/graphql/types/deployments_order_by_input_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..a87fef9fe8ad0a028565a6fca82132c1643a009a --- /dev/null +++ b/app/graphql/types/deployments_order_by_input_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + class DeploymentsOrderByInputType < BaseInputObject + graphql_name 'DeploymentsOrderByInput' + description 'Values for ordering deployments by a specific field' + + argument :created_at, + Types::SortDirectionEnum, + required: false, + description: 'Order by Created time.' + + argument :finished_at, + Types::SortDirectionEnum, + required: false, + description: 'Order by Finished time.' + + def prepare + raise GraphQL::ExecutionError, 'orderBy parameter must contain one key-value pair.' unless to_h.size == 1 + + super + end + end +end diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb index 2a7076cc3c9a9aadcfbc2a1d2f4fdac4b0382be7..994be0e5f5aeed90275b61a4ff0ab0673cb41e7e 100644 --- a/app/graphql/types/environment_type.rb +++ b/app/graphql/types/environment_type.rb @@ -29,5 +29,14 @@ class EnvironmentType < BaseObject Types::AlertManagement::AlertType, null: true, description: 'Most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned.' + + # Setting high complexity for preventing users from querying deployments for multiple environments, + # which could result in N+1 issue. + field :deployments, + Types::DeploymentType.connection_type, + null: true, + description: 'Deployments of the environment.', + resolver: Resolvers::DeploymentsResolver, + complexity: 150 end end diff --git a/app/graphql/types/sort_direction_enum.rb b/app/graphql/types/sort_direction_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..28dba1abfb6ce5b9dfabfbb607d9ac846e46ccea --- /dev/null +++ b/app/graphql/types/sort_direction_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class SortDirectionEnum < BaseEnum + graphql_name 'SortDirectionEnum' + description 'Values for sort direction' + + value 'ASC', 'Ascending order.', value: 'asc' + value 'DESC', 'Descending order.', value: 'desc' + end +end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index a3213a59beda95c29444be3838de352e32ea1830..dfc9f3f8fcad37245d9b694d1eb7e5b5ac3efd87 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -61,6 +61,7 @@ class Deployment < ApplicationRecord VISIBLE_STATUSES = %i[running success failed canceled blocked].freeze FINISHED_STATUSES = %i[success failed canceled].freeze + UPCOMING_STATUSES = %i[created blocked running].freeze state_machine :status, initial: :created do event :run do diff --git a/app/presenters/deployments/deployment_presenter.rb b/app/presenters/deployments/deployment_presenter.rb new file mode 100644 index 0000000000000000000000000000000000000000..97d4accc59c45bd627f9694b4cd2a929ade67c4a --- /dev/null +++ b/app/presenters/deployments/deployment_presenter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Deployments + class DeploymentPresenter < Gitlab::View::Presenter::Delegated + presents ::Deployment, as: :deployment + end +end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index a0c228a69448bbcf786b728a4f1cf1b6271e8342..1e5472f43311e3692a8838090303e5abd1f3b636 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -6959,6 +6959,29 @@ The edge type for [`DependencyProxyManifest`](#dependencyproxymanifest). | <a id="dependencyproxymanifestedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | | <a id="dependencyproxymanifestedgenode"></a>`node` | [`DependencyProxyManifest`](#dependencyproxymanifest) | The item at the end of the edge. | +#### `DeploymentConnection` + +The connection type for [`Deployment`](#deployment). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="deploymentconnectionedges"></a>`edges` | [`[DeploymentEdge]`](#deploymentedge) | A list of edges. | +| <a id="deploymentconnectionnodes"></a>`nodes` | [`[Deployment]`](#deployment) | A list of nodes. | +| <a id="deploymentconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `DeploymentEdge` + +The edge type for [`Deployment`](#deployment). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="deploymentedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | +| <a id="deploymentedgenode"></a>`node` | [`Deployment`](#deployment) | The item at the end of the edge. | + #### `DesignAtVersionConnection` The connection type for [`DesignAtVersion`](#designatversion). @@ -10950,6 +10973,24 @@ Group-level Dependency Proxy settings. | ---- | ---- | ----------- | | <a id="dependencyproxysettingenabled"></a>`enabled` | [`Boolean!`](#boolean) | Indicates whether the dependency proxy is enabled for the group. | +### `Deployment` + +The deployment of an environment. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="deploymentcreatedat"></a>`createdAt` | [`Time`](#time) | When the deployment record was created. | +| <a id="deploymentfinishedat"></a>`finishedAt` | [`Time`](#time) | When the deployment finished. | +| <a id="deploymentid"></a>`id` | [`ID`](#id) | Global ID of the deployment. | +| <a id="deploymentiid"></a>`iid` | [`ID`](#id) | Project-level internal ID of the deployment. | +| <a id="deploymentref"></a>`ref` | [`String`](#string) | Git-Ref that the deployment ran on. | +| <a id="deploymentsha"></a>`sha` | [`String`](#string) | Git-SHA that the deployment ran on. | +| <a id="deploymentstatus"></a>`status` | [`DeploymentStatus`](#deploymentstatus) | Status of the deployment. | +| <a id="deploymenttag"></a>`tag` | [`Boolean`](#boolean) | True or false if the deployment ran on a Git-tag. | +| <a id="deploymentupdatedat"></a>`updatedAt` | [`Time`](#time) | When the deployment record was updated. | + ### `Design` A single design. @@ -11381,6 +11422,23 @@ Describes where code is deployed for a project. #### Fields with arguments +##### `Environment.deployments` + +Deployments of the environment. + +Returns [`DeploymentConnection`](#deploymentconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#connection-pagination-arguments): +`before: String`, `after: String`, `first: Int`, `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="environmentdeploymentsorderby"></a>`orderBy` | [`DeploymentsOrderByInput`](#deploymentsorderbyinput) | Order by a specified field. | +| <a id="environmentdeploymentsstatuses"></a>`statuses` | [`[DeploymentStatus!]`](#deploymentstatus) | Statuses of the deployments. | + ##### `Environment.metricsDashboard` Metrics dashboard schema for the environment. @@ -19579,6 +19637,20 @@ Weight of the data visualization palette. | <a id="dependencyproxymanifeststatuspending_destruction"></a>`PENDING_DESTRUCTION` | Dependency proxy manifest has a status of pending_destruction. | | <a id="dependencyproxymanifeststatusprocessing"></a>`PROCESSING` | Dependency proxy manifest has a status of processing. | +### `DeploymentStatus` + +All deployment statuses. + +| Value | Description | +| ----- | ----------- | +| <a id="deploymentstatusblocked"></a>`BLOCKED` | A deployment that is blocked. | +| <a id="deploymentstatuscanceled"></a>`CANCELED` | A deployment that is canceled. | +| <a id="deploymentstatuscreated"></a>`CREATED` | A deployment that is created. | +| <a id="deploymentstatusfailed"></a>`FAILED` | A deployment that is failed. | +| <a id="deploymentstatusrunning"></a>`RUNNING` | A deployment that is running. | +| <a id="deploymentstatusskipped"></a>`SKIPPED` | A deployment that is skipped. | +| <a id="deploymentstatussuccess"></a>`SUCCESS` | A deployment that is success. | + ### `DeploymentTier` All environment deployment tiers. @@ -20595,6 +20667,15 @@ Common sort values. | <a id="sortupdated_asc"></a>`updated_asc` **{warning-solid}** | **Deprecated** in 13.5. This was renamed. Use: `UPDATED_ASC`. | | <a id="sortupdated_desc"></a>`updated_desc` **{warning-solid}** | **Deprecated** in 13.5. This was renamed. Use: `UPDATED_DESC`. | +### `SortDirectionEnum` + +Values for sort direction. + +| Value | Description | +| ----- | ----------- | +| <a id="sortdirectionenumasc"></a>`ASC` | Ascending order. | +| <a id="sortdirectionenumdesc"></a>`DESC` | Descending order. | + ### `TestCaseStatus` | Value | Description | @@ -22349,6 +22430,17 @@ Input type for DastSiteProfile authentication. | <a id="dastsiteprofileauthinputusername"></a>`username` | [`String`](#string) | Username to authenticate with on the target. | | <a id="dastsiteprofileauthinputusernamefield"></a>`usernameField` | [`String`](#string) | Name of username field at the sign-in HTML form. | +### `DeploymentsOrderByInput` + +Values for ordering deployments by a specific field. + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="deploymentsorderbyinputcreatedat"></a>`createdAt` | [`SortDirectionEnum`](#sortdirectionenum) | Order by Created time. | +| <a id="deploymentsorderbyinputfinishedat"></a>`finishedAt` | [`SortDirectionEnum`](#sortdirectionenum) | Order by Finished time. | + ### `DiffImagePositionInput` #### Arguments diff --git a/spec/finders/deployments_finder_spec.rb b/spec/finders/deployments_finder_spec.rb index 51c293bcfd1fab49bafe7f2a23290c5a3d5b52d1..efb739c3d2fb100a87db2a43d81cb3f3b03acba0 100644 --- a/spec/finders/deployments_finder_spec.rb +++ b/spec/finders/deployments_finder_spec.rb @@ -32,7 +32,17 @@ it 'raises an error' do expect { subject }.to raise_error( described_class::InefficientQueryError, - '`finished_at` filter and `finished_at` sorting must be paired') + '`finished_at` filter requires `finished_at` sort.') + end + end + + context 'when running status filter and finished_at sorting' do + let(:params) { { status: :running, order_by: :finished_at } } + + it 'raises an error' do + expect { subject }.to raise_error( + described_class::InefficientQueryError, + '`finished_at` sort requires `finished_at` filter or a filter with at least one of the finished statuses.') end end @@ -52,7 +62,17 @@ it 'raises an error' do expect { subject }.to raise_error( described_class::InefficientQueryError, - '`environment` filter must be combined with `project` scope.') + '`environment` name filter must be combined with `project` scope.') + end + end + + context 'when status filter with mixed finished and upcoming statuses' do + let(:params) { { status: [:success, :running] } } + + it 'raises an error' do + expect { subject }.to raise_error( + described_class::InefficientQueryError, + 'finished statuses and upcoming statuses must be separately queried.') end end end @@ -103,6 +123,24 @@ end end + context 'when the environment ID is specified' do + let!(:environment1) { create(:environment, project: project) } + let!(:environment2) { create(:environment, project: project) } + let!(:deployment1) do + create(:deployment, project: project, environment: environment1) + end + + let!(:deployment2) do + create(:deployment, project: project, environment: environment2) + end + + let(:params) { { environment: environment1.id } } + + it 'returns deployments for the given environment' do + is_expected.to match_array([deployment1]) + end + end + context 'when the deployment status is specified' do let!(:deployment1) { create(:deployment, :success, project: project) } let!(:deployment2) { create(:deployment, :failed, project: project) } diff --git a/spec/graphql/resolvers/deployments_resolver_spec.rb b/spec/graphql/resolvers/deployments_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4e5564aad0bdb6f91b3424cd23d4d3641fe205a7 --- /dev/null +++ b/spec/graphql/resolvers/deployments_resolver_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::DeploymentsResolver do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :repository, :private) } + let_it_be(:environment) { create(:environment, project: project) } + let_it_be(:deployment) { create(:deployment, :created, environment: environment, project: project) } + let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } } + + let(:current_user) { developer } + + describe '#resolve' do + it 'finds the deployment' do + expect(resolve_deployments).to contain_exactly(deployment) + end + + it 'finds the deployment when status matches' do + expect(resolve_deployments(statuses: [:created])).to contain_exactly(deployment) + end + + it 'does not find the deployment when status does not match' do + expect(resolve_deployments(statuses: [:success])).to be_empty + end + + it 'transforms order_by for finder' do + expect(DeploymentsFinder) + .to receive(:new) + .with(environment: environment.id, status: ['success'], order_by: 'finished_at', sort: 'asc') + .and_call_original + + resolve_deployments(statuses: [:success], order_by: { finished_at: :asc }) + end + end + + def resolve_deployments(args = {}, context = { current_user: current_user }) + resolve(described_class, obj: environment, args: args, ctx: context) + end +end diff --git a/spec/graphql/types/deployment_type_spec.rb b/spec/graphql/types/deployment_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..21e445c24b2b1971482582e6f1d6e8b1103953d2 --- /dev/null +++ b/spec/graphql/types/deployment_type_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['Deployment'] do + specify { expect(described_class.graphql_name).to eq('Deployment') } + + it 'has the expected fields' do + expected_fields = %w[ + id iid ref tag sha created_at updated_at finished_at status + ] + + expect(described_class).to have_graphql_fields(*expected_fields) + end + + specify { expect(described_class).to require_graphql_authorizations(:read_deployment) } +end diff --git a/spec/graphql/types/environment_type_spec.rb b/spec/graphql/types/environment_type_spec.rb index 3671d35e8a5a92ed28f7b8790c8d58431ab728a9..16859394978e22b60b38d38f3fb79d3a5c0bb0d9 100644 --- a/spec/graphql/types/environment_type_spec.rb +++ b/spec/graphql/types/environment_type_spec.rb @@ -7,7 +7,7 @@ it 'has the expected fields' do expected_fields = %w[ - name id state metrics_dashboard latest_opened_most_severe_alert path + name id state metrics_dashboard latest_opened_most_severe_alert path deployments ] expect(described_class).to have_graphql_fields(*expected_fields) diff --git a/spec/requests/api/graphql/environments/deployments_query_spec.rb b/spec/requests/api/graphql/environments/deployments_query_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..fbfd9c5d0ac648dabd04fc6af08587efb31c5391 --- /dev/null +++ b/spec/requests/api/graphql/environments/deployments_query_spec.rb @@ -0,0 +1,345 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Environments Deployments query' do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :private, :repository) } + let_it_be(:environment) { create(:environment, project: project) } + let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } } + let_it_be(:guest) { create(:user).tap { |u| project.add_guest(u) } } + + let(:user) { developer } + + subject { GitlabSchema.execute(query, context: { current_user: user }).as_json } + + context 'when there are deployments in the environment' do + let_it_be(:finished_deployment_old) do + create(:deployment, :success, environment: environment, project: project, finished_at: 2.days.ago) + end + + let_it_be(:finished_deployment_new) do + create(:deployment, :success, environment: environment, project: project, finished_at: 1.day.ago) + end + + let_it_be(:upcoming_deployment_old) do + create(:deployment, :created, environment: environment, project: project, created_at: 2.hours.ago) + end + + let_it_be(:upcoming_deployment_new) do + create(:deployment, :created, environment: environment, project: project, created_at: 1.hour.ago) + end + + let_it_be(:other_environment) { create(:environment, project: project) } + let_it_be(:other_deployment) { create(:deployment, :success, environment: other_environment, project: project) } + + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + environment(name: "#{environment.name}") { + deployments { + nodes { + id + iid + ref + tag + sha + createdAt + updatedAt + finishedAt + status + } + } + } + } + } + ) + end + + it 'returns all deployments of the environment' do + deployments = subject.dig('data', 'project', 'environment', 'deployments', 'nodes') + + expect(deployments.count).to eq(4) + end + + context 'when query last deployment' do + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + environment(name: "#{environment.name}") { + deployments(statuses: [SUCCESS], orderBy: { finishedAt: DESC }, first: 1) { + nodes { + iid + } + } + } + } + } + ) + end + + it 'returns deployment' do + deployments = subject.dig('data', 'project', 'environment', 'deployments', 'nodes') + + expect(deployments.count).to eq(1) + expect(deployments[0]['iid']).to eq(finished_deployment_new.iid.to_s) + end + end + + context 'when query latest upcoming deployment' do + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + environment(name: "#{environment.name}") { + deployments(statuses: [CREATED RUNNING BLOCKED], orderBy: { createdAt: DESC }, first: 1) { + nodes { + iid + } + } + } + } + } + ) + end + + it 'returns deployment' do + deployments = subject.dig('data', 'project', 'environment', 'deployments', 'nodes') + + expect(deployments.count).to eq(1) + expect(deployments[0]['iid']).to eq(upcoming_deployment_new.iid.to_s) + end + end + + context 'when query finished deployments in descending order' do + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + environment(name: "#{environment.name}") { + deployments(statuses: [SUCCESS FAILED CANCELED], orderBy: { finishedAt: DESC }) { + nodes { + iid + } + } + } + } + } + ) + end + + it 'returns deployments' do + deployments = subject.dig('data', 'project', 'environment', 'deployments', 'nodes') + + expect(deployments.count).to eq(2) + expect(deployments[0]['iid']).to eq(finished_deployment_new.iid.to_s) + expect(deployments[1]['iid']).to eq(finished_deployment_old.iid.to_s) + end + end + + context 'when query finished deployments in ascending order' do + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + environment(name: "#{environment.name}") { + deployments(statuses: [SUCCESS FAILED CANCELED], orderBy: { finishedAt: ASC }) { + nodes { + iid + } + } + } + } + } + ) + end + + it 'returns deployments' do + deployments = subject.dig('data', 'project', 'environment', 'deployments', 'nodes') + + expect(deployments.count).to eq(2) + expect(deployments[0]['iid']).to eq(finished_deployment_old.iid.to_s) + expect(deployments[1]['iid']).to eq(finished_deployment_new.iid.to_s) + end + end + + context 'when query upcoming deployments in descending order' do + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + environment(name: "#{environment.name}") { + deployments(statuses: [CREATED RUNNING BLOCKED], orderBy: { createdAt: DESC }) { + nodes { + iid + } + } + } + } + } + ) + end + + it 'returns deployments' do + deployments = subject.dig('data', 'project', 'environment', 'deployments', 'nodes') + + expect(deployments.count).to eq(2) + expect(deployments[0]['iid']).to eq(upcoming_deployment_new.iid.to_s) + expect(deployments[1]['iid']).to eq(upcoming_deployment_old.iid.to_s) + end + end + + context 'when query upcoming deployments in ascending order' do + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + environment(name: "#{environment.name}") { + deployments(statuses: [CREATED RUNNING BLOCKED], orderBy: { createdAt: ASC }) { + nodes { + iid + } + } + } + } + } + ) + end + + it 'returns deployments' do + deployments = subject.dig('data', 'project', 'environment', 'deployments', 'nodes') + + expect(deployments.count).to eq(2) + expect(deployments[0]['iid']).to eq(upcoming_deployment_old.iid.to_s) + expect(deployments[1]['iid']).to eq(upcoming_deployment_new.iid.to_s) + end + end + + context 'when query last deployments of multiple environments' do + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + environments { + nodes { + name + deployments(statuses: [SUCCESS], orderBy: { finishedAt: DESC }, first: 1) { + nodes { + iid + } + } + } + } + } + } + ) + end + + it 'returnes an error for preventing N+1 queries' do + expect(subject['errors'][0]['message']).to include('exceeds max complexity') + end + end + + context 'when query finished and upcoming deployments together' do + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + environment(name: "#{environment.name}") { + deployments(statuses: [CREATED SUCCESS]) { + nodes { + iid + } + } + } + } + } + ) + end + + it 'raises an error' do + expect { subject }.to raise_error(DeploymentsFinder::InefficientQueryError) + end + end + + context 'when multiple orderBy input are specified' do + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + environment(name: "#{environment.name}") { + deployments(orderBy: { finishedAt: DESC, createdAt: ASC }) { + nodes { + iid + } + } + } + } + } + ) + end + + it 'raises an error' do + expect(subject['errors'][0]['message']).to include('orderBy parameter must contain one key-value pair.') + end + end + + context 'when user is guest' do + let(:user) { guest } + + it 'returns nothing' do + expect(subject['data']['project']['environment']).to be_nil + end + end + + describe 'sorting and pagination' do + let(:data_path) { [:project, :environment, :deployments] } + let(:current_user) { user } + + def pagination_query(params) + %( + query { + project(fullPath: "#{project.full_path}") { + environment(name: "#{environment.name}") { + deployments(statuses: [SUCCESS], #{params}) { + nodes { + iid + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } + } + } + ) + end + + def pagination_results_data(nodes) + nodes.map { |deployment| deployment['iid'].to_i } + end + + context 'when sorting by finished_at in ascending order' do + it_behaves_like 'sorted paginated query' do + let(:sort_argument) { graphql_args(orderBy: { finishedAt: :ASC }) } + let(:first_param) { 2 } + let(:all_records) { [finished_deployment_old.iid, finished_deployment_new.iid] } + end + end + + context 'when sorting by finished_at in descending order' do + it_behaves_like 'sorted paginated query' do + let(:sort_argument) { graphql_args(orderBy: { finishedAt: :DESC }) } + let(:first_param) { 2 } + let(:all_records) { [finished_deployment_new.iid, finished_deployment_old.iid] } + end + end + end + end +end