diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb index 2395d0eb4439c63370beb5c3427e83a04b34caf8..42b066c1690a0452cb6a883a04f22eefaaee2c7a 100644 --- a/app/graphql/graphql_triggers.rb +++ b/app/graphql/graphql_triggers.rb @@ -9,6 +9,16 @@ def self.ci_pipeline_status_updated(pipeline) GitlabSchema.subscriptions.trigger(:ci_pipeline_status_updated, { pipeline_id: pipeline.to_gid }, pipeline) end + def self.ci_pipeline_schedule_status_updated(schedule) + return unless Feature.enabled?(:ci_pipeline_schedules_status_realtime, schedule.project) + + GitlabSchema.subscriptions.trigger( + :ci_pipeline_schedule_status_updated, + { project_id: schedule.project.to_gid }, + schedule + ) + end + def self.issuable_assignees_updated(issuable) GitlabSchema.subscriptions.trigger(:issuable_assignees_updated, { issuable_id: issuable.to_gid }, issuable) end diff --git a/app/graphql/subscriptions/ci/pipeline_schedule/status_updated.rb b/app/graphql/subscriptions/ci/pipeline_schedule/status_updated.rb new file mode 100644 index 0000000000000000000000000000000000000000..7ee8915c9a7c1649c220d71ab51aec0c75e99c40 --- /dev/null +++ b/app/graphql/subscriptions/ci/pipeline_schedule/status_updated.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Subscriptions + module Ci + module PipelineSchedule + class StatusUpdated < ::Subscriptions::BaseSubscription + include Gitlab::Graphql::Laziness + + argument :project_id, + ::Types::GlobalIDType[::Project], + required: true, + description: 'Global ID of the project.' + + payload_type Types::Ci::PipelineScheduleType + + def authorized?(project_id:) + project = force(GitlabSchema.find_by_gid(project_id)) + + unauthorized! unless project + unauthorized! unless Ability.allowed?(current_user, :read_pipeline_schedule, project) + + true + end + + def update(project_id:) + updated_schedule = object + + return NO_UPDATE unless updated_schedule + + project = force(GitlabSchema.find_by_gid(project_id)) + + return NO_UPDATE unless project && updated_schedule.project_id == project.id + return NO_UPDATE unless Ability.allowed?(current_user, :read_pipeline_schedule, updated_schedule) + + updated_schedule + end + end + end + end +end diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb index 94d84423869331759e9ce20e3110f7b7071d5d74..efe34d14225ddcf58a355ef5e208aad9d644a275 100644 --- a/app/graphql/types/subscription_type.rb +++ b/app/graphql/types/subscription_type.rb @@ -13,6 +13,11 @@ class SubscriptionType < ::Types::BaseObject description: 'Triggered when a pipeline status is updated.', experiment: { milestone: '17.10' } + field :ci_pipeline_schedule_status_updated, + subscription: Subscriptions::Ci::PipelineSchedule::StatusUpdated, null: true, + description: 'Triggered when a pipeline schedule is updated.', + experiment: { milestone: '18.4' } + field :issuable_assignees_updated, subscription: Subscriptions::IssuableUpdated, null: true, description: 'Triggered when the assignees of an issuable are updated.' diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index f04e64e36e924e247e9f2188b4ddb6030001ecb4..af87551ae203087c17c972f65f3e21ca6153614b 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -191,7 +191,7 @@ class Pipeline < Ci::ApplicationRecord validates :yaml_errors, bytesize: { maximum: -> { YAML_ERRORS_MAX_LENGTH } }, if: :yaml_errors_changed? after_create :keep_around_commits, unless: :importing? - after_commit :trigger_pipeline_status_change_subscription, if: :saved_change_to_status? + after_commit :trigger_status_change_subscriptions, if: :saved_change_to_status? after_commit :track_ci_pipeline_created_event, on: :create, if: :internal_pipeline? after_find :observe_age_in_minutes, unless: :importing? @@ -636,8 +636,12 @@ def self.internal_id_scope_usage :ci_pipelines end - def trigger_pipeline_status_change_subscription + def trigger_status_change_subscriptions GraphqlTriggers.ci_pipeline_status_updated(self) + + return unless self.pipeline_schedule_id.present? + + GraphqlTriggers.ci_pipeline_schedule_status_updated(self.pipeline_schedule) end def uses_needs? diff --git a/config/feature_flags/gitlab_com_derisk/ci_pipeline_schedules_status_realtime.yml b/config/feature_flags/gitlab_com_derisk/ci_pipeline_schedules_status_realtime.yml new file mode 100644 index 0000000000000000000000000000000000000000..7a6107fb169a1e457a2176917f5b498064e584fe --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/ci_pipeline_schedules_status_realtime.yml @@ -0,0 +1,10 @@ +--- +name: ci_pipeline_schedules_status_realtime +description: +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/560596 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/200990 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/561046 +milestone: '18.3' +group: group::pipeline execution +type: gitlab_com_derisk +default_enabled: false diff --git a/spec/graphql/graphql_triggers_spec.rb b/spec/graphql/graphql_triggers_spec.rb index ce53e3007bcb58a05838cd58ea72850cb797e40e..cd6c1fe3b9894e0769d980ac2254de0b76599d37 100644 --- a/spec/graphql/graphql_triggers_spec.rb +++ b/spec/graphql/graphql_triggers_spec.rb @@ -250,4 +250,34 @@ described_class.ci_job_status_updated(job) end end + + describe '.ci_pipeline_schedule_status_updated' do + let_it_be(:schedule) { create(:ci_pipeline_schedule, project: project, owner: project.first_owner) } + + it 'triggers the ci_pipeline_schedule_status_updated subscription' do + expect(GitlabSchema.subscriptions).to receive(:trigger).with( + :ci_pipeline_schedule_status_updated, + { project_id: schedule.project.to_gid }, + schedule + ) + + described_class.ci_pipeline_schedule_status_updated(schedule) + end + + describe 'when ci_pipeline_schedules_status_realtime is disabled' do + before do + stub_feature_flags(ci_pipeline_schedules_status_realtime: false) + end + + it 'does not trigger the ci_pipeline_schedules_status_realtime subscription' do + expect(GitlabSchema.subscriptions).not_to receive(:trigger).with( + :ci_pipeline_schedule_status_updated, + { project_id: schedule.project.to_gid }, + schedule + ) + + described_class.ci_pipeline_schedule_status_updated(schedule) + end + end + end end diff --git a/spec/graphql/subscriptions/ci/pipeline_schedule/status_updated_spec.rb b/spec/graphql/subscriptions/ci/pipeline_schedule/status_updated_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d92c8739d99c535048bb74176629f9fc628211ea --- /dev/null +++ b/spec/graphql/subscriptions/ci/pipeline_schedule/status_updated_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Subscriptions::Ci::PipelineSchedule::StatusUpdated, feature_category: :continuous_integration do + include GraphqlHelpers + + it { expect(described_class).to have_graphql_arguments(:project_id) } + it { expect(described_class.payload_type).to eq(Types::Ci::PipelineScheduleType) } + + describe '#resolve' do + let_it_be(:user) { create(:user) } + let_it_be(:unauthorized_user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:other_project) { create(:project) } + let_it_be(:schedule1) { create(:ci_pipeline_schedule, project: project, owner: user) } + let_it_be(:schedule2) { create(:ci_pipeline_schedule, project: project, owner: user) } + let_it_be(:other_schedule) { create(:ci_pipeline_schedule, project: other_project, owner: unauthorized_user) } + + let(:current_user) { user } + let(:project_id) { project.to_gid } + + before_all do + project.add_owner(user) + other_project.add_owner(unauthorized_user) + end + + subject(:subscription) { resolver.resolve_with_support(project_id: project_id) } + + context 'when initially subscribing to a projects pipeline schedules' do + let(:resolver) { resolver_instance(described_class, ctx: query_context, subscription_update: false) } + + it 'returns nil' do + expect(subscription).to be_nil + end + + context 'when the user is unauthorized' do + let(:current_user) { unauthorized_user } + + it 'raises an exception' do + expect { subscription }.to raise_error(GraphQL::ExecutionError) + end + end + + context 'when the project does not exist' do + let(:project_id) { GlobalID.parse("gid://gitlab/Project/#{non_existing_record_id}") } + + it 'raises an exception' do + expect { subscription }.to raise_error(GraphQL::ExecutionError) + end + end + + context 'when user can not read the schedule' do + before do + allow(Ability).to receive(:allowed?) + .with(current_user, :read_pipeline_schedule, project) + .and_return(false) + end + + it 'raises an exception' do + expect { subscription }.to raise_error(GraphQL::ExecutionError) + end + end + end + + context 'with subscription updates' do + let(:updated_schedule) { schedule1 } + let(:resolver) do + resolver_instance(described_class, obj: updated_schedule, ctx: query_context, subscription_update: true) + end + + context 'when the updated schedule is in the subscribed list' do + it 'returns the updated schedule' do + expect(subscription).to eq(updated_schedule) + end + end + + context 'when the updated schedule belongs to a different project' do + let(:updated_schedule) { other_schedule } + + it 'unsubscribes the user' do + # GraphQL::Execution::Skip is returned when unsubscribed + expect(subscription).to be_an(GraphQL::Execution::Skip) + end + end + + context 'with multiple schedules in the project' do + context 'when schedule1 updates' do + let(:updated_schedule) { schedule1 } + + it 'returns schedule1' do + expect(subscription).to eq(schedule1) + end + end + + context 'when schedule2 updates' do + let(:updated_schedule) { schedule2 } + + it 'returns schedule2' do + expect(subscription).to eq(schedule2) + end + end + end + end + end +end diff --git a/spec/graphql/types/subscription_type_spec.rb b/spec/graphql/types/subscription_type_spec.rb index 0e963dcfa37f6a92f0a44cd305dc2b83061d6c31..9214807dcda341902bbaa862910849bfa8b8b843 100644 --- a/spec/graphql/types/subscription_type_spec.rb +++ b/spec/graphql/types/subscription_type_spec.rb @@ -5,6 +5,7 @@ RSpec.describe GitlabSchema.types['Subscription'], feature_category: :subscription_management do it 'has the expected fields' do expected_fields = %i[ + ci_pipeline_schedule_status_updated ci_job_status_updated ci_pipeline_status_updated issuable_assignees_updated diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index e7863321b2de27b3344d151137c079778d36b87a..274693ad759f50a259e1da4f9f2a6a449952da61 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -319,15 +319,30 @@ end end - describe '.trigger_pipeline_status_change_subscription' do + describe '.trigger_status_change_subscriptions' do let(:pipeline) { build(:ci_pipeline, user: user) } %w[run! succeed! drop! skip! cancel! block! delay!].each do |action| context "when pipeline receives #{action} event" do - it 'triggers GraphQL subscription ciPipelineStatusUpdated' do - expect(GraphqlTriggers).to receive(:ci_pipeline_status_updated).with(pipeline) + context 'without a schedule' do + it 'triggers only GraphQL subscription ciPipelineStatusUpdated' do + expect(GraphqlTriggers).to receive(:ci_pipeline_status_updated).with(pipeline) + expect(GraphqlTriggers).not_to receive(:ci_pipeline_schedule_status_updated) - pipeline.public_send(action) + pipeline.public_send(action) + end + end + + context 'with a schedule' do + let(:schedule) { create(:ci_pipeline_schedule, :nightly, project: project) } + let(:pipeline) { build(:ci_pipeline, pipeline_schedule: schedule, project: project, user: user) } + + it 'triggers both GraphQL subscriptions' do + expect(GraphqlTriggers).to receive(:ci_pipeline_status_updated).with(pipeline) + expect(GraphqlTriggers).to receive(:ci_pipeline_schedule_status_updated).with(schedule) + + pipeline.public_send(action) + end end end end