diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2019-12-10 18:07:52 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2019-12-10 18:07:52 +0300 |
commit | 27d91a629918e417a9e87825e838209b9ace79c1 (patch) | |
tree | e066c3fc84e3011641e662252810cb2c240edb90 /app | |
parent | 5e11c9b77cb1b2b77ee29359047b55807afe255d (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
23 files changed, 238 insertions, 38 deletions
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index e29509ce074..429122c8083 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -100,9 +100,6 @@ export default { hasOnlyOneJob(stage) { return stage.groups.length === 1; }, - hasDownstream(index, length) { - return index === length - 1 && this.hasTriggered; - }, hasUpstream(index) { return index === 0 && this.hasTriggeredBy; }, @@ -160,7 +157,6 @@ export default { :key="stage.name" :class="{ 'has-upstream prepend-left-64': hasUpstream(index), - 'has-downstream': hasDownstream(index, graph.length), 'has-only-one-job': hasOnlyOneJob(stage), 'append-right-46': shouldAddRightMargin(index), }" diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue index 6efdde2b17e..998519f9df1 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -1,5 +1,6 @@ <script> import LinkedPipeline from './linked_pipeline.vue'; +import { __ } from '~/locale'; export default { components: { @@ -27,6 +28,9 @@ export default { }; return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; }, + isUpstream() { + return this.columnTitle === __('Upstream'); + }, }, }; </script> @@ -34,13 +38,12 @@ export default { <template> <div :class="columnClass" class="stage-column linked-pipelines-column"> <div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div> - <div class="cross-project-triangle"></div> + <div v-if="isUpstream" class="cross-project-triangle"></div> <ul> <linked-pipeline v-for="(pipeline, index) in linkedPipelines" :key="pipeline.id" :class="{ - 'flat-connector-before': index === 0 && graphPosition === 'right', active: pipeline.isExpanded, 'left-connector': pipeline.isExpanded && graphPosition === 'left', }" diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index a1afcf5077e..f394e4ab58a 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -473,6 +473,7 @@ table.code { text-align: right; width: 50px; position: relative; + white-space: nowrap; a { transition: none; diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb index 63fff821871..06ba916fc55 100644 --- a/app/controllers/admin/broadcast_messages_controller.rb +++ b/app/controllers/admin/broadcast_messages_controller.rb @@ -61,6 +61,7 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController message starts_at target_path + broadcast_type )) end end diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb index 9c2ae92071d..b3d72ebdcf3 100644 --- a/app/models/broadcast_message.rb +++ b/app/models/broadcast_message.rb @@ -9,6 +9,7 @@ class BroadcastMessage < ApplicationRecord validates :message, presence: true validates :starts_at, presence: true validates :ends_at, presence: true + validates :broadcast_type, presence: true validates :color, allow_blank: true, color: true validates :font, allow_blank: true, color: true @@ -17,35 +18,62 @@ class BroadcastMessage < ApplicationRecord default_value_for :font, '#FFFFFF' CACHE_KEY = 'broadcast_message_current_json' + BANNER_CACHE_KEY = 'broadcast_message_current_banner_json' + NOTIFICATION_CACHE_KEY = 'broadcast_message_current_notification_json' after_commit :flush_redis_cache - def self.current(current_path = nil) - messages = cache.fetch(CACHE_KEY, as: BroadcastMessage, expires_in: cache_expires_in) do - current_and_future_messages + enum broadcast_type: { + banner: 1, + notification: 2 + } + + class << self + def current_banner_messages(current_path = nil) + fetch_messages BANNER_CACHE_KEY, current_path do + current_and_future_messages.banner + end end - return [] unless messages&.present? + def current_notification_messages(current_path = nil) + fetch_messages NOTIFICATION_CACHE_KEY, current_path do + current_and_future_messages.notification + end + end - now_or_future = messages.select(&:now_or_future?) + def current(current_path = nil) + fetch_messages CACHE_KEY, current_path do + current_and_future_messages + end + end - # If there are cached entries but none are to be displayed we'll purge the - # cache so we don't keep running this code all the time. - cache.expire(CACHE_KEY) if now_or_future.empty? + def current_and_future_messages + where('ends_at > :now', now: Time.current).order_id_asc + end - now_or_future.select(&:now?).select { |message| message.matches_current_path(current_path) } - end + def cache + Gitlab::JsonCache.new(cache_key_with_version: false) + end - def self.current_and_future_messages - where('ends_at > :now', now: Time.zone.now).order_id_asc - end + def cache_expires_in + 2.weeks + end - def self.cache - Gitlab::JsonCache.new(cache_key_with_version: false) - end + private + + def fetch_messages(cache_key, current_path) + messages = cache.fetch(cache_key, as: BroadcastMessage, expires_in: cache_expires_in) do + yield + end + + now_or_future = messages.select(&:now_or_future?) - def self.cache_expires_in - 2.weeks + # If there are cached entries but none are to be displayed we'll purge the + # cache so we don't keep running this code all the time. + cache.expire(cache_key) if now_or_future.empty? + + now_or_future.select(&:now?).select { |message| message.matches_current_path(current_path) } + end end def active? @@ -53,19 +81,19 @@ class BroadcastMessage < ApplicationRecord end def started? - Time.zone.now >= starts_at + Time.current >= starts_at end def ended? - ends_at < Time.zone.now + ends_at < Time.current end def now? - (starts_at..ends_at).cover?(Time.zone.now) + (starts_at..ends_at).cover?(Time.current) end def future? - starts_at > Time.zone.now + starts_at > Time.current end def now_or_future? @@ -79,7 +107,9 @@ class BroadcastMessage < ApplicationRecord end def flush_redis_cache - self.class.cache.expire(CACHE_KEY) + [CACHE_KEY, BANNER_CACHE_KEY, NOTIFICATION_CACHE_KEY].each do |key| + self.class.cache.expire(key) + end end end diff --git a/app/models/commit.rb b/app/models/commit.rb index c683a9aeb76..01c7991ba2f 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -281,6 +281,10 @@ class Commit project.notes.for_commit_id(self.id) end + def user_mentions + CommitUserMention.where(commit_id: self.id) + end + def discussion_notes notes.non_diff_notes end diff --git a/app/models/commit_user_mention.rb b/app/models/commit_user_mention.rb new file mode 100644 index 00000000000..680d20b61cf --- /dev/null +++ b/app/models/commit_user_mention.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class CommitUserMention < UserMention + belongs_to :note +end diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 9b6c57261d8..b43b91699ab 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -80,6 +80,66 @@ module Mentionable all_references(current_user).users end + def store_mentions! + # if store_mentioned_users_to_db feature flag is not enabled then consider storing operation as succeeded + # because we wrap this method in transaction with with_transaction_returning_status, and we need the status to be + # successful if mentionable.save is successful. + # + # This line will get removed when we remove the feature flag. + return true unless store_mentioned_users_to_db_enabled? + + refs = all_references(self.author) + + references = {} + references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence + references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence + references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence + + # One retry should be enough as next time `model_user_mention` should return the existing mention record, that + # threw the `ActiveRecord::RecordNotUnique` exception in first place. + self.class.safe_ensure_unique(retries: 1) do + user_mention = model_user_mention + user_mention.mentioned_users_ids = references[:mentioned_users_ids] + user_mention.mentioned_groups_ids = references[:mentioned_groups_ids] + user_mention.mentioned_projects_ids = references[:mentioned_projects_ids] + + if user_mention.has_mentions? + user_mention.save! + elsif user_mention.persisted? + user_mention.destroy! + end + + true + end + end + + def referenced_users + User.where(id: user_mentions.select("unnest(mentioned_users_ids)")) + end + + def referenced_projects(current_user = nil) + Project.where(id: user_mentions.select("unnest(mentioned_projects_ids)")).public_or_visible_to_user(current_user) + end + + def referenced_project_users(current_user = nil) + User.joins(:project_members).where(members: { source_id: referenced_projects(current_user) }).distinct + end + + def referenced_groups(current_user = nil) + # TODO: IMPORTANT: Revisit before using it. + # Check DB data for max mentioned groups per mentionable: + # + # select issue_id, count(mentions_count.men_gr_id) gr_count from + # (select DISTINCT unnest(mentioned_groups_ids) as men_gr_id, issue_id + # from issue_user_mentions group by issue_id, mentioned_groups_ids) as mentions_count + # group by mentions_count.issue_id order by gr_count desc limit 10 + Group.where(id: user_mentions.select("unnest(mentioned_groups_ids)")).public_or_visible_to_user(current_user) + end + + def referenced_group_users(current_user = nil) + User.joins(:group_members).where(members: { source_id: referenced_groups }).distinct + end + def directly_addressed_users(current_user = nil) all_references(current_user).directly_addressed_users end @@ -171,6 +231,26 @@ module Mentionable def mentionable_params {} end + + # User mention that is parsed from model description rather then its related notes. + # Models that have a descriprion attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention. + # Other mentionable models like Commit, DesignManagement::Design, will never have such record as those do not have + # a description attribute. + # + # Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception + # in a multithreaded environment. Make sure to use it within a *safe_ensure_unique* block. + def model_user_mention + user_mentions.where(note_id: nil).first_or_initialize + end + + # We need this method to be checking that store_mentioned_users_to_db feature flag is enabled at the group level + # and not the project level as epics are defined at group level and we want to have epics store user mentions as well + # for the test period. + # During the test period the flag should be enabled at the group level. + def store_mentioned_users_to_db_enabled? + return Feature.enabled?(:store_mentioned_users_to_db, self.project&.group) if self.respond_to?(:project) + return Feature.enabled?(:store_mentioned_users_to_db, self.group) if self.respond_to?(:group) + end end Mentionable.prepend_if_ee('EE::Mentionable') diff --git a/app/models/issue.rb b/app/models/issue.rb index 2f8d6cb0c06..d49f3c28000 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -42,6 +42,7 @@ class Issue < ApplicationRecord has_many :issue_assignees has_many :assignees, class_name: "User", through: :issue_assignees has_many :zoom_meetings + has_many :user_mentions, class_name: "IssueUserMention" has_one :sentry_issue validates :project, presence: true diff --git a/app/models/issue_user_mention.rb b/app/models/issue_user_mention.rb new file mode 100644 index 00000000000..3eadd580f7f --- /dev/null +++ b/app/models/issue_user_mention.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class IssueUserMention < UserMention + belongs_to :issue + belongs_to :note +end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index f4dfd9128fa..240c143abba 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -71,6 +71,7 @@ class MergeRequest < ApplicationRecord has_many :merge_request_assignees has_many :assignees, class_name: "User", through: :merge_request_assignees + has_many :user_mentions, class_name: "MergeRequestUserMention" has_many :deployment_merge_requests diff --git a/app/models/merge_request_user_mention.rb b/app/models/merge_request_user_mention.rb new file mode 100644 index 00000000000..222d9c1aa8c --- /dev/null +++ b/app/models/merge_request_user_mention.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class MergeRequestUserMention < UserMention + belongs_to :merge_request + belongs_to :note +end diff --git a/app/models/note.rb b/app/models/note.rb index f5f6ecf6336..cfa7ba98081 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -499,8 +499,18 @@ class Note < ApplicationRecord project end + def user_mentions + noteable.user_mentions.where(note: self) + end + private + # Using this method followed by a call to `save` may result in ActiveRecord::RecordNotUnique exception + # in a multithreaded environment. Make sure to use it within a `safe_ensure_unique` block. + def model_user_mention + user_mentions.first_or_initialize + end + def system_note_viewable_by?(user) return true unless system_note_metadata diff --git a/app/models/snippet.rb b/app/models/snippet.rb index b802ea2fd59..92746d28f05 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -37,6 +37,7 @@ class Snippet < ApplicationRecord belongs_to :project has_many :notes, as: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :user_mentions, class_name: "SnippetUserMention" delegate :name, :email, to: :author, prefix: true, allow_nil: true @@ -69,6 +70,8 @@ class Snippet < ApplicationRecord scope :inc_author, -> { includes(:author) } scope :inc_relations_for_view, -> { includes(author: :status) } + attr_mentionable :description + participant :author participant :notes_with_associations diff --git a/app/models/snippet_user_mention.rb b/app/models/snippet_user_mention.rb new file mode 100644 index 00000000000..87ce77a5787 --- /dev/null +++ b/app/models/snippet_user_mention.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class SnippetUserMention < UserMention + belongs_to :snippet + belongs_to :note +end diff --git a/app/models/user_mention.rb b/app/models/user_mention.rb new file mode 100644 index 00000000000..a85c6168cea --- /dev/null +++ b/app/models/user_mention.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class UserMention < ApplicationRecord + self.abstract_class = true + + def has_mentions? + mentioned_users_ids.present? || mentioned_groups_ids.present? || mentioned_projects_ids.present? + end + + private + + def mentioned_users + User.where(id: mentioned_users_ids) + end + + def mentioned_groups + Group.where(id: mentioned_groups_ids) + end + + def mentioned_projects + Project.where(id: mentioned_projects_ids) + end +end diff --git a/app/services/create_snippet_service.rb b/app/services/create_snippet_service.rb index 0aa76df35ba..eacea7d94c7 100644 --- a/app/services/create_snippet_service.rb +++ b/app/services/create_snippet_service.rb @@ -21,7 +21,11 @@ class CreateSnippetService < BaseService spam_check(snippet, current_user) - if snippet.save + snippet_saved = snippet.with_transaction_returning_status do + snippet.save && snippet.store_mentions! + end + + if snippet_saved UserAgentDetailService.new(snippet, @request).create Gitlab::UsageDataCounters::SnippetCounter.count(:create) end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index bb65a8f402d..6cb84458d9b 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -163,7 +163,11 @@ class IssuableBaseService < BaseService before_create(issuable) - if issuable.save + issuable_saved = issuable.with_transaction_returning_status do + issuable.save && issuable.store_mentions! + end + + if issuable_saved Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, is_update: false) after_create(issuable) @@ -224,7 +228,11 @@ class IssuableBaseService < BaseService update_project_counters = issuable.project && update_project_counter_caches?(issuable) ensure_milestone_available(issuable) - if issuable.with_transaction_returning_status { issuable.save(touch: should_touch) } + issuable_saved = issuable.with_transaction_returning_status do + issuable.save(touch: should_touch) && issuable.store_mentions! + end + + if issuable_saved Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: old_associations[:labels]) handle_changes(issuable, old_associations: old_associations) diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 9e6cbfa06fe..3468341a0f2 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -33,7 +33,11 @@ module Notes NewNoteWorker.perform_async(note.id) end - if !only_commands && note.save + note_saved = note.with_transaction_returning_status do + !only_commands && note.save && note.store_mentions! + end + + if note_saved if note.part_of_discussion? && note.discussion.can_convert_to_discussion? note.discussion.convert_to_discussion!(save: true) end diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb index 573be8fbe8b..15c556498ec 100644 --- a/app/services/notes/update_service.rb +++ b/app/services/notes/update_service.rb @@ -7,7 +7,11 @@ module Notes old_mentioned_users = note.mentioned_users(current_user).to_a - note.update(params.merge(updated_by: current_user)) + note.assign_attributes(params.merge(updated_by: current_user)) + + note.with_transaction_returning_status do + note.save && note.store_mentions! + end only_commands = false diff --git a/app/services/update_snippet_service.rb b/app/services/update_snippet_service.rb index a294812ef9e..ac7f8e9b1f5 100644 --- a/app/services/update_snippet_service.rb +++ b/app/services/update_snippet_service.rb @@ -25,8 +25,12 @@ class UpdateSnippetService < BaseService snippet.assign_attributes(params) spam_check(snippet, current_user) - snippet.save.tap do |succeeded| - Gitlab::UsageDataCounters::SnippetCounter.count(:update) if succeeded + snippet_saved = snippet.with_transaction_returning_status do + snippet.save && snippet.store_mentions! + end + + if snippet_saved + Gitlab::UsageDataCounters::SnippetCounter.count(:update) end end end diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index 84198489e41..255a62d0d06 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -27,7 +27,7 @@ = search_filter_link 'snippet_blobs', _("Snippet Contents"), search: { snippets: true, group_id: nil, project_id: nil } = search_filter_link 'snippet_titles', _("Titles and Filenames"), search: { snippets: true, group_id: nil, project_id: nil } - else - = search_filter_link 'projects', _("Projects") + = search_filter_link 'projects', _("Projects"), data: { qa_selector: 'projects_tab' } = search_filter_link 'issues', _("Issues") = search_filter_link 'merge_requests', _("Merge requests") = search_filter_link 'milestones', _("Milestones") diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 67dad9b7a75..5b9af0267cc 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -26,7 +26,7 @@ = image_tag avatar_icon_for_user(project.creator, 48), class: "avatar s48", alt:'' - else = project_icon(project, alt: '', class: 'avatar project-avatar s48', width: 48, height: 48) - .project-details.d-sm-flex.flex-sm-fill.align-items-center + .project-details.d-sm-flex.flex-sm-fill.align-items-center{ data: { qa_selector: 'project', qa_project_name: project.name } } .flex-wrapper .d-flex.align-items-center.flex-wrap.project-title %h2.d-flex.prepend-top-8 |