This instance will be upgraded to Heptapod 0.31.0 (final) on 2022-05-24 at 14:00 UTC+2 (a few minutes of down time)

Commit 0fec0de4 authored by Jan Provaznik's avatar Jan Provaznik
Browse files

Use ResourceLabelEvent for tracking label changes

parent 824d94548859
......@@ -24,12 +24,13 @@ export default {
required: true,
},
noteId: {
type: Number,
type: String,
required: true,
},
noteUrl: {
type: String,
required: true,
required: false,
default: '',
},
accessLevel: {
type: String,
......@@ -225,11 +226,11 @@ export default {
Report as abuse
</a>
</li>
<li>
<li v-if="noteUrl">
<button
:data-clipboard-text="noteUrl"
type="button"
css-class="btn-default btn-transparent"
class="btn-default btn-transparent js-btn-copy-note-link"
>
Copy link
</button>
......
......@@ -25,7 +25,7 @@ export default {
required: true,
},
noteId: {
type: Number,
type: String,
required: true,
},
canAwardEmoji: {
......
......@@ -20,9 +20,9 @@ export default {
default: '',
},
noteId: {
type: Number,
type: String,
required: false,
default: 0,
default: '',
},
markdownVersion: {
type: Number,
......@@ -67,7 +67,10 @@ export default {
'getUserDataByProp',
]),
noteHash() {
return `#note_${this.noteId}`;
if (this.noteId) {
return `#note_${this.noteId}`;
}
return '#';
},
markdownPreviewPath() {
return this.getNoteableDataByProp('preview_note_path');
......
......@@ -9,7 +9,8 @@ export default {
props: {
author: {
type: Object,
required: true,
required: false,
default: () => ({}),
},
createdAt: {
type: String,
......@@ -21,7 +22,7 @@ export default {
default: '',
},
noteId: {
type: Number,
type: String,
required: true,
},
includeToggle: {
......@@ -72,7 +73,10 @@ export default {
{{ __('Toggle discussion') }}
</button>
</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
v-if="author.status_tooltip_html"
......@@ -81,6 +85,9 @@ export default {
@{{ author.username }}
</span>
</a>
<span v-else>
{{ __('A deleted user') }}
</span>
<span class="note-headline-light">
<span class="note-headline-meta">
<template v-if="actionText">
......
......@@ -95,6 +95,7 @@ def discussions
.includes(:noteable)
.fresh
notes = ResourceEvents::MergeIntoNotesService.new(issuable, current_user).execute(notes)
notes = prepare_notes_for_rendering(notes)
notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
......
......@@ -18,6 +18,7 @@ def index
notes = notes_finder.execute
.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 = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
......
......@@ -108,7 +108,7 @@ def note_url(note, project = @project)
end
def noteable_note_url(note)
Gitlab::UrlBuilder.build(note)
Gitlab::UrlBuilder.build(note) if note.id
end
def form_resources
......
......@@ -109,10 +109,6 @@ def allows_multiple_assignees?
false
end
def etag_caching_enabled?
false
end
def has_multiple_assignees?
assignees.count > 1
end
......
......@@ -82,4 +82,23 @@ def discussions_can_be_resolved_by?(user)
def lockable?
[MergeRequest, Issue].include?(self.class)
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
# 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 @@ def references
end
def expire_etag_cache
return unless noteable&.discussions_rendered_on_frontend?
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
)
noteable&.expire_note_etag_cache
end
def touch(*args)
......
......@@ -3,33 +3,122 @@
# This model is not used yet, it will be used for:
# https://gitlab.com/gitlab-org/gitlab-ce/issues/48483
class ResourceLabelEvent < ActiveRecord::Base
include Importable
include Gitlab::Utils::StrongMemoize
include CacheMarkdownField
cache_markdown_field :reference
belongs_to :user
belongs_to :issue
belongs_to :merge_request
belongs_to :label
validates :user, presence: true, on: :create
validates :label, presence: true, on: :create
scope :created_after, ->(time) { where('created_at > ?', time) }
validates :user, presence: { unless: :importing? }, on: :create
validates :label, presence: { unless: :importing? }, on: :create
validate :exactly_one_issuable
after_save :expire_etag_cache
after_destroy :expire_etag_cache
enum action: {
add: 1,
remove: 2
}
def self.issuable_columns
%i(issue_id merge_request_id).freeze
def self.issuable_attrs
%i(issue merge_request).freeze
end
def issuable
issue || merge_request
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
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
if self.class.issuable_columns.count { |attr| self[attr] } != 1
errors.add(:base, "Exactly one of #{self.class.issuable_columns.join(', ')} is required")
issuable_count = self.class.issuable_attrs.count { |attr| self["#{attr}_id"] }
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
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
......@@ -4,6 +4,12 @@ class NoteEntity < API::Entities::Note
include RequestAwareEntity
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 :author, using: NoteUserEntity
......@@ -46,8 +52,8 @@ class NoteEntity < API::Entities::Note
expose :emoji_awardable?, as: :emoji_awardable
expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity
expose :report_abuse_path do |note|
new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(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))
end
expose :noteable_note_url do |note|
......
# frozen_string_literal: true
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)
end
......@@ -9,7 +9,7 @@ class ProjectNoteEntity < NoteEntity
toggle_award_emoji_project_note_path(note.project, note.id)
end
expose :path do |note|
expose :path, if: -> (note, _) { note.id } do |note|
project_note_path(note.project, note)
end
......
......@@ -55,7 +55,9 @@ def create_labels_note(old_labels)
added_labels = issuable.labels - old_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
def create_title_change_note(old_title)
......
......@@ -36,6 +36,7 @@ def execute(issue, new_project)
def update_new_issue
rewrite_notes
copy_resource_label_events
rewrite_issue_award_emoji
add_note_moved_from
end
......@@ -96,6 +97,18 @@ def rewrite_notes
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
rewrite_award_emoji(@old_issue, @new_issue)
end
......
......@@ -13,6 +13,7 @@ def execute(label)
label_ids_for_merge(new_label).find_in_batches(batch_size: BATCH_SIZE) do |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_priorities(new_label, batched_ids)
subscribe_users(new_label, batched_ids)
......@@ -52,6 +53,12 @@ def update_issuables(new_label, label_ids)
.update_all(label_id: new_label)
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)
List
.where(label: label_ids)
......
# 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
class ChangeLabelsService
attr_reader :resource, :user
......@@ -25,6 +23,7 @@ def execute(added_labels: [], removed_labels: [])
end
Gitlab::Database.bulk_insert(ResourceLabelEvent.table_name, labels)
resource.expire_note_etag_cache
end
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
@params = params
end
def execute(notes = [])
(notes + label_notes).sort_by { |n| n.created_at }
end
private
def label_notes
label_events_by_discussion_id.map do |discussion_id, events|
LabelNote.from_events(events, resource: resource, resource_parent: resource_parent)
end
end
def label_events_by_discussion_id
return [] unless resource.respond_to?(:resource_label_events)
events = resource.resource_label_events.includes(:label, :user)
events = since_fetch_at(events)
events.group_by { |event| event.discussion_id }
end
def since_fetch_at(events)
return events unless params[:last_fetched_at].present?
last_fetched_at = Time.at(params.fetch(:last_fetched_at).to_i)
events.created_after(last_fetched_at - NotesFinder::FETCH_OVERLAP)
end
def resource_parent
strong_memoize(:resource_parent) do
resource.project || resource.group
end
end
end
end
......@@ -98,47 +98,6 @@ def change_issue_assignees(issue, project, author, old_assignees)
create_note(NoteSummary.new(issue, project, author, body, action: 'assignee'))
end
# Called when one or more labels on a Noteable are added and/or removed
#
# noteable - Noteable object
# project - Project owning noteable
# author - User performing the change
# added_labels - Array of Labels added
# removed_labels - Array of Labels removed
#
# Example Note text:
#
# "added ~1 and removed ~2 ~3 labels"
#
# "added ~4 label"