diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 58f40e10fd4289cb97f21b7fc77ec2bfc8722c4c..e4354eaa45237ff7217b048802f1e48377ae9212 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -72,7 +72,19 @@ def activity format.json do load_events - pager_json("events/_events", @events.count, events: @events) + + if Feature.enabled?(:profile_tabs_vue, current_user) + @events = if user.include_private_contributions? + @events + else + @events.select { |event| event.visible_to_user?(current_user) } + end + + render json: ::Profile::EventSerializer.new(current_user: current_user, target_user: user) + .represent(@events) + else + pager_json("events/_events", @events.count, events: @events) + end end end end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 590659be0cb2c64ff98d6fc9a8eb541ab885c4cd..70b40221c35eaa57be2d844d631908e6023ccf46 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -2,6 +2,7 @@ module MergeRequestsHelper include Gitlab::Utils::StrongMemoize + include CompareHelper def create_mr_button_from_event?(event) create_mr_button?(from: event.branch_name, source_project: event.project) diff --git a/app/presenters/event_presenter.rb b/app/presenters/event_presenter.rb index 2f2fb1aa3ba856cf656b9b054596d11f9679e234..a098db7fbbc8b41495514139d29cdf9a46c8c622 100644 --- a/app/presenters/event_presenter.rb +++ b/app/presenters/event_presenter.rb @@ -9,7 +9,7 @@ def initialize(event, **attributes) @visible_to_user_cache = ActiveSupport::Cache::MemoryStore.new end - # Caching `visible_to_user?` method in the presenter beause it might be called multiple times. + # Caching `visible_to_user?` method in the presenter because it might be called multiple times. delegator_override :visible_to_user? def visible_to_user?(user = nil) @visible_to_user_cache.fetch(user&.id) { super(user) } diff --git a/app/serializers/profile/event_entity.rb b/app/serializers/profile/event_entity.rb new file mode 100644 index 0000000000000000000000000000000000000000..fe90265c888b5b70917160461396718b3d5570a0 --- /dev/null +++ b/app/serializers/profile/event_entity.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Profile + class EventEntity < Grape::Entity + include ActionView::Helpers::SanitizeHelper + include RequestAwareEntity + include MarkupHelper + include MergeRequestsHelper + include EventsHelper + + expose :created_at, if: ->(event) { include_private_event?(event) } + expose(:action, if: ->(event) { include_private_event?(event) }) { |event| event_action(event) } + + expose :ref, if: ->(event) { event.visible_to_user?(current_user) && event.push_action? } do + expose(:type) { |event| event.ref_type } # rubocop:disable Style/SymbolProc + expose(:count) { |event| event.ref_count } # rubocop:disable Style/SymbolProc + expose(:name) { |event| event.ref_name } # rubocop:disable Style/SymbolProc + expose(:path) { |event| ref_path(event) } + end + + expose :commit, if: ->(event) { event.visible_to_user?(current_user) && event.push_action? } do + expose(:truncated_sha) { |event| Commit.truncate_sha(event.commit_id) } + expose(:path) { |event| project_commit_path(event.project, event.commit_id) } + expose(:title) { |event| event_commit_title(event.commit_title) } + expose(:count) { |event| event.commits_count } # rubocop:disable Style/SymbolProc + expose(:create_mr_path) { |event| commit_create_mr_path(event) } + expose(:from_truncated_sha) { |event| commit_from(event) if event.commit_from } + expose(:to_truncated_sha) { |event| Commit.truncate_sha(event.commit_to) if event.commit_to } + + expose :compare_path, if: ->(event) { event.push_with_commits? && event.commits_count > 1 } do |event| + project = event.project + from = event.md_ref? ? event.commit_from : project.default_branch + project_compare_path(project, from: from, to: event.commit_to) + end + end + + expose :author, if: ->(event) { include_private_event?(event) } do + expose(:id) { |event| event.author.id } + expose(:name) { |event| event.author.name } + expose(:path) { |event| event.author.username } + end + + expose :target, if: ->(event) { event.visible_to_user?(current_user) } do + expose :target_type + + expose(:title) { |event| event.target_title } # rubocop:disable Style/SymbolProc + expose :target_url, if: ->(event) { event.target } do |event| + Gitlab::UrlBuilder.build(event.target, only_path: true) + end + expose :reference_link_text, if: ->(event) { event.target&.respond_to?(:reference_link_text) } do |event| + event.target.reference_link_text + end + expose :first_line_in_markdown, if: ->(event) { event.note? && event.target && event.project } do |event| + first_line_in_markdown(event.target, :note, 150, project: event.project) + end + expose :attachment, if: ->(event) { event.note? && event.target&.attachment } do + expose(:url) { |event| event.target.attachment.url } + end + end + + expose :resource_parent, if: ->(event) { event.visible_to_user?(current_user) } do + expose(:type) { |event| resource_parent_type(event) } + expose(:full_name) { |event| event.resource_parent&.full_name } + expose(:full_path) { |event| event.resource_parent&.full_path } + end + + private + + def current_user + request.current_user + end + + def target_user + request.target_user + end + + def include_private_event?(event) + event.visible_to_user?(current_user) || target_user.include_private_contributions? + end + + def commit_from(event) + if event.md_ref? + Commit.truncate_sha(event.commit_from) + else + event.project.default_branch + end + end + + def event_action(event) + if event.visible_to_user?(current_user) + event.action + elsif target_user.include_private_contributions? + 'private' + end + end + + def ref_path(event) + project = event.project + commits_link = project_commits_path(project, event.ref_name) + should_link = if event.tag? + project.repository.tag_exists?(event.ref_name) + else + project.repository.branch_exists?(event.ref_name) + end + + should_link ? commits_link : nil + end + + def commit_create_mr_path(event) + if event.new_ref? && + create_mr_button_from_event?(event) && + event.authored_by?(current_user) + create_mr_path_from_push_event(event) + end + end + + def resource_parent_type(event) + if event.project + "project" + elsif event.group + "group" + end + end + end +end diff --git a/app/serializers/profile/event_serializer.rb b/app/serializers/profile/event_serializer.rb new file mode 100644 index 0000000000000000000000000000000000000000..c7f23d61fe1b2676e6b394ca9f6e60d346432fd4 --- /dev/null +++ b/app/serializers/profile/event_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Profile + class EventSerializer < BaseSerializer + entity Profile::EventEntity + end +end diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index 11d8be24e0603cbb5b2fb25bac9c398fcccdb816..75ae0c970c02403b374d5321a48cff98aedf31b5 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -174,39 +174,95 @@ end context 'requested in json format' do - let(:project) { create(:project) } + context 'when profile_tabs_vue feature flag is turned OFF' do + let(:project) { create(:project) } - before do - project.add_developer(user) - Gitlab::DataBuilder::Push.build_sample(project, user) + before do + project.add_developer(user) + Gitlab::DataBuilder::Push.build_sample(project, user) + stub_feature_flags(profile_tabs_vue: false) + sign_in(user) + end - sign_in(user) - end + it 'loads events' do + get user_activity_url user.username, format: :json - it 'loads events' do - get user_activity_url user.username, format: :json + expect(response.media_type).to eq('application/json') + expect(Gitlab::Json.parse(response.body)['count']).to eq(1) + end - expect(response.media_type).to eq('application/json') - expect(Gitlab::Json.parse(response.body)['count']).to eq(1) - end + it 'hides events if the user cannot read cross project' do + allow(Ability).to receive(:allowed?).and_call_original + expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false } - it 'hides events if the user cannot read cross project' do - allow(Ability).to receive(:allowed?).and_call_original - expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false } + get user_activity_url user.username, format: :json - get user_activity_url user.username, format: :json + expect(response.media_type).to eq('application/json') + expect(Gitlab::Json.parse(response.body)['count']).to eq(0) + end - expect(response.media_type).to eq('application/json') - expect(Gitlab::Json.parse(response.body)['count']).to eq(0) + it 'hides events if the user has a private profile' do + Gitlab::DataBuilder::Push.build_sample(project, private_user) + + get user_activity_url private_user.username, format: :json + + expect(response.media_type).to eq('application/json') + expect(Gitlab::Json.parse(response.body)['count']).to eq(0) + end end - it 'hides events if the user has a private profile' do - Gitlab::DataBuilder::Push.build_sample(project, private_user) + context 'when profile_tabs_vue feature flag is turned ON' do + let(:project) { create(:project) } - get user_activity_url private_user.username, format: :json + before do + project.add_developer(user) + Gitlab::DataBuilder::Push.build_sample(project, user) + stub_feature_flags(profile_tabs_vue: true) + sign_in(user) + end - expect(response.media_type).to eq('application/json') - expect(Gitlab::Json.parse(response.body)['count']).to eq(0) + it 'loads events' do + get user_activity_url user.username, format: :json + + expect(response.media_type).to eq('application/json') + expect(Gitlab::Json.parse(response.body).count).to eq(1) + end + + it 'hides events if the user cannot read cross project' do + allow(Ability).to receive(:allowed?).and_call_original + expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false } + + get user_activity_url user.username, format: :json + + expect(response.media_type).to eq('application/json') + expect(Gitlab::Json.parse(response.body).count).to eq(0) + end + + it 'hides events if the user has a private profile' do + Gitlab::DataBuilder::Push.build_sample(project, private_user) + + get user_activity_url private_user.username, format: :json + + expect(response.media_type).to eq('application/json') + expect(Gitlab::Json.parse(response.body).count).to eq(0) + end + + it 'hides events if the user has a private profile' do + project = create(:project, :private) + private_event_user = create(:user, include_private_contributions: true) + push_data = Gitlab::DataBuilder::Push.build_sample(project, private_event_user) + EventCreateService.new.push(project, private_event_user, push_data) + + get user_activity_url private_event_user.username, format: :json + + response_body = Gitlab::Json.parse(response.body) + event = response_body.first + expect(response.media_type).to eq('application/json') + expect(response_body.count).to eq(1) + expect(event).to include('created_at', 'author', 'action') + expect(event['action']).to eq('private') + expect(event).not_to include('ref', 'commit', 'target', 'resource_parent') + end end end end diff --git a/spec/serializers/profile/event_entity_spec.rb b/spec/serializers/profile/event_entity_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1551fc764660179f298463e83c98680bd3ae3506 --- /dev/null +++ b/spec/serializers/profile/event_entity_spec.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Profile::EventEntity, feature_category: :user_profile do + let_it_be(:group) { create(:group) } # rubocop:disable RSpec/FactoryBot/AvoidCreate + let_it_be(:project) { build(:project_empty_repo, group: group) } + let_it_be(:user) { create(:user) } # rubocop:disable RSpec/FactoryBot/AvoidCreate + let_it_be(:merge_request) { create(:merge_request, source_project: project, target_project: project) } # rubocop:disable RSpec/FactoryBot/AvoidCreate + + let(:target_user) { user } + let(:event) { build(:event, :merged, author: user, project: project, target: merge_request) } + let(:request) { double(described_class, current_user: user, target_user: target_user) } # rubocop:disable RSpec/VerifiedDoubles + let(:entity) { described_class.new(event, request: request) } + + subject { entity.as_json } + + before do + group.add_maintainer(user) + end + + it 'exposes fields', :aggregate_failures do + expect(subject[:created_at]).to eq(event.created_at) + expect(subject[:action]).to eq(event.action) + expect(subject[:author][:id]).to eq(target_user.id) + expect(subject[:author][:name]).to eq(target_user.name) + expect(subject[:author][:path]).to eq(target_user.username) + end + + context 'for push events' do + let_it_be(:commit_from) { Gitlab::Git::BLANK_SHA } + let_it_be(:commit_title) { 'My commit' } + let(:event) { build(:push_event, project: project, author: target_user) } + + it 'exposes ref fields' do + build(:push_event_payload, event: event, ref_count: 3) + + expect(subject[:ref][:type]).to eq(event.ref_type) + expect(subject[:ref][:count]).to eq(event.ref_count) + expect(subject[:ref][:name]).to eq(event.ref_name) + expect(subject[:ref][:path]).to be_nil + end + + shared_examples 'returns ref path' do + specify do + expect(subject[:ref][:path]).to be_present + end + end + + context 'with tag' do + before do + allow(project.repository).to receive(:tag_exists?).and_return(true) + build(:push_event_payload, event: event, ref_type: :tag) + end + + it_behaves_like 'returns ref path' + end + + context 'with branch' do + before do + allow(project.repository).to receive(:branch_exists?).and_return(true) + build(:push_event_payload, event: event, ref_type: :branch) + end + + it_behaves_like 'returns ref path' + end + + it 'exposes commit fields' do + build(:push_event_payload, event: event, commit_title: commit_title, commit_from: commit_from, commit_count: 2) + + compare_path = "/#{group.path}/#{project.path}/-/compare/#{commit_from}...#{event.commit_to}" + expect(subject[:commit][:compare_path]).to eq(compare_path) + expect(event.commit_id).to include(subject[:commit][:truncated_sha]) + expect(subject[:commit][:path]).to be_present + expect(subject[:commit][:title]).to eq(commit_title) + expect(subject[:commit][:count]).to eq(2) + expect(commit_from).to include(subject[:commit][:from_truncated_sha]) + expect(event.commit_to).to include(subject[:commit][:to_truncated_sha]) + expect(subject[:commit][:create_mr_path]).to be_nil + end + + it 'exposes create_mr_path' do + allow(project).to receive(:default_branch).and_return('main') + allow(project.repository).to receive(:branch_exists?).and_return(true) + build(:push_event_payload, event: event, action: :created, commit_from: commit_from, commit_count: 2) + + new_mr_path = "/#{group.path}/#{project.path}/-/merge_requests/new?" \ + "merge_request%5Bsource_branch%5D=#{event.branch_name}" + expect(subject[:commit][:create_mr_path]).to eq(new_mr_path) + end + end + + context 'with target' do + let_it_be(:note) { build(:note_on_merge_request, :with_attachment, noteable: merge_request, project: project) } + + context 'when target does not responds to :reference_link_text' do + let(:event) { build(:event, :commented, project: project, target: note, author: target_user) } + + it 'exposes target fields' do + expect(subject[:target]).not_to include(:reference_link_text) + expect(subject[:target][:target_type]).to eq(note.class.to_s) + expect(subject[:target][:target_url]).to be_present + expect(subject[:target][:title]).to eq(note.title) + expect(subject[:target][:first_line_in_markdown]).to be_present + expect(subject[:target][:attachment][:url]).to eq(note.attachment.url) + end + end + + context 'when target responds to :reference_link_text' do + it 'exposes reference_link_text' do + expect(subject[:target][:reference_link_text]).to eq(merge_request.reference_link_text) + end + end + end + + context 'with resource parent' do + it 'exposes resource parent fields' do + resource_parent = event.resource_parent + + expect(subject[:resource_parent][:type]).to eq('project') + expect(subject[:resource_parent][:full_name]).to eq(resource_parent.full_name) + expect(subject[:resource_parent][:full_path]).to eq(resource_parent.full_path) + end + end + + context 'for private events' do + let(:event) { build(:event, :merged, author: target_user) } + + context 'when include_private_contributions? is true' do + let(:target_user) { build(:user, include_private_contributions: true) } + + it 'exposes only created_at, action, and author', :aggregate_failures do + expect(subject[:created_at]).to eq(event.created_at) + expect(subject[:action]).to eq('private') + expect(subject[:author][:id]).to eq(target_user.id) + expect(subject[:author][:name]).to eq(target_user.name) + expect(subject[:author][:path]).to eq(target_user.username) + + is_expected.not_to include(:ref, :commit, :target, :resource_parent) + end + end + + context 'when include_private_contributions? is false' do + let(:target_user) { build(:user, include_private_contributions: false) } + + it { is_expected.to be_empty } + end + end +end