Commit d95c1f03 authored by Jan Provaznik's avatar Jan Provaznik

Use ResourceLabelEvent for tracking label changes

parent 81f4dc05
...@@ -24,12 +24,13 @@ export default { ...@@ -24,12 +24,13 @@ export default {
required: true, required: true,
}, },
noteId: { noteId: {
type: Number, type: String,
required: true, required: true,
}, },
noteUrl: { noteUrl: {
type: String, type: String,
required: true, required: false,
default: '',
}, },
accessLevel: { accessLevel: {
type: String, type: String,
...@@ -225,11 +226,11 @@ export default { ...@@ -225,11 +226,11 @@ export default {
Report as abuse Report as abuse
</a> </a>
</li> </li>
<li> <li v-if="noteUrl">
<button <button
:data-clipboard-text="noteUrl" :data-clipboard-text="noteUrl"
type="button" type="button"
css-class="btn-default btn-transparent" class="btn-default btn-transparent js-btn-copy-note-link"
> >
Copy link Copy link
</button> </button>
......
...@@ -25,7 +25,7 @@ export default { ...@@ -25,7 +25,7 @@ export default {
required: true, required: true,
}, },
noteId: { noteId: {
type: Number, type: String,
required: true, required: true,
}, },
canAwardEmoji: { canAwardEmoji: {
......
...@@ -20,9 +20,9 @@ export default { ...@@ -20,9 +20,9 @@ export default {
default: '', default: '',
}, },
noteId: { noteId: {
type: Number, type: String,
required: false, required: false,
default: 0, default: '',
}, },
markdownVersion: { markdownVersion: {
type: Number, type: Number,
...@@ -67,7 +67,10 @@ export default { ...@@ -67,7 +67,10 @@ export default {
'getUserDataByProp', 'getUserDataByProp',
]), ]),
noteHash() { noteHash() {
return `#note_${this.noteId}`; if (this.noteId) {
return `#note_${this.noteId}`;
}
return '#';
}, },
markdownPreviewPath() { markdownPreviewPath() {
return this.getNoteableDataByProp('preview_note_path'); return this.getNoteableDataByProp('preview_note_path');
......
...@@ -9,7 +9,8 @@ export default { ...@@ -9,7 +9,8 @@ export default {
props: { props: {
author: { author: {
type: Object, type: Object,
required: true, required: false,
default: () => ({}),
}, },
createdAt: { createdAt: {
type: String, type: String,
...@@ -21,7 +22,7 @@ export default { ...@@ -21,7 +22,7 @@ export default {
default: '', default: '',
}, },
noteId: { noteId: {
type: Number, type: String,
required: true, required: true,
}, },
includeToggle: { includeToggle: {
...@@ -72,7 +73,10 @@ export default { ...@@ -72,7 +73,10 @@ export default {
{{ __('Toggle discussion') }} {{ __('Toggle discussion') }}
</button> </button>
</div> </div>
<a :href="author.path"> <a
v-if="Object.keys(author).length"
:href="author.path"
>
<span class="note-header-author-name">{{ author.name }}</span> <span class="note-header-author-name">{{ author.name }}</span>
<span <span
v-if="author.status_tooltip_html" v-if="author.status_tooltip_html"
...@@ -81,6 +85,9 @@ export default { ...@@ -81,6 +85,9 @@ export default {
@{{ author.username }} @{{ author.username }}
</span> </span>
</a> </a>
<span v-else>
{{ __('A deleted user') }}
</span>
<span class="note-headline-light"> <span class="note-headline-light">
<span class="note-headline-meta"> <span class="note-headline-meta">
<template v-if="actionText"> <template v-if="actionText">
......
...@@ -95,6 +95,7 @@ module IssuableActions ...@@ -95,6 +95,7 @@ module IssuableActions
.includes(:noteable) .includes(:noteable)
.fresh .fresh
notes = ResourceEvents::MergeIntoNotesService.new(issuable, current_user).execute(notes)
notes = prepare_notes_for_rendering(notes) notes = prepare_notes_for_rendering(notes)
notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
......
...@@ -18,6 +18,7 @@ module NotesActions ...@@ -18,6 +18,7 @@ module NotesActions
notes = notes_finder.execute notes = notes_finder.execute
.inc_relations_for_view .inc_relations_for_view
notes = ResourceEvents::MergeIntoNotesService.new(noteable, current_user, last_fetched_at: current_fetched_at).execute(notes)
notes = prepare_notes_for_rendering(notes) notes = prepare_notes_for_rendering(notes)
notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
......
...@@ -108,7 +108,7 @@ module NotesHelper ...@@ -108,7 +108,7 @@ module NotesHelper
end end
def noteable_note_url(note) def noteable_note_url(note)
Gitlab::UrlBuilder.build(note) Gitlab::UrlBuilder.build(note) if note.id
end end
def form_resources def form_resources
......
...@@ -109,10 +109,6 @@ module Issuable ...@@ -109,10 +109,6 @@ module Issuable
false false
end end
def etag_caching_enabled?
false
end
def has_multiple_assignees? def has_multiple_assignees?
assignees.count > 1 assignees.count > 1
end end
......
...@@ -82,4 +82,23 @@ module Noteable ...@@ -82,4 +82,23 @@ module Noteable
def lockable? def lockable?
[MergeRequest, Issue].include?(self.class) [MergeRequest, Issue].include?(self.class)
end end
def etag_caching_enabled?
false
end
def expire_note_etag_cache
return unless discussions_rendered_on_frontend?
return unless etag_caching_enabled?
Gitlab::EtagCaching::Store.new.touch(note_etag_key)
end
def note_etag_key
Gitlab::Routing.url_helpers.project_noteable_notes_path(
project,
target_type: self.class.name.underscore,
target_id: id
)
end
end end
# frozen_string_literal: true
class LabelNote < Note
attr_accessor :resource_parent
attr_reader :events
def self.from_events(events, resource: nil, resource_parent: nil)
resource ||= events.first.issuable
attrs = {
system: true,
author: events.first.user,
created_at: events.first.created_at,
discussion_id: events.first.discussion_id,
noteable: resource,
system_note_metadata: SystemNoteMetadata.new(action: 'label'),
events: events,
resource_parent: resource_parent
}
if resource_parent.is_a?(Project)
attrs[:project_id] = resource_parent.id
end
LabelNote.new(attrs)
end
def events=(events)
@events = events
update_outdated_markdown
end
def cached_html_up_to_date?(markdown_field)
true
end
def note
@note ||= note_text
end
def note_html
@note_html ||= "<p dir=\"auto\">#{note_text(html: true)}</p>"
end
def project
resource_parent if resource_parent.is_a?(Project)
end
def group
resource_parent if resource_parent.is_a?(Group)
end
private
def update_outdated_markdown
events.each do |event|
if event.outdated_markdown?
event.refresh_invalid_reference
end
end
end
def note_text(html: false)
added = labels_str('added', label_refs_by_action('add', html))
removed = labels_str('removed', label_refs_by_action('remove', html))
[added, removed].compact.join(' and ')
end
# returns string containing added/removed labels including
# count of deleted labels:
#
# added ~1 ~2 + 1 deleted label
# added 3 deleted labels
# added ~1 ~2 labels
def labels_str(prefix, label_refs)
existing_refs = label_refs.select { |ref| ref.present? }.sort
refs_str = existing_refs.empty? ? nil : existing_refs.join(' ')
deleted = label_refs.count - existing_refs.count
deleted_str = deleted == 0 ? nil : "#{deleted} deleted"
return nil unless refs_str || deleted_str
label_list_str = [refs_str, deleted_str].compact.join(' + ')
suffix = 'label'.pluralize(deleted > 0 ? deleted : existing_refs.count)
"#{prefix} #{label_list_str} #{suffix}"
end
def label_refs_by_action(action, html)
field = html ? :reference_html : :reference
events.select { |e| e.action == action }.map(&field)
end
end
...@@ -389,18 +389,7 @@ class Note < ActiveRecord::Base ...@@ -389,18 +389,7 @@ class Note < ActiveRecord::Base
end end
def expire_etag_cache def expire_etag_cache
return unless noteable&.discussions_rendered_on_frontend? noteable&.expire_note_etag_cache
return unless noteable&.etag_caching_enabled?
Gitlab::EtagCaching::Store.new.touch(etag_key)
end
def etag_key
Gitlab::Routing.url_helpers.project_noteable_notes_path(
project,
target_type: noteable_type.underscore,
target_id: noteable_id
)
end end
def touch(*args) def touch(*args)
......
...@@ -3,33 +3,122 @@ ...@@ -3,33 +3,122 @@
# This model is not used yet, it will be used for: # This model is not used yet, it will be used for:
# https://gitlab.com/gitlab-org/gitlab-ce/issues/48483 # https://gitlab.com/gitlab-org/gitlab-ce/issues/48483
class ResourceLabelEvent < ActiveRecord::Base class ResourceLabelEvent < ActiveRecord::Base
include Importable
include Gitlab::Utils::StrongMemoize
include CacheMarkdownField
cache_markdown_field :reference
belongs_to :user belongs_to :user
belongs_to :issue belongs_to :issue
belongs_to :merge_request belongs_to :merge_request
belongs_to :label belongs_to :label
validates :user, presence: true, on: :create scope :created_after, ->(time) { where('created_at > ?', time) }
validates :label, presence: true, on: :create
validates :user, presence: { unless: :importing? }, on: :create
validates :label, presence: { unless: :importing? }, on: :create
validate :exactly_one_issuable validate :exactly_one_issuable
after_save :expire_etag_cache
after_destroy :expire_etag_cache
enum action: { enum action: {
add: 1, add: 1,
remove: 2 remove: 2
} }
def self.issuable_columns def self.issuable_attrs
%i(issue_id merge_request_id).freeze %i(issue merge_request).freeze
end end
def issuable def issuable
issue || merge_request issue || merge_request
end end
# create same discussion id for all actions with the same user and time
def discussion_id(resource = nil)
strong_memoize(:discussion_id) do
Digest::SHA1.hexdigest([self.class.name, created_at, user_id].join("-"))
end
end
def project
issuable.project
end
def group
issuable.group if issuable.respond_to?(:group)
end
def outdated_markdown?
return true if label_id.nil? && reference.present?
reference.nil? || latest_cached_markdown_version != cached_markdown_version
end
def banzai_render_context(field)
super.merge(pipeline: 'label', only_path: true)
end
def refresh_invalid_reference
# label_id could be nullified on label delete
self.reference = '' if label_id.nil?
# reference is not set for events which were not rendered yet
self.reference ||= label_reference
if changed?
save
elsif invalidated_markdown_cache?
refresh_markdown_cache!
end
end
private private
def label_reference
if local_label?
label.to_reference(format: :id)
elsif label.is_a?(GroupLabel)
label.to_reference(label.group, target_project: resource_parent, format: :id)
else
label.to_reference(resource_parent, format: :id)
end
end
def exactly_one_issuable def exactly_one_issuable
if self.class.issuable_columns.count { |attr| self[attr] } != 1 issuable_count = self.class.issuable_attrs.count { |attr| self["#{attr}_id"] }
errors.add(:base, "Exactly one of #{self.class.issuable_columns.join(', ')} is required")
return true if issuable_count == 1
# if none of issuable IDs is set, check explicitly if nested issuable
# object is set, this is used during project import
if issuable_count == 0 && importing?
issuable_count = self.class.issuable_attrs.count { |attr| self.public_send(attr) } # rubocop:disable GitlabSecurity/PublicSend
return true if issuable_count == 1
end end
errors.add(:base, "Exactly one of #{self.class.issuable_attrs.join(', ')} is required")
end
def expire_etag_cache
issuable.expire_note_etag_cache
end
def local_label?
params = { include_ancestor_groups: true }
if resource_parent.is_a?(Project)
params[:project_id] = resource_parent.id
else
params[:group_id] = resource_parent.id
end
LabelsFinder.new(nil, params).execute(skip_authorization: true).where(id: label.id).any?
end
def resource_parent
issuable.project || issuable.group
end end
end end
...@@ -4,6 +4,12 @@ class NoteEntity < API::Entities::Note ...@@ -4,6 +4,12 @@ class NoteEntity < API::Entities::Note
include RequestAwareEntity include RequestAwareEntity
include NotesHelper include NotesHelper
expose :id do |note|
# resource events are represented as notes too, but don't
# have ID, discussion ID is used for them instead
note.id ? note.id.to_s : note.discussion_id
end
expose :type expose :type
expose :author, using: NoteUserEntity expose :author, using: NoteUserEntity
...@@ -46,8 +52,8 @@ class NoteEntity < API::Entities::Note ...@@ -46,8 +52,8 @@ class NoteEntity < API::Entities::Note
expose :emoji_awardable?, as: :emoji_awardable expose :emoji_awardable?, as: :emoji_awardable
expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity
expose :report_abuse_path do |note| expose :report_abuse_path, if: -> (note, _) { note.author_id } do |note|
new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(note)) new_abuse_report_path(user_id: note.author_id, ref_url: Gitlab::UrlBuilder.build(note))
end end
expose :noteable_note_url do |note| expose :noteable_note_url do |note|
......
# frozen_string_literal: true # frozen_string_literal: true
class ProjectNoteEntity < NoteEntity class ProjectNoteEntity < NoteEntity
expose :human_access do |note| expose :human_access, if: -> (note, _) { note.project.present? } do |note|
note.project.team.human_max_access(note.author_id) note.project.team.human_max_access(note.author_id)
end end
...@@ -9,7 +9,7 @@ class ProjectNoteEntity < NoteEntity ...@@ -9,7 +9,7 @@ class ProjectNoteEntity < NoteEntity
toggle_award_emoji_project_note_path(note.project, note.id) toggle_award_emoji_project_note_path(note.project, note.id)
end end
expose :path do |note| expose :path, if: -> (note, _) { note.id } do |note|
project_note_path(note.project, note) project_note_path(note.project, note)
end end
......
...@@ -55,7 +55,9 @@ module Issuable ...@@ -55,7 +55,9 @@ module Issuable
added_labels = issuable.labels - old_labels added_labels = issuable.labels - old_labels
removed_labels = old_labels - issuable.labels removed_labels = old_labels - issuable.labels
SystemNoteService.change_label(issuable, issuable.project, current_user, added_labels, removed_labels) ResourceEvents::ChangeLabelsService
.new(issuable, current_user)
.execute(added_labels: added_labels, removed_labels: removed_labels)
end end
def create_title_change_note(old_title) def create_title_change_note(old_title)
......
...@@ -36,6 +36,7 @@ module Issues ...@@ -36,6 +36,7 @@ module Issues
def update_new_issue def update_new_issue
rewrite_notes rewrite_notes
copy_resource_label_events
rewrite_issue_award_emoji rewrite_issue_award_emoji
add_note_moved_from add_note_moved_from
end end
...@@ -96,6 +97,18 @@ module Issues ...@@ -96,6 +97,18 @@ module Issues
end end
end end
def copy_resource_label_events
@old_issue.resource_label_events.find_in_batches do |batch|
events = batch.map do |event|
event.attributes
.except('id', 'reference', 'reference_html')
.merge('issue_id' => @new_issue.id, 'created_at' => event.created_at)
end
Gitlab::Database.bulk_insert(ResourceLabelEvent.table_name, events)
end
end
def rewrite_issue_award_emoji def rewrite_issue_award_emoji
rewrite_award_emoji(@old_issue, @new_issue) rewrite_award_emoji(@old_issue, @new_issue)
end end
......
...@@ -13,6 +13,7 @@ module Labels ...@@ -13,6 +13,7 @@ module Labels
label_ids_for_merge(new_label).find_in_batches(batch_size: BATCH_SIZE) do |batched_ids| label_ids_for_merge(new_label).find_in_batches(batch_size: BATCH_SIZE) do |batched_ids|
update_issuables(new_label, batched_ids) update_issuables(new_label, batched_ids)
update_resource_label_events(new_label, batched_ids)
update_issue_board_lists(new_label, batched_ids) update_issue_board_lists(new_label, batched_ids)
update_priorities(new_label, batched_ids) update_priorities(new_label, batched_ids)
subscribe_users(new_label, batched_ids) subscribe_users(new_label, batched_ids)
...@@ -52,6 +53,12 @@ module Labels ...@@ -52,6 +53,12 @@ module Labels
.update_all(label_id: new_label) .update_all(label_id: new_label)
end end
def update_resource_label_events(new_label, label_ids)
ResourceLabelEvent
.where(label: label_ids)
.update_all(label_id: new_label)
end
def update_issue_board_lists(new_label, label_ids) def update_issue_board_lists(new_label, label_ids)
List List
.where(label: label_ids) .where(label: label_ids)
......
# frozen_string_literal: true # frozen_string_literal: true
# This service is not used yet, it will be used for:
# https://gitlab.com/gitlab-org/gitlab-ce/issues/48483
module ResourceEvents module ResourceEvents
class ChangeLabelsService class ChangeLabelsService
attr_reader :resource, :user attr_reader :resource, :user
...@@ -25,6 +23,7 @@ module ResourceEvents ...@@ -25,6 +23,7 @@ module ResourceEvents
end end
Gitlab::Database.bulk_insert(ResourceLabelEvent.table_name, labels) Gitlab::Database.bulk_insert(ResourceLabelEvent.table_name, labels)
resource.expire_note_etag_cache
end end
private private
......
# frozen_string_literal: true
# We store events about issuable label changes in a separate table (not as
# other system notes), but we still want to display notes about label changes
# as classic system notes in UI. This service generates "synthetic" notes for
# label event changes and merges them with classic notes and sorts them by
# creation time.
module ResourceEvents
class MergeIntoNotesService
include Gitlab::Utils::StrongMemoize
attr_reader :resource, :current_user, :params
def initialize(resource, current_user, params = {})
@resource = resource
@current_user = current_user