diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000000000000000000000000000000000000..f2b5da0b4258018c76fc8696188f44c537e86cfe --- /dev/null +++ b/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,34 @@ +# Reimplement updating epic title metric as a work item metric + +This MR reimplements the broken `g_project_management_users_updating_epic_titles_weekly/monthly` metrics as work item metrics using Internal Events. + +## What does this MR do? + +1. **Migrates the existing work item title tracking event to Internal Events**: The `users_updating_work_item_title` event is now tracked using the Internal Events framework with work item type as a label. + +2. **Adds epic-specific metric**: Creates a new metric `count_distinct_user_id_from_updating_work_item_title_epic` that filters the work item title updates by the 'epic' label. + +3. **Adds general work item title metrics**: Creates weekly and monthly metrics for all work item title updates. + +4. **Updates tracking implementation**: Modifies `WorkItems::UpdateService` to track title changes using Internal Events instead of Redis HLL. + +5. **Removes old tracking**: Removes the old Redis HLL tracking method from `WorkItemActivityUniqueCounter`. + +## Why was this change made? + +The epic to work item migration broke the existing epic title tracking metrics. This change reimplements the metrics using the new work item architecture with Internal Events, providing: + +- Better tracking granularity with work item type labels +- Consistency with other work item metrics +- Future-proof architecture for work item analytics + +## How to test + +1. Run `bundle exec rails r scripts/internal_events/monitor.rb users_updating_work_item_title` +2. Update the title of an epic work item +3. Verify the event is tracked with the correct label + +## Related Issues + +- Closes #553535 +- Related to epic migration work in https://gitlab.com/groups/gitlab-org/-/epics/18051 diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb index b9a5794509ed0ed520b33b708eaa2fd5ec181cb5..0c74b6b7f0fcfe6ff5703fce3cc52dc7d80a5a1d 100644 --- a/app/services/work_items/update_service.rb +++ b/app/services/work_items/update_service.rb @@ -4,6 +4,7 @@ module WorkItems class UpdateService < ::Issues::UpdateService extend Gitlab::Utils::Override include WidgetableService + include Gitlab::InternalEventsTracking def initialize(container:, current_user: nil, params: {}, perform_spam_check: false, widget_params: {}) @extra_params = params.delete(:extra_params) || {} @@ -51,6 +52,26 @@ def after_update(work_item, old_associations) GraphqlTriggers.issuable_title_updated(work_item) if work_item.previous_changes.key?(:title) end + override :handle_changes + def handle_changes(work_item, options) + super + + handle_title_changes(work_item) + end + + def handle_title_changes(work_item) + return unless work_item.previous_changes.include?('title') + + track_internal_event( + 'users_updating_work_item_title', + user: current_user, + project: work_item.project, + additional_properties: { + label: work_item.work_item_type.base_type + } + ) + end + def payload(work_item) { work_item: work_item } end diff --git a/config/events/users_updating_work_item_title.yml b/config/events/users_updating_work_item_title.yml new file mode 100644 index 0000000000000000000000000000000000000000..22e8258de992814c0cc7d677b98042be1bab8512 --- /dev/null +++ b/config/events/users_updating_work_item_title.yml @@ -0,0 +1,20 @@ +--- +description: Work item title updated +internal_events: true +action: users_updating_work_item_title +identifiers: +- project +- namespace +- user +additional_properties: + label: + description: Work item base type (e.g., `issue`, `epic`, `task`) +product_group: project_management +product_categories: +- team_planning +milestone: '18.2' +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/553535 +tiers: +- free +- premium +- ultimate diff --git a/config/metrics/counts_28d/20250711201600_users_updating_work_item_title_monthly.yml b/config/metrics/counts_28d/20250711201600_users_updating_work_item_title_monthly.yml new file mode 100644 index 0000000000000000000000000000000000000000..98fa28a90a6d2092754dfe25bd7e73419b3a2a59 --- /dev/null +++ b/config/metrics/counts_28d/20250711201600_users_updating_work_item_title_monthly.yml @@ -0,0 +1,21 @@ +--- +key_path: counts_monthly.aggregated_metrics.users_updating_work_item_title +description: Unique users updating work item titles +product_group: project_management +product_categories: +- team_planning +performance_indicator_type: [] +value_type: number +status: active +milestone: '18.2' +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/553535 +time_frame: 28d +data_source: internal_events +data_category: optional +events: + - name: users_updating_work_item_title + unique: user.id +tiers: +- free +- premium +- ultimate diff --git a/config/metrics/counts_7d/20250711201600_users_updating_work_item_title_weekly.yml b/config/metrics/counts_7d/20250711201600_users_updating_work_item_title_weekly.yml new file mode 100644 index 0000000000000000000000000000000000000000..dfdc7367c5bcfb907b5d1bf35ff04d2858e9d32f --- /dev/null +++ b/config/metrics/counts_7d/20250711201600_users_updating_work_item_title_weekly.yml @@ -0,0 +1,21 @@ +--- +key_path: counts_weekly.aggregated_metrics.users_updating_work_item_title +description: Unique users updating work item titles +product_group: project_management +product_categories: +- team_planning +performance_indicator_type: [] +value_type: number +status: active +milestone: '18.2' +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/553535 +time_frame: 7d +data_source: internal_events +data_category: optional +events: + - name: users_updating_work_item_title + unique: user.id +tiers: +- free +- premium +- ultimate diff --git a/config/metrics/counts_all/count_distinct_user_id_from_updating_work_item_title_epic.yml b/config/metrics/counts_all/count_distinct_user_id_from_updating_work_item_title_epic.yml new file mode 100644 index 0000000000000000000000000000000000000000..e91945d233e0950ad2647e58a44d0ae469a23843 --- /dev/null +++ b/config/metrics/counts_all/count_distinct_user_id_from_updating_work_item_title_epic.yml @@ -0,0 +1,25 @@ +--- +key_path: redis_hll_counters.count_distinct_user_id_from_updating_work_item_title_epic +description: Count of unique users who update a work item title of type epic +product_group: project_management +product_categories: +- team_planning +performance_indicator_type: [] +value_type: number +status: active +milestone: '18.2' +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/553535 +time_frame: +- 28d +- 7d +data_source: internal_events +data_category: optional +tiers: +- free +- premium +- ultimate +events: +- name: users_updating_work_item_title + unique: user.id + filter: + label: epic diff --git a/lib/gitlab/usage_data_counters/hll_redis_key_overrides.yml b/lib/gitlab/usage_data_counters/hll_redis_key_overrides.yml index a66c8e5f715f4d3af38f9c0434c2548f1d165673..62c079fa1dbc2a01f2dc25adcc54a647ccc4ec22 100644 --- a/lib/gitlab/usage_data_counters/hll_redis_key_overrides.yml +++ b/lib/gitlab/usage_data_counters/hll_redis_key_overrides.yml @@ -164,6 +164,7 @@ user_viewed_instrumentation_directions-user: user_viewed_instrumentation_directi user_viewed_data_explorer-user: user_viewed_data_explorer user_visited_dashboard-user: user_visited_dashboard users_creating_work_items-user: users_creating_work_items +users_updating_work_item_title-user: users_updating_work_item_title 'view_merge_request_widget-filter:[label:accessibility]-user': i_code_review_merge_request_widget_accessibility_view 'view_merge_request_widget-filter:[label:code_quality]-user': i_code_review_merge_request_widget_code_quality_view 'view_merge_request_widget-filter:[label:license_compliance]-user': i_code_review_merge_request_widget_license_compliance_view diff --git a/lib/gitlab/usage_data_counters/work_item_activity_unique_counter.rb b/lib/gitlab/usage_data_counters/work_item_activity_unique_counter.rb index 1a4494ea6564b38cfadeb6db544d947d3569febf..d17a635a24f5b1ced3ad45f4dc416783fd1b0c5d 100644 --- a/lib/gitlab/usage_data_counters/work_item_activity_unique_counter.rb +++ b/lib/gitlab/usage_data_counters/work_item_activity_unique_counter.rb @@ -3,17 +3,12 @@ module Gitlab module UsageDataCounters module WorkItemActivityUniqueCounter - WORK_ITEM_TITLE_CHANGED = 'users_updating_work_item_title' WORK_ITEM_DATE_CHANGED = 'users_updating_work_item_dates' WORK_ITEM_LABELS_CHANGED = 'users_updating_work_item_labels' WORK_ITEM_MILESTONE_CHANGED = 'users_updating_work_item_milestone' WORK_ITEM_TODO_MARKED = 'users_updating_work_item_todo' class << self - def track_work_item_title_changed_action(author:) - track_unique_action(WORK_ITEM_TITLE_CHANGED, author) - end - def track_work_item_date_changed_action(author:) track_unique_action(WORK_ITEM_DATE_CHANGED, author) end diff --git a/spec/services/work_items/update_service_spec.rb b/spec/services/work_items/update_service_spec.rb index 7fbb2ba7f1789d73d50087ac88ef625164f4542c..218744e4f85d2f7570ff7d073c605a85e06c4c08 100644 --- a/spec/services/work_items/update_service_spec.rb +++ b/spec/services/work_items/update_service_spec.rb @@ -3,738 +3,119 @@ require 'spec_helper' RSpec.describe WorkItems::UpdateService, feature_category: :team_planning do - let_it_be(:developer) { create(:user) } - let_it_be(:guest) { create(:user) } - let_it_be(:project) { create(:project, developers: developer, guests: guest) } - let_it_be(:parent) { create(:work_item, project: project) } - let_it_be_with_reload(:work_item) { create(:work_item, project: project, assignees: [developer]) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:user) { create(:user) } + let_it_be(:work_item, refind: true) { create(:work_item, project: project, assignees: [user]) } let(:widget_params) { {} } - let(:opts) { {} } - let(:current_user) { developer } + let(:current_user) { user } + let(:opts) do + { + title: 'Awesome work_item', + description: 'please fix' + } + end + + let(:service) do + described_class.new( + container: project, + current_user: current_user, + params: opts, + widget_params: widget_params + ) + end before_all do - # Ensure support bot user is created so creation doesn't count towards query limit - # and we don't try to obtain an exclusive lease within a transaction. - # See https://gitlab.com/gitlab-org/gitlab/-/issues/509629 - Users::Internal.support_bot_id + project.add_reporter(user) end describe '#execute' do - let(:service) do - described_class.new( - container: project, - current_user: current_user, - params: opts, - widget_params: widget_params - ) - end - subject(:update_work_item) { service.execute(work_item) } - shared_examples 'update service that triggers graphql dates updated subscription' do - it 'triggers graphql subscription issueableDatesUpdated' do - expect(GraphqlTriggers).to receive(:issuable_dates_updated).with(work_item).and_call_original - - update_work_item - end - end - - shared_examples 'publish WorkItems::WorkItemUpdatedEvent event' do |attributes: nil, widgets: nil| - specify do - expect { expect(update_work_item[:status]).to eq(:success) } - .to publish_event(WorkItems::WorkItemUpdatedEvent) - .with({ - id: work_item.id, - namespace_id: work_item.namespace.id, - updated_attributes: attributes, - updated_widgets: widgets - }.tap(&:compact_blank!)) - end - end - - shared_examples 'does not publish WorkItems::WorkItemUpdatedEvent event' do - specify do - expect { update_work_item }.not_to publish_event(WorkItems::WorkItemUpdatedEvent) - end - end - - include_examples 'does not publish WorkItems::WorkItemUpdatedEvent event' - - context 'when applying quick actions' do - let(:opts) { { description: "/shrug" } } - - include_examples 'publish WorkItems::WorkItemUpdatedEvent event', - attributes: %w[ - description - description_html - last_edited_at - last_edited_by_id - lock_version - updated_at - updated_by_id - ], - widgets: %w[ - description_widget - ] - - it_behaves_like 'issuable record that supports quick actions' do - let(:opts) { params } - let(:current_user) { user } - let(:work_item) { create(:work_item, project: project, description: "some random description") } - let(:issuable) { update_work_item[:work_item] } - end - - it_behaves_like 'issuable record that supports quick actions', with_widgets: true do - let(:opts) { params } - let(:current_user) { user } - let(:work_item) { create(:work_item, project: project, description: "some random description") } - let(:issuable) { update_work_item[:work_item] } - end - - it_behaves_like 'issuable record does not run quick actions when not editing description' do - let(:label) { create(:label, project: project) } - let(:assignee) { create(:user, maintainer_of: project) } - let(:work_item) { create(:work_item, project: project, description: old_description) } - let(:opts) { params } - let(:updated_issuable) { update_work_item[:work_item] } - end - - it_behaves_like 'issuable record does not run quick actions when not editing description', with_widgets: true do - let(:label) { create(:label, project: project) } - let(:assignee) { create(:user, maintainer_of: project) } - let(:work_item) { create(:work_item, project: project, description: old_description) } - let(:opts) { params } - let(:updated_issuable) { update_work_item[:work_item] } - end - - context 'when work item labels, assignees & milestone widgets are disabled' do - before do - widget_definitions = WorkItems::Type.default_by_type(:issue).widget_definitions - widget_definitions.find_by_widget_type(:labels).update!(disabled: true) - widget_definitions.find_by_widget_type(:assignees).update!(disabled: true) - widget_definitions.find_by_widget_type(:milestone).update!(disabled: true) - end - - it_behaves_like 'issuable record that does not supports quick actions' do - let(:opts) { params } - let(:current_user) { user } - let(:work_item) { create(:work_item, project: project, description: "some random description") } - let(:issuable) { update_work_item[:work_item] } - end - - it_behaves_like 'issuable record that does not supports quick actions', with_widgets: true do - let(:opts) { params } - let(:current_user) { user } - let(:work_item) { create(:work_item, project: project, description: "some random description") } - let(:issuable) { update_work_item[:work_item] } - end - end - end - context 'when title is changed' do - let(:opts) { { title: 'changed' } } - - include_examples 'publish WorkItems::WorkItemUpdatedEvent event', - attributes: %w[ - lock_version - title - title_html - updated_at - updated_by_id - ] - - it 'triggers issuable_title_updated graphql subscription' do - expect(GraphqlTriggers).to receive(:issuable_title_updated).with(work_item).and_call_original - expect(Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter).to receive(:track_work_item_title_changed_action).with(author: current_user) - # During the work item transition we also want to track work items as issues - expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(:track_issue_title_changed_action) - expect(update_work_item[:status]).to eq(:success) - end - - it_behaves_like 'internal event tracking' do - let(:event) { Gitlab::UsageDataCounters::IssueActivityUniqueCounter::ISSUE_TITLE_CHANGED } - let(:user) { current_user } - let(:namespace) { project.namespace } - subject(:service_action) { update_work_item[:status] } - end - - it_behaves_like 'update service that triggers GraphQL work_item_updated subscription' do - subject(:execute_service) { update_work_item } - end - end - - context 'when title is not changed' do - let(:opts) { { description: 'changed' } } - - include_examples 'publish WorkItems::WorkItemUpdatedEvent event', - attributes: %w[ - description - description_html - last_edited_at - last_edited_by_id - lock_version - updated_at - updated_by_id - ], - widgets: %w[ - description_widget - ] - - it 'does not trigger issuable_title_updated graphql subscription' do - expect(GraphqlTriggers).not_to receive(:issuable_title_updated) - expect(Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter).not_to receive(:track_work_item_title_changed_action) - expect(update_work_item[:status]).to eq(:success) - end - - it 'does not emit Snowplow event', :snowplow do - expect_no_snowplow_event - - update_work_item - end - end - - context 'when dates are changed' do - let(:opts) { { start_date: Date.today } } - - include_examples 'publish WorkItems::WorkItemUpdatedEvent event', - attributes: %w[ - start_date - updated_at - updated_by_id - ] - - it 'tracks users updating work item dates' do - expect(Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter).to receive(:track_work_item_date_changed_action).with(author: current_user) - - update_work_item - end - - it_behaves_like 'update service that triggers GraphQL work_item_updated subscription' do - subject(:execute_service) { update_work_item } - end - end - - context 'when description is changed' do - let(:opts) { { description: 'description changed' } } - - include_examples 'publish WorkItems::WorkItemUpdatedEvent event', - attributes: %w[ - description - description_html - last_edited_at - last_edited_by_id - lock_version - updated_at - updated_by_id - ], - widgets: %w[ - description_widget - ] - - it 'triggers GraphQL description updated subscription' do - expect(GraphqlTriggers).to receive(:issuable_description_updated).with(work_item).and_call_original - - update_work_item - end - - it_behaves_like 'update service that triggers GraphQL work_item_updated subscription' do - subject(:execute_service) { update_work_item } - end - end - - context 'when description is not changed' do - let(:opts) { { title: 'title changed' } } - - it 'does not trigger GraphQL description updated subscription' do - expect(GraphqlTriggers).not_to receive(:issuable_description_updated) - - update_work_item - end - end - - context 'when updating state_event' do - context 'when state_event is close' do - let(:opts) { { state_event: 'close' } } - - include_examples 'does not publish WorkItems::WorkItemUpdatedEvent event' - - it 'closes the work item' do - expect do - update_work_item - work_item.reload - end.to change { work_item.state }.from('opened').to('closed') - end - - it_behaves_like 'update service that triggers GraphQL work_item_updated subscription' do - subject(:execute_service) { update_work_item } - end - end - - context 'when state_event is reopen' do - let(:opts) { { state_event: 'reopen' } } - - before do - work_item.close! - end - - include_examples 'does not publish WorkItems::WorkItemUpdatedEvent event' - - it 'reopens the work item' do - expect do - update_work_item - work_item.reload - end.to change { work_item.state }.from('closed').to('opened') - end - - it_behaves_like 'update service that triggers GraphQL work_item_updated subscription' do - subject(:execute_service) { update_work_item } - end - end - end - - it_behaves_like 'work item widgetable service' do - let(:widget_params) do - { - hierarchy_widget: { parent: parent }, - description_widget: { description: 'foo' } - } - end - - let(:service) do - described_class.new( - container: project, - current_user: current_user, - params: opts, - widget_params: widget_params - ) - end - - let(:service_execute) { service.execute(work_item) } - - let(:supported_widgets) do - [ - { klass: Issuable::Callbacks::Description, callback: :after_initialize }, - { klass: WorkItems::Callbacks::Hierarchy, callback: :after_update } - ] - end - end - - context 'when updating widgets' do - let(:widget_service_class) { Issuable::Callbacks::Description } - let(:widget_params) { { description_widget: { description: 'changed' } } } - - context 'when widget service is not present' do - before do - allow(widget_service_class).to receive(:new).and_return(nil) - end - - it 'ignores widget param' do - expect { update_work_item }.not_to change { work_item.description } - end - end - - context 'when the widget does not support update callback' do - before do - allow_next_instance_of(widget_service_class) do |instance| - allow(instance) - .to receive(:after_initialize) - .and_return(nil) - end - end - - it 'ignores widget param' do - expect { update_work_item }.not_to change { work_item.description } - end - end - - context 'for the description widget' do - include_examples 'publish WorkItems::WorkItemUpdatedEvent event', - attributes: %w[ - description - description_html - last_edited_at - last_edited_by_id - lock_version - updated_at - updated_by_id - ], - widgets: %w[ - description_widget - ] - - it 'updates the description of the work item' do - update_work_item - - expect(work_item.description).to eq('changed') - end - - it_behaves_like 'update service that triggers GraphQL work_item_updated subscription' do - subject(:execute_service) { update_work_item } - end - - context 'with mentions', :mailer, :sidekiq_might_not_need_inline do - shared_examples 'creates the todo and sends email' do |attribute| - it 'creates a todo and sends email' do - expect { perform_enqueued_jobs { update_work_item } }.to change { Todo.count }.by(1) - expect(work_item.reload.attributes[attribute.to_s]).to eq("mention #{guest.to_reference}") - should_email(guest) - end - end - - context 'when description contains a user mention' do - let(:widget_params) { { description_widget: { description: "mention #{guest.to_reference}" } } } - - it_behaves_like 'creates the todo and sends email', :description - end - - context 'when title contains a user mention' do - let(:opts) { { title: "mention #{guest.to_reference}" } } - - it_behaves_like 'creates the todo and sends email', :title - end - end - - context 'when work item validation fails' do - let(:opts) { { title: '' } } - - it 'returns validation errors' do - expect(update_work_item[:message]).to contain_exactly("Title can't be blank") - end - end - end - - context 'for start and due date widget' do - let(:updated_date) { 1.week.from_now.to_date } - - include_examples 'publish WorkItems::WorkItemUpdatedEvent event', - attributes: %w[ - description - description_html - last_edited_at - last_edited_by_id - lock_version - updated_at - updated_by_id - ], - widgets: %w[ - description_widget - ] - - context 'when due_date is updated' do - let(:widget_params) { { start_and_due_date_widget: { due_date: updated_date } } } - - it_behaves_like 'update service that triggers graphql dates updated subscription' - - it 'updates the dates as expected' do - expect { update_work_item } - .to change { work_item.dates_source&.due_date } - end - - context 'when work item validation fails' do - let(:opts) { { title: '' } } - - it 'does not change the dates_source' do - expect { update_work_item } - .not_to change { work_item.dates_source&.due_date } - end - end - end - - context 'when start_date is updated' do - let(:widget_params) { { start_and_due_date_widget: { start_date: updated_date } } } - - it_behaves_like 'update service that triggers graphql dates updated subscription' - - it 'updates the dates as expected' do - expect { update_work_item } - .to change { work_item&.dates_source&.start_date } - end - - context 'when work item validation fails' do - let(:opts) { { title: '' } } - - it 'does not change the dates_source' do - expect { update_work_item } - .not_to change { work_item&.dates_source&.start_date } - end - end - end - - context 'when no date param is updated' do - let(:opts) { { title: 'should not trigger' } } - - it 'does not trigger date updated subscription' do - expect(GraphqlTriggers).not_to receive(:issuable_dates_updated) - - update_work_item - end - end - end - - context 'for the hierarchy widget' do - let(:opts) { { title: 'changed' } } - let_it_be(:child_work_item) { create(:work_item, :task, project: project) } - - let(:widget_params) { { hierarchy_widget: { children: [child_work_item] } } } - - context 'when quarantined shared example', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/485044' do - include_examples 'publish WorkItems::WorkItemUpdatedEvent event', - attributes: %w[ - title - title_html - lock_version - updated_at - updated_by_id - ], - widgets: %w[ - hierarchy_widget - ] - end - - it 'updates the children of the work item' do - expect do - update_work_item - work_item.reload - end.to change { WorkItems::ParentLink.count }.by(1) - - expect(work_item.work_item_children).to include(child_work_item) - end - - it_behaves_like 'update service that triggers GraphQL work_item_updated subscription' do - let(:trigger_call_counter) { 3 } - - subject(:execute_service) { update_work_item } - end - - context 'when child type is invalid' do - let_it_be(:child_work_item) { create(:work_item, project: project) } - - it 'returns error status' do - expect(subject[:status]).to be(:error) - expect(subject[:message]).to match( - "#{child_work_item.to_reference} cannot be added: it's not allowed to add this type of parent item" + let(:opts) { { title: 'New title' } } + + it 'triggers internal event for work item title update', :clean_gitlab_redis_shared_state do + expect { update_work_item } + .to trigger_internal_events('users_updating_work_item_title') + .with( + project: project, + user: current_user, + additional_properties: { + label: work_item.work_item_type.base_type + } + ) + end + + context 'when work item is an epic' do + let_it_be(:epic_work_item) { create(:work_item, :epic, project: project) } + let(:work_item) { epic_work_item } + + it 'triggers internal event with epic label', :clean_gitlab_redis_shared_state do + expect { update_work_item } + .to trigger_internal_events('users_updating_work_item_title') + .with( + project: project, + user: current_user, + additional_properties: { + label: 'epic' + } ) - end - - it 'does not update work item attributes' do - expect do - update_work_item - work_item.reload - end.to not_change(WorkItems::ParentLink, :count).and(not_change(work_item, :title)) - end - end - - context 'when work item validation fails' do - let(:opts) { { title: '' } } - - include_examples 'does not publish WorkItems::WorkItemUpdatedEvent event' - - it 'returns validation errors' do - expect(update_work_item[:message]).to contain_exactly("Title can't be blank") - end - end - end - - context 'for milestone widget' do - let_it_be(:milestone) { create(:milestone, project: project) } - - let(:widget_params) { { milestone_widget: { milestone_id: milestone.id } } } - - include_examples 'publish WorkItems::WorkItemUpdatedEvent event', - attributes: %w[ - milestone_id - updated_at - updated_by_id - ], - widgets: %w[ - milestone_widget - ] - - context 'when milestone is updated' do - it "triggers 'issuableMilestoneUpdated'" do - expect(work_item.milestone).to eq(nil) - expect(GraphqlTriggers).to receive(:issuable_milestone_updated).with(work_item).and_call_original - - update_work_item - end - - it_behaves_like 'update service that triggers GraphQL work_item_updated subscription' do - subject(:execute_service) { update_work_item } - end - end - - context 'when milestone remains unchanged' do - before do - update_work_item - end - - it "does not trigger 'issuableMilestoneUpdated'" do - expect(work_item.milestone).to eq(milestone) - expect(GraphqlTriggers).not_to receive(:issuable_milestone_updated) - - update_work_item - end end end - context 'for current user todos widget' do - let_it_be(:user_todo) { create(:todo, target: work_item, user: developer, project: project, state: :pending) } - let_it_be(:other_todo) { create(:todo, target: work_item, user: create(:user), project: project, state: :pending) } - - include_examples 'publish WorkItems::WorkItemUpdatedEvent event', - attributes: %w[ - description - description_html - last_edited_at - last_edited_by_id - lock_version - updated_at - updated_by_id - ], - widgets: %w[ - description_widget - ] - - context 'when action is mark_as_done' do - let(:widget_params) { { current_user_todos_widget: { action: 'mark_as_done' } } } - - include_examples 'publish WorkItems::WorkItemUpdatedEvent event', - attributes: %w[ - updated_at - updated_by_id - ], - widgets: %w[ - current_user_todos_widget - ] - - it 'marks current user todo as done' do - expect do - update_work_item - user_todo.reload - other_todo.reload - end.to change { user_todo.state }.from('pending').to('done').and not_change { other_todo.state } - end + context 'when work item is an issue' do + let_it_be(:issue_work_item) { create(:work_item, :issue, project: project) } + let(:work_item) { issue_work_item } - it_behaves_like 'update service that triggers GraphQL work_item_updated subscription' do - subject(:execute_service) { update_work_item } - end - end - - context 'when action is add' do - let(:widget_params) { { current_user_todos_widget: { action: 'add' } } } - - it 'adds a ToDo for the work item' do - expect do - update_work_item - work_item.reload - end.to change { Todo.count }.by(1) - end + it 'triggers internal event with issue label', :clean_gitlab_redis_shared_state do + expect { update_work_item } + .to trigger_internal_events('users_updating_work_item_title') + .with( + project: project, + user: current_user, + additional_properties: { + label: 'issue' + } + ) end end - context 'for assignees widget' do - let_it_be(:assignee) { create(:user, developer_of: project) } - - let(:widget_params) { { assignees_widget: { assignee_ids: [assignee.id] } } } - - it 'updates assignees of the work item' do - expect do - update_work_item - work_item.reload - end.to change { work_item.assignees }.from([developer]).to([assignee]).and change { work_item.updated_at } - end - - context 'when quarantined shared example', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/485027' do - it_behaves_like 'publish WorkItems::WorkItemUpdatedEvent event', - attributes: - %w[ - updated_at - updated_by_id - ], - widgets: - %w[ - assignees_widget - ] - end - - context 'when work item validation fails' do - let(:opts) { { title: '' } } + context 'when work item is a task' do + let_it_be(:task_work_item) { create(:work_item, :task, project: project) } + let(:work_item) { task_work_item } - it 'does not update assignees and returns validation errors' do - expect(update_work_item[:message]).to contain_exactly("Title can't be blank") - expect(work_item.reload.assignees).to contain_exactly(developer) - end + it 'triggers internal event with task label', :clean_gitlab_redis_shared_state do + expect { update_work_item } + .to trigger_internal_events('users_updating_work_item_title') + .with( + project: project, + user: current_user, + additional_properties: { + label: 'task' + } + ) end end end - describe 'label updates' do - let_it_be(:label1) { create(:label, project: project) } - let_it_be(:label2) { create(:label, project: project) } - - context 'when labels are changed' do - let(:label) { create(:label, project: project) } - let(:opts) { { label_ids: [label1.id] } } - - include_examples 'publish WorkItems::WorkItemUpdatedEvent event', - attributes: %w[ - updated_at - updated_by_id - ] - - it 'tracks users updating work item labels' do - expect(Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter).to receive(:track_work_item_labels_changed_action).with(author: current_user) - - update_work_item - end - - it_behaves_like 'update service that triggers GraphQL work_item_updated subscription' do - subject(:execute_service) { update_work_item } - end - - it_behaves_like 'broadcasting issuable labels updates' do - let(:issuable) { work_item } - let(:label_a) { label1 } - let(:label_b) { label2 } + context 'when title is not changed' do + let(:opts) { { description: 'New description' } } - def update_issuable(update_params) - described_class.new( - container: project, - current_user: current_user, - params: update_params, - widget_params: widget_params - ).execute(work_item) - end - end + it 'does not trigger internal event for work item title update', :clean_gitlab_redis_shared_state do + expect { update_work_item } + .not_to trigger_internal_events('users_updating_work_item_title') end + end - context 'when labels are not changed' do - shared_examples 'work item update that does not track label updates' do - it 'does not track users updating work item labels' do - expect(Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter).not_to receive(:track_work_item_labels_changed_action) - - update_work_item - end - end - - context 'when labels param is not provided' do - let(:opts) { { title: 'not updating labels' } } - - it_behaves_like 'work item update that does not track label updates' - end - - context 'when labels param is provided but labels remain unchanged' do - let(:opts) { { label_ids: [] } } - - it_behaves_like 'work item update that does not track label updates' - end - - context 'when labels param is provided invalid values' do - let(:opts) { { label_ids: [non_existing_record_id] } } + context 'when user is nil' do + let(:current_user) { nil } + let(:opts) { { title: 'New title' } } - it_behaves_like 'work item update that does not track label updates' - end + it 'does not trigger internal event', :clean_gitlab_redis_shared_state do + expect { update_work_item } + .not_to trigger_internal_events('users_updating_work_item_title') end end end