Commit 50e4641c authored by Alex Kalderimis's avatar Alex Kalderimis

Add event creation support in wiki services

This ensures that events are created when wiki pages are
created/updated/destroyed in the app.

This does not add events for wiki pages edited off-line and then
published using git-access.

The wiki page base service is the place where WikiPageMeta objects are
constructed, leaving the logic in `EventCreateService#wiki_event` very
thin. For this reason the two related changes are contained in a
transaction.

We also refactor wiki services to avoid passing statically known
parameters. This is a more OOO conformant design, with fewer branches
and if statements. The different kinds of actions are distinguished,
with three actions:
 - external_action (used in the webhook)
 - usage_counter_action (used in the counter)
 - event_action (stored in events)

These have (potentially) different values and different types.

This interface in WikiPages::BaseService is enforced by implementing
the three methods inheriting classes must override.

We also place feature flag checks around more use sites. This ensures
that we do not create wiki page events if the flag is turned off, and
that we do not filter for them either.

Includes suggested changes from reviewer (@.luke)

Co-authored-by: @.luke

Incidental chang: Less dynamic queries

This passes class names for polymorphic types as constant strings. We do
this because these strings are persisted to the database, and changes to
the application need to keep previously stored values in mind. Any
updates to the WikiPage::Meta class name need to ensure that values
stored with `"WikiPage::Meta"` as target_type will still be around.
parent 342b97d7
...@@ -36,6 +36,8 @@ class Event < ApplicationRecord ...@@ -36,6 +36,8 @@ class Event < ApplicationRecord
expired: EXPIRED expired: EXPIRED
).freeze ).freeze
WIKI_ACTIONS = [CREATED, UPDATED, DESTROYED].freeze
TARGET_TYPES = HashWithIndifferentAccess.new( TARGET_TYPES = HashWithIndifferentAccess.new(
issue: Issue, issue: Issue,
milestone: Milestone, milestone: Milestone,
...@@ -81,8 +83,10 @@ class Event < ApplicationRecord ...@@ -81,8 +83,10 @@ class Event < ApplicationRecord
scope :recent, -> { reorder(id: :desc) } scope :recent, -> { reorder(id: :desc) }
scope :code_push, -> { where(action: PUSHED) } scope :code_push, -> { where(action: PUSHED) }
scope :merged, -> { where(action: MERGED) } scope :merged, -> { where(action: MERGED) }
scope :for_wiki_page, -> { where(target_type: WikiPage::Meta.name) } scope :for_wiki_page, -> { where(target_type: 'WikiPage::Meta') }
scope :not_wiki_page, -> { where('target_type IS NULL or target_type <> ?', WikiPage::Meta.name) }
# Needed to implement feature flag: can be removed when feature flag is removed
scope :not_wiki_page, -> { where('target_type IS NULL or target_type <> ?', 'WikiPage::Meta') }
scope :with_associations, -> do scope :with_associations, -> do
# We're using preload for "push_event_payload" as otherwise the association # We're using preload for "push_event_payload" as otherwise the association
...@@ -230,7 +234,7 @@ class Event < ApplicationRecord ...@@ -230,7 +234,7 @@ class Event < ApplicationRecord
end end
def wiki_page? def wiki_page?
target_type == WikiPage::Meta.name target_type == 'WikiPage::Meta'
end end
def milestone def milestone
......
...@@ -8,6 +8,8 @@ ...@@ -8,6 +8,8 @@
# EventCreateService.new.new_issue(issue, current_user) # EventCreateService.new.new_issue(issue, current_user)
# #
class EventCreateService class EventCreateService
IllegalActionError = Class.new(StandardError)
def open_issue(issue, current_user) def open_issue(issue, current_user)
create_record_event(issue, current_user, Event::CREATED) create_record_event(issue, current_user, Event::CREATED)
end end
...@@ -80,6 +82,19 @@ class EventCreateService ...@@ -80,6 +82,19 @@ class EventCreateService
create_push_event(BulkPushEventPayloadService, project, current_user, push_data) create_push_event(BulkPushEventPayloadService, project, current_user, push_data)
end end
# Create a new wiki page event
#
# @param [WikiPage::Meta] wiki_page_meta The event target
# @param [User] current_user The event author
# @param [Integer] action One of the Event::WIKI_ACTIONS
def wiki_event(wiki_page_meta, current_user, action)
return unless Feature.enabled?(:wiki_events)
raise IllegalActionError, action unless Event::WIKI_ACTIONS.include?(action)
create_record_event(wiki_page_meta, current_user, action)
end
private private
def create_record_event(record, current_user, status) def create_record_event(record, current_user, status)
......
# frozen_string_literal: true # frozen_string_literal: true
module WikiPages module WikiPages
# There are 3 notions of 'action' that inheriting classes must implement:
#
# - external_action: the action we report to external clients with webhooks
# - usage_counter_action: the action that we count in out internal counters
# - event_action: what we record as the value of `Event#action`
class BaseService < ::BaseService class BaseService < ::BaseService
private private
def execute_hooks(page, action = 'create') def execute_hooks(page)
page_data = Gitlab::DataBuilder::WikiPage.build(page, current_user, action) page_data = payload(page)
@project.execute_hooks(page_data, :wiki_page_hooks) @project.execute_hooks(page_data, :wiki_page_hooks)
@project.execute_services(page_data, :wiki_page_hooks) @project.execute_services(page_data, :wiki_page_hooks)
increment_usage(action) increment_usage
create_wiki_event(page)
end
# Passed to web-hooks, and send to external consumers.
def external_action
raise NotImplementedError
end
# Passed to the WikiPageCounter to count events.
# Must be one of WikiPageCounter::KNOWN_EVENTS
def usage_counter_action
raise NotImplementedError
end
# Used to create `Event` records.
# Must be a valid value for `Event#action`
def event_action
raise NotImplementedError
end
def payload(page)
Gitlab::DataBuilder::WikiPage.build(page, current_user, external_action)
end end
# This method throws an error if the action is an unanticipated value. # This method throws an error if the action is an unanticipated value.
def increment_usage(action) def increment_usage
Gitlab::UsageDataCounters::WikiPageCounter.count(action) Gitlab::UsageDataCounters::WikiPageCounter.count(usage_counter_action)
end
def create_wiki_event(page)
return unless ::Feature.enabled?(:wiki_events)
slug = slug_for_page(page)
Event.transaction do
wiki_page_meta = WikiPage::Meta.find_or_create(slug, page)
EventCreateService.new.wiki_event(wiki_page_meta, current_user, event_action)
end
end
def slug_for_page(page)
page.slug
end end
end end
end end
......
...@@ -7,10 +7,22 @@ module WikiPages ...@@ -7,10 +7,22 @@ module WikiPages
page = WikiPage.new(project_wiki) page = WikiPage.new(project_wiki)
if page.create(@params) if page.create(@params)
execute_hooks(page, 'create') execute_hooks(page)
end end
page page
end end
def usage_counter_action
:create
end
def external_action
'create'
end
def event_action
Event::CREATED
end
end end
end end
...@@ -4,10 +4,22 @@ module WikiPages ...@@ -4,10 +4,22 @@ module WikiPages
class DestroyService < WikiPages::BaseService class DestroyService < WikiPages::BaseService
def execute(page) def execute(page)
if page&.delete if page&.delete
execute_hooks(page, 'delete') execute_hooks(page)
end end
page page
end end
def usage_counter_action
:delete
end
def external_action
'delete'
end
def event_action
Event::DESTROYED
end
end end
end end
...@@ -3,11 +3,30 @@ ...@@ -3,11 +3,30 @@
module WikiPages module WikiPages
class UpdateService < WikiPages::BaseService class UpdateService < WikiPages::BaseService
def execute(page) def execute(page)
# this class is not thread safe!
@old_slug = page.slug
if page.update(@params) if page.update(@params)
execute_hooks(page, 'update') execute_hooks(page)
end end
page page
end end
def usage_counter_action
:update
end
def external_action
'update'
end
def event_action
Event::UPDATED
end
def slug_for_page(page)
@old_slug.presence || super
end
end end
end end
...@@ -11,7 +11,7 @@ module EE ...@@ -11,7 +11,7 @@ module EE
private private
def execute_hooks(page, action = 'create') def execute_hooks(page)
super super
process_wiki_repository_update process_wiki_repository_update
end end
......
...@@ -25,12 +25,12 @@ FactoryBot.define do ...@@ -25,12 +25,12 @@ FactoryBot.define do
factory :wiki_page_event do factory :wiki_page_event do
action { Event::CREATED } action { Event::CREATED }
project { @overrides[:wiki_page]&.project || create(:project, :wiki_repo) }
target { create(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page) }
transient do transient do
wiki_page { create(:wiki_page, project: project) } wiki_page { create(:wiki_page, project: project) }
end end
target { create(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page) }
end end
end end
......
...@@ -470,6 +470,7 @@ describe Event do ...@@ -470,6 +470,7 @@ describe Event do
it 'only contains the wiki page events' do it 'only contains the wiki page events' do
wiki_events = events.select(&:wiki_page?) wiki_events = events.select(&:wiki_page?)
expect(events).not_to match_array(wiki_events)
expect(described_class.for_wiki_page).to match_array(wiki_events) expect(described_class.for_wiki_page).to match_array(wiki_events)
end end
end end
...@@ -478,6 +479,7 @@ describe Event do ...@@ -478,6 +479,7 @@ describe Event do
it 'does not contain the wiki page events' do it 'does not contain the wiki page events' do
non_wiki_events = events.reject(&:wiki_page?) non_wiki_events = events.reject(&:wiki_page?)
expect(events).not_to match_array(non_wiki_events)
expect(described_class.not_wiki_page).to match_array(non_wiki_events) expect(described_class.not_wiki_page).to match_array(non_wiki_events)
end end
end end
......
...@@ -115,21 +115,15 @@ describe API::Events do ...@@ -115,21 +115,15 @@ describe API::Events do
end end
context 'when the list of events includes wiki page events' do context 'when the list of events includes wiki page events' do
let(:page) do it 'returns information about the wiki event', :aggregate_failures do
wiki = create(:project_wiki, project: private_project, user: user) page = create(:wiki_page, project: private_project)
create(:wiki_page, wiki: wiki) [Event::CREATED, Event::UPDATED, Event::DESTROYED].each do |action|
end create(:wiki_page_event, wiki_page: page, action: action, author: user)
before do
[Event::CREATED, Event::UPDATED, Event::DESTROYED].each do |event|
EventCreateService.new.wiki_event(page, event, nil)
end end
end
it 'returns information about the wiki event', :aggregate_failures do
get api("/users/#{user.id}/events", user) get api("/users/#{user.id}/events", user)
wiki_events = json_response.select { |e| e['target_type'] == 'WikiPageMeta' } wiki_events = json_response.select { |e| e['target_type'] == 'WikiPage::Meta' }
action_names = wiki_events.map { |e| e['action_name'] } action_names = wiki_events.map { |e| e['action_name'] }
titles = wiki_events.map { |e| e['target_title'] } titles = wiki_events.map { |e| e['target_title'] }
slugs = wiki_events.map { |e| e.dig('wiki_page', 'slug') } slugs = wiki_events.map { |e| e.dig('wiki_page', 'slug') }
......
...@@ -153,6 +153,46 @@ describe EventCreateService do ...@@ -153,6 +153,46 @@ describe EventCreateService do
end end
end end
describe '#wiki_event' do
let_it_be(:user) { create(:user) }
let_it_be(:wiki_page) { create(:wiki_page) }
let_it_be(:meta) { create(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page) }
Event::WIKI_ACTIONS.each do |action|
context "The action is #{action}" do
let(:event) { service.wiki_event(meta, user, action) }
it 'creates the event' do
expect(event).to have_attributes(
wiki_page?: true,
valid?: true,
persisted?: true,
action: action,
wiki_page: wiki_page
)
end
context 'the feature is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not create the event' do
expect { event }.not_to change(Event, :count)
end
end
end
end
(Event::ACTIONS.values - Event::WIKI_ACTIONS).each do |bad_action|
context "The action is #{bad_action}" do
it 'raises an error' do
expect { service.wiki_event(meta, user, bad_action) }.to raise_error(described_class::IllegalActionError)
end
end
end
end
describe '#push', :clean_gitlab_redis_shared_state do describe '#push', :clean_gitlab_redis_shared_state do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:user) { create(:user) } let(:user) { create(:user) }
......
...@@ -6,22 +6,24 @@ describe WikiPages::BaseService do ...@@ -6,22 +6,24 @@ describe WikiPages::BaseService do
let(:project) { double('project') } let(:project) { double('project') }
let(:user) { double('user') } let(:user) { double('user') }
subject(:service) { described_class.new(project, user, {}) }
describe '#increment_usage' do describe '#increment_usage' do
counter = Gitlab::UsageDataCounters::WikiPageCounter counter = Gitlab::UsageDataCounters::WikiPageCounter
error = counter::UnknownEvent error = counter::UnknownEvent
it 'raises an error on unknown events' do let(:subject) { bad_service_class.new(project, user, {}) }
expect { subject.send(:increment_usage, :bad_event) }.to raise_error error
end
context 'the event is valid' do context 'the class implements usage_counter_action incorrectly' do
counter::KNOWN_EVENTS.each do |e| let(:bad_service_class) do
it "updates the #{e} counter" do Class.new(described_class) do
expect { subject.send(:increment_usage, e) }.to change { counter.read(e) } def usage_counter_action
:bad_event
end
end end
end end
it 'raises an error on unknown events' do
expect { subject.send(:increment_usage) }.to raise_error(error)
end
end end
end end
end end
...@@ -5,19 +5,16 @@ require 'spec_helper' ...@@ -5,19 +5,16 @@ require 'spec_helper'
describe WikiPages::CreateService do describe WikiPages::CreateService do
let(:project) { create(:project, :wiki_repo) } let(:project) { create(:project, :wiki_repo) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:page_title) { 'Title' }
let(:opts) do let(:opts) do
{ {
title: 'Title', title: page_title,
content: 'Content for wiki page', content: 'Content for wiki page',
format: 'markdown' format: 'markdown'
} }
end end
let(:bad_opts) do
{ title: '' }
end
subject(:service) { described_class.new(project, user, opts) } subject(:service) { described_class.new(project, user, opts) }
before do before do
...@@ -35,8 +32,7 @@ describe WikiPages::CreateService do ...@@ -35,8 +32,7 @@ describe WikiPages::CreateService do
end end
it 'executes webhooks' do it 'executes webhooks' do
expect(service).to receive(:execute_hooks).once expect(service).to receive(:execute_hooks).once.with(WikiPage)
.with(instance_of(WikiPage), 'create')
service.execute service.execute
end end
...@@ -47,8 +43,41 @@ describe WikiPages::CreateService do ...@@ -47,8 +43,41 @@ describe WikiPages::CreateService do
expect { service.execute }.to change { counter.read(:create) }.by 1 expect { service.execute }.to change { counter.read(:create) }.by 1
end end
shared_examples 'correct event created' do
it 'creates appropriate events' do
expect { service.execute }.to change { Event.count }.by 1
expect(Event.recent.first).to have_attributes(
action: Event::CREATED,
target: have_attributes(canonical_slug: page_title)
)
end
end
context 'the new page is at the top level' do
let(:page_title) { 'root-level-page' }
include_examples 'correct event created'
end
context 'the new page is in a subsection' do
let(:page_title) { 'subsection/page' }
include_examples 'correct event created'
end
context 'the feature is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not record the activity' do
expect { service.execute }.not_to change(Event, :count)
end
end
context 'when the options are bad' do context 'when the options are bad' do
subject(:service) { described_class.new(project, user, bad_opts) } let(:page_title) { '' }
it 'does not count a creation event' do it 'does not count a creation event' do
counter = Gitlab::UsageDataCounters::WikiPageCounter counter = Gitlab::UsageDataCounters::WikiPageCounter
...@@ -56,6 +85,10 @@ describe WikiPages::CreateService do ...@@ -56,6 +85,10 @@ describe WikiPages::CreateService do
expect { service.execute }.not_to change { counter.read(:create) } expect { service.execute }.not_to change { counter.read(:create) }
end end
it 'does not record the activity' do
expect { service.execute }.not_to change(Event, :count)
end
it 'reports the error' do it 'reports the error' do
expect(service.execute).to be_invalid expect(service.execute).to be_invalid
.and have_attributes(errors: be_present) .and have_attributes(errors: be_present)
......
...@@ -15,8 +15,7 @@ describe WikiPages::DestroyService do ...@@ -15,8 +15,7 @@ describe WikiPages::DestroyService do
describe '#execute' do describe '#execute' do
it 'executes webhooks' do it 'executes webhooks' do
expect(service).to receive(:execute_hooks).once expect(service).to receive(:execute_hooks).once.with(page)
.with(instance_of(WikiPage), 'delete')
service.execute(page) service.execute(page)
end end
...@@ -27,10 +26,29 @@ describe WikiPages::DestroyService do ...@@ -27,10 +26,29 @@ describe WikiPages::DestroyService do
expect { service.execute(page) }.to change { counter.read(:delete) }.by 1 expect { service.execute(page) }.to change { counter.read(:delete) }.by 1
end end
it 'creates a new wiki page deletion event' do
expect { service.execute(page) }.to change { Event.count }.by 1
expect(Event.recent.first).to have_attributes(
action: Event::DESTROYED,
target: have_attributes(canonical_slug: page.slug)
)
end
it 'does not increment the delete count if the deletion failed' do it 'does not increment the delete count if the deletion failed' do
counter = Gitlab::UsageDataCounters::WikiPageCounter counter = Gitlab::UsageDataCounters::WikiPageCounter
expect { service.execute(nil) }.not_to change { counter.read(:delete) } expect { service.execute(nil) }.not_to change { counter.read(:delete) }
end end
end end
context 'the feature is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not record the activity' do
expect { service.execute(page) }.not_to change(Event, :count)
end
end
end end
...@@ -6,20 +6,17 @@ describe WikiPages::UpdateService do ...@@ -6,20 +6,17 @@ describe WikiPages::UpdateService do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:page) { create(:wiki_page) } let(:page) { create(:wiki_page) }
let(:page_title) { 'New Title' }
let(:opts) do let(:opts) do
{ {
content: 'New content for wiki page', content: 'New content for wiki page',
format: 'markdown', format: 'markdown',
message: 'New wiki message', message: 'New wiki message',
title: 'New Title' title: page_title
} }
end end
let(:bad_opts) do
{ title: '' }
end
subject(:service) { described_class.new(project, user, opts) } subject(:service) { described_class.new(project, user, opts) }
before do before do
...@@ -34,12 +31,11 @@ describe WikiPages::UpdateService do ...@@ -34,12 +31,11 @@ describe WikiPages::UpdateService do
expect(updated_page.message).to eq(opts[:message]) expect(updated_page.message).to eq(opts[:message])
expect(updated_page.content).to eq(opts[:content]) expect(updated_page.content).to eq(opts[:content])
expect(updated_page.format).to eq(opts[:format].to_sym) expect(updated_page.format).to eq(opts[:format].to_sym)
expect(updated_page.title).to eq(opts[:title]) expect(updated_page.title).to eq(page_title)
end end
it 'executes webhooks' do it 'executes webhooks' do
expect(service).to receive(:execute_hooks).once expect(service).to receive(:execute_hooks).once.with(WikiPage)
.with(instance_of(WikiPage), 'update')
service.execute(page) service.execute(page)
end end
...@@ -50,8 +46,42 @@ describe WikiPages::UpdateService do ...@@ -50,8 +46,42 @@ describe WikiPages::UpdateService do
expect { service.execute page }.to change { counter.read(:update) }.by 1 expect { service.execute page }.to change { counter.read(:update) }.by 1
end end
shared_examples 'adds activity event' do
it 'adds a new wiki page activity event' do
expect { service.execute(page) }.to change { Event.count }.by 1
expect(Event.recent.first).to have_attributes(
action: Event::UPDATED,
wiki_page: page,
target_title: page.title
)
end
end
context 'the page is at the top level' do
let(:page_title) { 'Top level page' }
include_examples 'adds activity event'
end
context 'the page is in a subsection' do
let(:page_title) { 'Subsection / secondary page' }
include_examples 'adds activity event'
end
context 'the feature is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not record the activity' do
expect { service.execute(page) }.not_to change(Event, :count)
end
end
context 'when the options are bad' do context 'when the options are bad' do
subject(:service) { described_class.new(project, user, bad_opts) } let(:page_title) { '' }
it 'does not count an edit event' do it 'does not count an edit event' do
counter = Gitlab::UsageDataCounters::WikiPageCounter counter = Gitlab::UsageDataCounters::WikiPageCounter
...@@ -59,6 +89,10 @@ describe WikiPages::UpdateService do ...@@ -59,6 +89,10 @@ describe WikiPages::UpdateService do
expect { service.execute page }.not_to change { counter.read(:update) } expect { service.execute page }.not_to change { counter.read(:update) }
end end
it 'does not record the activity' do
expect { service.execute page }.not_to change(Event, :count)
end
it 'reports the error' do it 'reports the error' do
expect(service.execute page).to be_invalid expect(service.execute page).to be_invalid
.and have_attributes(errors: be_present) .and have_attributes(errors: be_present)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment