From 1d3591ff3c0327047926db7426d6c519d696cdf6 Mon Sep 17 00:00:00 2001 From: Schmil Monderer Date: Wed, 17 Dec 2025 12:05:58 +0000 Subject: [PATCH 1/4] Add support for updating default tracked context when default branch changes This commit implements the functionality to keep the default tracked context name synchronized with the project's default branch name. Changes include: 1. New event: Repositories::RepositoryCreatedEvent 2. New service: Security::ProjectTrackedContexts::UpdateDefaultService 3. New worker: Security::ProjectTrackedContexts::UpdateDefaultWorker 4. Event publishing in HasRepository concern 5. Event subscriptions in EventStore Changelog: added EE: true --- .../repositories/repository_created_event.rb | 16 ++++++ app/models/concerns/has_repository.rb | 5 ++ .../update_default_service.rb | 57 +++++++++++++++++++ .../update_default_worker.rb | 37 ++++++++++++ ee/lib/ee/gitlab/event_store.rb | 4 ++ 5 files changed, 119 insertions(+) create mode 100644 app/events/repositories/repository_created_event.rb create mode 100644 ee/app/services/security/project_tracked_contexts/update_default_service.rb create mode 100644 ee/app/workers/security/project_tracked_contexts/update_default_worker.rb diff --git a/app/events/repositories/repository_created_event.rb b/app/events/repositories/repository_created_event.rb new file mode 100644 index 00000000000000..3718404f7fba8c --- /dev/null +++ b/app/events/repositories/repository_created_event.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Repositories + class RepositoryCreatedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'container_id' => { 'type' => 'integer' }, + 'container_type' => { 'type' => 'string' } + }, + 'required' => %w[container_id container_type] + } + end + end +end diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb index bc155bf7809aca..c5bf067ecc7493 100644 --- a/app/models/concerns/has_repository.rb +++ b/app/models/concerns/has_repository.rb @@ -162,6 +162,11 @@ def after_repository_change_head ::Repositories::DefaultBranchChangedEvent.new(data: { container_id: id, container_type: self.class.name })) end + def after_create_repository + Gitlab::EventStore.publish( + ::Repositories::RepositoryCreatedEvent.new(data: { container_id: id, container_type: self.class.name })) + end + def after_change_head_branch_does_not_exist(branch) # No-op (by default) end diff --git a/ee/app/services/security/project_tracked_contexts/update_default_service.rb b/ee/app/services/security/project_tracked_contexts/update_default_service.rb new file mode 100644 index 00000000000000..293fa1963d6f42 --- /dev/null +++ b/ee/app/services/security/project_tracked_contexts/update_default_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Security + module ProjectTrackedContexts + class UpdateDefaultService < ::BaseService + def initialize(project, current_user = nil) + super + end + + def execute + return error_no_default_branch unless project.default_branch.present? + + default_context = find_or_initialize_default_context + + return success_response(default_context) if default_context.context_name == project.default_branch + + default_context.context_name = project.default_branch + + return error_response(default_context) unless default_context.save + + success_response(default_context) + end + + private + + def find_or_initialize_default_context + # Find existing default context or build a new one + project.security_project_tracked_contexts.default_branch.first_or_initialize do |context| + context.context_type = :branch + context.is_default = true + context.state = ::Security::ProjectTrackedContext::STATES[:tracked] + end + end + + def success_response(tracked_context) + ServiceResponse.success( + payload: { + tracked_context: tracked_context + } + ) + end + + def error_response(tracked_context) + ServiceResponse.error( + message: tracked_context.errors.full_messages.join(', '), + payload: { tracked_context: tracked_context } + ) + end + + def error_no_default_branch + ServiceResponse.error( + message: 'Project does not have a default branch' + ) + end + end + end +end diff --git a/ee/app/workers/security/project_tracked_contexts/update_default_worker.rb b/ee/app/workers/security/project_tracked_contexts/update_default_worker.rb new file mode 100644 index 00000000000000..88baf0ae7ba8eb --- /dev/null +++ b/ee/app/workers/security/project_tracked_contexts/update_default_worker.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Security + module ProjectTrackedContexts + class UpdateDefaultWorker + include ApplicationWorker + include Gitlab::EventStore::Subscriber + + data_consistency :sticky + feature_category :vulnerability_management + idempotent! + + def handle_event(event) + container_id = event.data[:container_id] + container_type = event.data[:container_type] + + return unless container_type == 'Project' + + Project.find_by_id(container_id).try do |project| + result = Security::ProjectTrackedContexts::UpdateDefaultService.new(project).execute + + log_error(project, result.errors) if result.error? + end + end + + private + + def log_error(project, errors) + Gitlab::AppLogger.warn( + message: "Failed to update default tracked context for project: #{errors.join(',')}", + project_id: project.id, + project_path: project.full_path + ) + end + end + end +end diff --git a/ee/lib/ee/gitlab/event_store.rb b/ee/lib/ee/gitlab/event_store.rb index 6ea9f521add355..5522a3e11515e7 100644 --- a/ee/lib/ee/gitlab/event_store.rb +++ b/ee/lib/ee/gitlab/event_store.rb @@ -67,6 +67,10 @@ def configure!(store) ::Gitlab::CurrentSettings.enable_member_promotion_management? } store.subscribe ::Security::CreateDefaultTrackedContextWorker, to: ::Projects::ProjectCreatedEvent + store.subscribe ::Security::ProjectTrackedContexts::UpdateDefaultWorker, + to: ::Repositories::DefaultBranchChangedEvent + store.subscribe ::Security::ProjectTrackedContexts::UpdateDefaultWorker, + to: ::Repositories::RepositoryCreatedEvent register_threat_insights_subscribers(store) register_security_policy_subscribers(store) -- GitLab From 9f32064d67a64983532e27b09114bd9a1c6d059c Mon Sep 17 00:00:00 2001 From: Schmil Monderer Date: Wed, 17 Dec 2025 12:06:54 +0000 Subject: [PATCH 2/4] Call after_create_repository when repository is created This ensures the RepositoryCreatedEvent is published when a repository is added to a project that didn't have one before. --- app/models/project.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/project.rb b/app/models/project.rb index 24cd9811db8425..72acd79dd45027 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -2347,6 +2347,7 @@ def create_repository(force: false, default_branch: nil, object_format: nil) repository.create_repository(default_branch, object_format: object_format) repository.after_create + after_create_repository true rescue StandardError => e -- GitLab From eb4163e2cbf427e34bd7624da078572c83824778 Mon Sep 17 00:00:00 2001 From: Schmil Monderer Date: Wed, 17 Dec 2025 12:07:48 +0000 Subject: [PATCH 3/4] Add specs for new service, worker, and event - Add specs for UpdateDefaultService - Add specs for UpdateDefaultWorker - Add specs for RepositoryCreatedEvent --- .../update_default_service_spec.rb | 117 ++++++++++++++++ .../update_default_worker_spec.rb | 125 ++++++++++++++++++ .../repository_created_event_spec.rb | 25 ++++ 3 files changed, 267 insertions(+) create mode 100644 ee/spec/services/security/project_tracked_contexts/update_default_service_spec.rb create mode 100644 ee/spec/workers/security/project_tracked_contexts/update_default_worker_spec.rb create mode 100644 spec/events/repositories/repository_created_event_spec.rb diff --git a/ee/spec/services/security/project_tracked_contexts/update_default_service_spec.rb b/ee/spec/services/security/project_tracked_contexts/update_default_service_spec.rb new file mode 100644 index 00000000000000..175f1d60f35115 --- /dev/null +++ b/ee/spec/services/security/project_tracked_contexts/update_default_service_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::ProjectTrackedContexts::UpdateDefaultService, feature_category: :vulnerability_management do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + + subject(:service) { described_class.new(project, user) } + + describe '#execute' do + context 'when project has a default branch' do + before do + allow(project).to receive(:default_branch).and_return('main') + end + + context 'when default tracked context does not exist' do + it 'creates a new default tracked context' do + expect { service.execute }.to change { Security::ProjectTrackedContext.count }.by(1) + + result = service.execute + expect(result).to be_success + + tracked_context = result.payload[:tracked_context] + expect(tracked_context.project).to eq(project) + expect(tracked_context.context_name).to eq('main') + expect(tracked_context.context_type).to eq('branch') + expect(tracked_context.is_default).to be true + expect(tracked_context).to be_tracked + end + end + + context 'when default tracked context exists with matching name' do + let!(:existing_context) do + create(:security_project_tracked_context, + project: project, + context_name: 'main', + context_type: :branch, + is_default: true, + state: :tracked) + end + + it 'does not update the context' do + expect { service.execute }.not_to change { existing_context.reload.updated_at } + + result = service.execute + expect(result).to be_success + expect(result.payload[:tracked_context]).to eq(existing_context) + end + end + + context 'when default tracked context exists with different name' do + let!(:existing_context) do + create(:security_project_tracked_context, + project: project, + context_name: 'master', + context_type: :branch, + is_default: true, + state: :tracked) + end + + it 'updates the context name to match the default branch' do + result = service.execute + + expect(result).to be_success + expect(existing_context.reload.context_name).to eq('main') + expect(result.payload[:tracked_context]).to eq(existing_context) + end + end + + context 'when update fails due to validation error' do + let!(:existing_context) do + create(:security_project_tracked_context, + project: project, + context_name: 'master', + context_type: :branch, + is_default: true, + state: :tracked) + end + + before do + # Create a context with the target name to cause uniqueness validation error + create(:security_project_tracked_context, + project: project, + context_name: 'main', + context_type: :branch, + is_default: false, + state: :tracked) + end + + it 'returns an error' do + result = service.execute + + expect(result).to be_error + expect(result.message).to include('Context name has already been taken') + end + end + end + + context 'when project does not have a default branch' do + before do + allow(project).to receive(:default_branch).and_return(nil) + end + + it 'returns an error' do + result = service.execute + + expect(result).to be_error + expect(result.message).to eq('Project does not have a default branch') + end + + it 'does not create a tracked context' do + expect { service.execute }.not_to change { Security::ProjectTrackedContext.count } + end + end + end +end diff --git a/ee/spec/workers/security/project_tracked_contexts/update_default_worker_spec.rb b/ee/spec/workers/security/project_tracked_contexts/update_default_worker_spec.rb new file mode 100644 index 00000000000000..869c4ae16b3230 --- /dev/null +++ b/ee/spec/workers/security/project_tracked_contexts/update_default_worker_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::ProjectTrackedContexts::UpdateDefaultWorker, feature_category: :vulnerability_management do + let_it_be(:project) { create(:project, :repository) } + + describe '#handle_event' do + shared_examples 'updates default tracked context' do + subject(:handle_event) { described_class.new.handle_event(event) } + + context 'when project exists' do + before do + allow(project).to receive(:default_branch).and_return('main') + end + + it 'calls UpdateDefaultService' do + expect(Security::ProjectTrackedContexts::UpdateDefaultService) + .to receive(:new).with(project).and_call_original + + handle_event + end + + context 'when UpdateDefaultService succeeds' do + it 'does not log any warnings' do + expect(Gitlab::AppLogger).not_to receive(:warn) + + handle_event + end + end + + context 'when UpdateDefaultService fails' do + let(:service_result) do + ServiceResponse.error( + message: 'Some error', + payload: { + tracked_context: build(:security_project_tracked_context) + } + ) + end + + before do + allow_next_instance_of(Security::ProjectTrackedContexts::UpdateDefaultService) do |service| + allow(service).to receive(:execute).and_return(service_result) + end + end + + it 'logs a warning' do + expect(Gitlab::AppLogger).to receive(:warn).with( + message: "Failed to update default tracked context for project: Some error", + project_id: project.id, + project_path: project.full_path + ) + + handle_event + end + end + end + + context 'when project does not exist' do + let(:event_data) { event.data.merge(container_id: non_existing_record_id) } + let(:event) { event_class.new(data: event_data) } + + it 'does not raise an error' do + expect { handle_event }.not_to raise_error + end + + it 'does not call UpdateDefaultService' do + expect(Security::ProjectTrackedContexts::UpdateDefaultService).not_to receive(:new) + + handle_event + end + end + + context 'when container_type is not Project' do + let(:event_data) { event.data.merge(container_type: 'Snippet') } + let(:event) { event_class.new(data: event_data) } + + it 'does not call UpdateDefaultService' do + expect(Security::ProjectTrackedContexts::UpdateDefaultService).not_to receive(:new) + + handle_event + end + end + end + + context 'when handling DefaultBranchChangedEvent' do + let(:event_class) { Repositories::DefaultBranchChangedEvent } + let(:event) do + event_class.new(data: { + container_id: project.id, + container_type: 'Project' + }) + end + + it_behaves_like 'updates default tracked context' + + include_examples 'an idempotent worker' do + let(:job_args) { [event] } + end + end + + context 'when handling RepositoryCreatedEvent' do + let(:event_class) { Repositories::RepositoryCreatedEvent } + let(:event) do + event_class.new(data: { + container_id: project.id, + container_type: 'Project' + }) + end + + it_behaves_like 'updates default tracked context' + + include_examples 'an idempotent worker' do + let(:job_args) { [event] } + end + end + end + + describe 'event subscription' do + it 'is subscribed to events' do + expect(described_class.ancestors).to include(Gitlab::EventStore::Subscriber) + end + end +end diff --git a/spec/events/repositories/repository_created_event_spec.rb b/spec/events/repositories/repository_created_event_spec.rb new file mode 100644 index 00000000000000..f68f2db7a4d3e0 --- /dev/null +++ b/spec/events/repositories/repository_created_event_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Repositories::RepositoryCreatedEvent, feature_category: :source_code_management do + it_behaves_like 'a GitLab event store event' + + it 'has the correct schema' do + expect(event.schema).to eq({ + 'type' => 'object', + 'properties' => { + 'container_id' => { 'type' => 'integer' }, + 'container_type' => { 'type' => 'string' } + }, + 'required' => %w[container_id container_type] + }) + end + + def event + described_class.new(data: { + container_id: 1, + container_type: 'Project' + }) + end +end -- GitLab From f39e0f85386119440f6cefde11f77b95b4ec6b05 Mon Sep 17 00:00:00 2001 From: Schmil Monderer Date: Wed, 17 Dec 2025 12:23:31 +0000 Subject: [PATCH 4/4] Call after_create_repository from Repository#after_create This is a better approach as it ensures the event is published whenever a repository is created, regardless of the code path. The Repository model now calls the container's after_create_repository method if it responds to it, making it work for any container type (Project, Snippet, etc.). --- app/models/project.rb | 1 - app/models/repository.rb | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/project.rb b/app/models/project.rb index 72acd79dd45027..24cd9811db8425 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -2347,7 +2347,6 @@ def create_repository(force: false, default_branch: nil, object_format: nil) repository.create_repository(default_branch, object_format: object_format) repository.after_create - after_create_repository true rescue StandardError => e diff --git a/app/models/repository.rb b/app/models/repository.rb index 5b39a219da6411..7fd32751cb1afe 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -502,6 +502,8 @@ def after_create expire_status_cache repository_event(:create_repository) + + container.after_create_repository if container.respond_to?(:after_create_repository) end # Runs code just before a repository is deleted. -- GitLab