Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue7
-rw-r--r--app/assets/stylesheets/pages/diff.scss1
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb1
-rw-r--r--app/models/broadcast_message.rb76
-rw-r--r--app/models/commit.rb4
-rw-r--r--app/models/commit_user_mention.rb5
-rw-r--r--app/models/concerns/mentionable.rb80
-rw-r--r--app/models/issue.rb1
-rw-r--r--app/models/issue_user_mention.rb6
-rw-r--r--app/models/merge_request.rb1
-rw-r--r--app/models/merge_request_user_mention.rb6
-rw-r--r--app/models/note.rb10
-rw-r--r--app/models/snippet.rb3
-rw-r--r--app/models/snippet_user_mention.rb6
-rw-r--r--app/models/user_mention.rb23
-rw-r--r--app/services/create_snippet_service.rb6
-rw-r--r--app/services/issuable_base_service.rb12
-rw-r--r--app/services/notes/create_service.rb6
-rw-r--r--app/services/notes/update_service.rb6
-rw-r--r--app/services/update_snippet_service.rb8
-rw-r--r--app/views/search/_category.html.haml2
-rw-r--r--app/views/shared/projects/_project.html.haml2
-rw-r--r--changelogs/unreleased/21800-mentioned-users-models-with-array-type.yml5
-rw-r--r--changelogs/unreleased/nicolasdular-add-broadcast-type.yml5
-rw-r--r--changelogs/unreleased/remove-downstream-node-lines.yml5
-rw-r--r--db/fixtures/development/03_project.rb4
-rw-r--r--db/migrate/20191129134844_add_broadcast_type_to_broadcast_message.rb19
-rw-r--r--db/schema.rb1
-rw-r--r--doc/ci/yaml/README.md1
-rw-r--r--lib/banzai/reference_parser/mentioned_group_parser.rb (renamed from lib/banzai/reference_parser/mentioned_users_by_group_parser.rb)2
-rw-r--r--lib/banzai/reference_parser/mentioned_project_parser.rb (renamed from lib/banzai/reference_parser/mentioned_users_by_project_parser.rb)2
-rw-r--r--lib/gitlab/ci/config/entry/boolean.rb20
-rw-r--r--lib/gitlab/ci/config/entry/default.rb11
-rw-r--r--lib/gitlab/ci/config/entry/job.rb7
-rw-r--r--lib/gitlab/config/entry/array_of_strings.rb18
-rw-r--r--lib/gitlab/reference_extractor.rb2
-rw-r--r--qa/qa/page/search/results.rb21
-rw-r--r--qa/qa/resource/api_fabricator.rb16
-rw-r--r--qa/qa/runtime/api/client.rb17
-rw-r--r--spec/frontend/environments/environment_item_spec.js131
-rw-r--r--spec/frontend/environments/environment_table_spec.js (renamed from spec/javascripts/environments/environment_table_spec.js)120
-rw-r--r--spec/frontend/environments/mock_data.js106
-rw-r--r--spec/javascripts/environments/environment_item_spec.js230
-rw-r--r--spec/javascripts/pipelines/graph/linked_pipelines_column_spec.js4
-rw-r--r--spec/lib/banzai/reference_parser/mentioned_group_parser_spec.rb (renamed from spec/lib/banzai/reference_parser/mentioned_users_by_group_parser_spec.rb)2
-rw-r--r--spec/lib/banzai/reference_parser/mentioned_project_parser_spec.rb (renamed from spec/lib/banzai/reference_parser/mentioned_users_by_project_parser_spec.rb)2
-rw-r--r--spec/lib/gitlab/ci/config/entry/default_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/entry/job_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/yaml_processor_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml4
-rw-r--r--spec/models/broadcast_message_spec.rb119
-rw-r--r--spec/models/concerns/mentionable_spec.rb67
-rw-r--r--spec/models/issue_spec.rb1
-rw-r--r--spec/models/merge_request_spec.rb1
-rw-r--r--spec/models/snippet_spec.rb1
-rw-r--r--spec/models/user_mentions/commit_user_mention_spec.rb11
-rw-r--r--spec/models/user_mentions/issue_user_mention_spec.rb12
-rw-r--r--spec/models/user_mentions/merge_request_user_mention_spec.rb12
-rw-r--r--spec/models/user_mentions/snippet_user_mention_spec.rb12
-rw-r--r--spec/support/shared_examples/mentionable_shared_examples.rb150
-rw-r--r--spec/support/shared_examples/models/user_mentions_shared_examples.rb40
62 files changed, 1070 insertions, 395 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
diff --git a/changelogs/unreleased/21800-mentioned-users-models-with-array-type.yml b/changelogs/unreleased/21800-mentioned-users-models-with-array-type.yml
new file mode 100644
index 00000000000..2f9ccdd8fe3
--- /dev/null
+++ b/changelogs/unreleased/21800-mentioned-users-models-with-array-type.yml
@@ -0,0 +1,5 @@
+---
+title: Store users, groups, projects mentioned in Markdown to DB tables
+merge_request: 19088
+author:
+type: added
diff --git a/changelogs/unreleased/nicolasdular-add-broadcast-type.yml b/changelogs/unreleased/nicolasdular-add-broadcast-type.yml
new file mode 100644
index 00000000000..649b25c04cd
--- /dev/null
+++ b/changelogs/unreleased/nicolasdular-add-broadcast-type.yml
@@ -0,0 +1,5 @@
+---
+title: Add type to broadcast messages
+merge_request: 21038
+author:
+type: added
diff --git a/changelogs/unreleased/remove-downstream-node-lines.yml b/changelogs/unreleased/remove-downstream-node-lines.yml
new file mode 100644
index 00000000000..80cdec3e0ca
--- /dev/null
+++ b/changelogs/unreleased/remove-downstream-node-lines.yml
@@ -0,0 +1,5 @@
+---
+title: Remove downstream pipeline connecting lines
+merge_request: 21196
+author:
+type: removed
diff --git a/db/fixtures/development/03_project.rb b/db/fixtures/development/03_project.rb
index 33df3ed7156..596c5e81a2e 100644
--- a/db/fixtures/development/03_project.rb
+++ b/db/fixtures/development/03_project.rb
@@ -141,6 +141,10 @@ class Gitlab::Seeder::Projects
# the `after_commit` queue to ensure the job is run now.
project.send(:_run_after_commit_queue)
project.import_state.send(:_run_after_commit_queue)
+
+ # Expire repository cache after import to ensure
+ # valid_repo? call below returns a correct answer
+ project.repository.expire_all_method_caches
end
if project.valid? && project.valid_repo?
diff --git a/db/migrate/20191129134844_add_broadcast_type_to_broadcast_message.rb b/db/migrate/20191129134844_add_broadcast_type_to_broadcast_message.rb
new file mode 100644
index 00000000000..84d17f558d1
--- /dev/null
+++ b/db/migrate/20191129134844_add_broadcast_type_to_broadcast_message.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class AddBroadcastTypeToBroadcastMessage < ActiveRecord::Migration[5.2]
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+ BROADCAST_MESSAGE_BANNER_TYPE = 1
+
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default(:broadcast_messages, :broadcast_type, :smallint, default: BROADCAST_MESSAGE_BANNER_TYPE)
+ end
+
+ def down
+ remove_column(:broadcast_messages, :broadcast_type)
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index e3df8af0ea0..4f94c0accfa 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -575,6 +575,7 @@ ActiveRecord::Schema.define(version: 2019_12_06_122926) do
t.text "message_html", null: false
t.integer "cached_markdown_version"
t.string "target_path", limit: 255
+ t.integer "broadcast_type", limit: 2, default: 1, null: false
t.index ["starts_at", "ends_at", "id"], name: "index_broadcast_messages_on_starts_at_and_ends_at_and_id"
end
diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md
index ff1a6180f00..b4516be4d13 100644
--- a/doc/ci/yaml/README.md
+++ b/doc/ci/yaml/README.md
@@ -135,6 +135,7 @@ The following job parameters can be defined inside a `default:` block:
- [`services`](#services)
- [`before_script`](#before_script-and-after_script)
- [`after_script`](#before_script-and-after_script)
+- [`tags`](#tags)
- [`cache`](#cache)
- [`retry`](#retry)
- [`timeout`](#timeout)
diff --git a/lib/banzai/reference_parser/mentioned_users_by_group_parser.rb b/lib/banzai/reference_parser/mentioned_group_parser.rb
index d4ff6a12cd0..a0892e15df8 100644
--- a/lib/banzai/reference_parser/mentioned_users_by_group_parser.rb
+++ b/lib/banzai/reference_parser/mentioned_group_parser.rb
@@ -2,7 +2,7 @@
module Banzai
module ReferenceParser
- class MentionedUsersByGroupParser < BaseParser
+ class MentionedGroupParser < BaseParser
GROUP_ATTR = 'data-group'
self.reference_type = :user
diff --git a/lib/banzai/reference_parser/mentioned_users_by_project_parser.rb b/lib/banzai/reference_parser/mentioned_project_parser.rb
index 79258d81cc3..40f1819f2d8 100644
--- a/lib/banzai/reference_parser/mentioned_users_by_project_parser.rb
+++ b/lib/banzai/reference_parser/mentioned_project_parser.rb
@@ -2,7 +2,7 @@
module Banzai
module ReferenceParser
- class MentionedUsersByProjectParser < ProjectParser
+ class MentionedProjectParser < ProjectParser
PROJECT_ATTR = 'data-project'
self.reference_type = :user
diff --git a/lib/gitlab/ci/config/entry/boolean.rb b/lib/gitlab/ci/config/entry/boolean.rb
deleted file mode 100644
index 10619ef9f8d..00000000000
--- a/lib/gitlab/ci/config/entry/boolean.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
- module Ci
- class Config
- module Entry
- ##
- # Entry that represents the interrutible value.
- #
- class Boolean < ::Gitlab::Config::Entry::Node
- include ::Gitlab::Config::Entry::Validatable
-
- validations do
- validates :config, boolean: true
- end
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/ci/config/entry/default.rb b/lib/gitlab/ci/config/entry/default.rb
index b84ae53a514..8714885efc5 100644
--- a/lib/gitlab/ci/config/entry/default.rb
+++ b/lib/gitlab/ci/config/entry/default.rb
@@ -15,7 +15,7 @@ module Gitlab
ALLOWED_KEYS = %i[before_script image services
after_script cache interruptible
- timeout retry].freeze
+ timeout retry tags].freeze
validations do
validates :config, allowed_keys: ALLOWED_KEYS
@@ -41,7 +41,7 @@ module Gitlab
description: 'Configure caching between build jobs.',
inherit: true
- entry :interruptible, Entry::Boolean,
+ entry :interruptible, ::Gitlab::Config::Entry::Boolean,
description: 'Set jobs interruptible default value.',
inherit: false
@@ -53,7 +53,12 @@ module Gitlab
description: 'Set retry default value.',
inherit: false
- helpers :before_script, :image, :services, :after_script, :cache, :interruptible, :timeout, :retry
+ entry :tags, ::Gitlab::Config::Entry::ArrayOfStrings,
+ description: 'Set the default tags.',
+ inherit: false
+
+ helpers :before_script, :image, :services, :after_script, :cache, :interruptible,
+ :timeout, :retry, :tags
private
diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb
index 0c431c0a1de..eea59ecb937 100644
--- a/lib/gitlab/ci/config/entry/job.rb
+++ b/lib/gitlab/ci/config/entry/job.rb
@@ -36,7 +36,6 @@ module Gitlab
if: :has_rules?
with_options allow_nil: true do
- validates :tags, array_of_strings: true
validates :allow_failure, boolean: true
validates :parallel, numericality: { only_integer: true,
greater_than_or_equal_to: 2,
@@ -97,7 +96,7 @@ module Gitlab
description: 'Services that will be used to execute this job.',
inherit: true
- entry :interruptible, Entry::Boolean,
+ entry :interruptible, ::Gitlab::Config::Entry::Boolean,
description: 'Set jobs interruptible value.',
inherit: true
@@ -109,6 +108,10 @@ module Gitlab
description: 'Retry configuration for this job.',
inherit: true
+ entry :tags, ::Gitlab::Config::Entry::ArrayOfStrings,
+ description: 'Set the tags.',
+ inherit: true
+
entry :only, Entry::Policy,
description: 'Refs policy this job will be executed for.',
default: Entry::Policy::DEFAULT_ONLY,
diff --git a/lib/gitlab/config/entry/array_of_strings.rb b/lib/gitlab/config/entry/array_of_strings.rb
new file mode 100644
index 00000000000..403b15e8f32
--- /dev/null
+++ b/lib/gitlab/config/entry/array_of_strings.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Config
+ module Entry
+ ##
+ # Entry that represents a array of strings value.
+ #
+ class ArrayOfStrings < Node
+ include Validatable
+
+ validations do
+ validates :config, array_of_strings: true
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/reference_extractor.rb b/lib/gitlab/reference_extractor.rb
index ea2b03b42c1..f095ac9ffd1 100644
--- a/lib/gitlab/reference_extractor.rb
+++ b/lib/gitlab/reference_extractor.rb
@@ -3,7 +3,7 @@
module Gitlab
# Extract possible GFM references from an arbitrary String for further processing.
class ReferenceExtractor < Banzai::ReferenceExtractor
- REFERABLES = %i(user issue label milestone
+ REFERABLES = %i(user issue label milestone mentioned_user mentioned_group mentioned_project
merge_request snippet commit commit_range directly_addressed_user epic).freeze
attr_accessor :project, :current_user, :author
diff --git a/qa/qa/page/search/results.rb b/qa/qa/page/search/results.rb
index b9b18abf660..2f99d8da784 100644
--- a/qa/qa/page/search/results.rb
+++ b/qa/qa/page/search/results.rb
@@ -5,6 +5,7 @@ module QA::Page
class Results < QA::Page::Base
view 'app/views/search/_category.html.haml' do
element :code_tab
+ element :projects_tab
end
view 'app/views/search/results/_blob_data.html.haml' do
@@ -13,21 +14,33 @@ module QA::Page
element :file_text_content
end
+ view 'app/views/shared/projects/_project.html.haml' do
+ element :project
+ end
+
def switch_to_code
click_element(:code_tab)
end
+ def switch_to_projects
+ click_element(:projects_tab)
+ end
+
def has_file_in_project?(file_name, project_name)
- has_element? :result_item_content, text: "#{project_name}: #{file_name}"
+ has_element?(:result_item_content, text: "#{project_name}: #{file_name}")
end
def has_file_with_content?(file_name, file_text)
- within_element_by_index :result_item_content, 0 do
- false unless has_element? :file_title_content, text: file_name
+ within_element_by_index(:result_item_content, 0) do
+ false unless has_element?(:file_title_content, text: file_name)
- has_element? :file_text_content, text: file_text
+ has_element?(:file_text_content, text: file_text)
end
end
+
+ def has_project?(project_name)
+ has_element?(:project, project_name: project_name)
+ end
end
end
end
diff --git a/qa/qa/resource/api_fabricator.rb b/qa/qa/resource/api_fabricator.rb
index e4f708dc251..3862bd68c40 100644
--- a/qa/qa/resource/api_fabricator.rb
+++ b/qa/qa/resource/api_fabricator.rb
@@ -19,8 +19,8 @@ module QA
def api_support?
respond_to?(:api_get_path) &&
- respond_to?(:api_post_path) &&
- respond_to?(:api_post_body)
+ (respond_to?(:api_post_path) && respond_to?(:api_post_body)) ||
+ (respond_to?(:api_put_path) && respond_to?(:api_put_body))
end
def fabricate_via_api!
@@ -84,6 +84,18 @@ module QA
process_api_response(parse_body(response))
end
+ def api_put
+ response = put(
+ Runtime::API::Request.new(api_client, api_put_path).url,
+ api_put_body)
+
+ unless response.code == HTTP_STATUS_OK
+ raise ResourceFabricationFailedError, "Updating #{self.class.name} using the API failed (#{response.code}) with `#{response}`."
+ end
+
+ process_api_response(parse_body(response))
+ end
+
def api_delete
url = Runtime::API::Request.new(api_client, api_delete_path).url
response = delete(url)
diff --git a/qa/qa/runtime/api/client.rb b/qa/qa/runtime/api/client.rb
index 4f9fb586ee3..b9a3c9184aa 100644
--- a/qa/qa/runtime/api/client.rb
+++ b/qa/qa/runtime/api/client.rb
@@ -25,6 +25,23 @@ module QA
end
end
+ def self.as_admin
+ if Runtime::Env.admin_personal_access_token
+ Runtime::API::Client.new(:gitlab, personal_access_token: Runtime::Env.admin_personal_access_token)
+ else
+ user = Resource::User.fabricate_via_api! do |user|
+ user.username = Runtime::User.admin_username
+ user.password = Runtime::User.admin_password
+ end
+
+ unless user.admin?
+ raise AuthorizationError, "User '#{user.username}' is not an administrator."
+ end
+
+ Runtime::API::Client.new(:gitlab, user: user)
+ end
+ end
+
private
def enable_ip_limits
diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js
new file mode 100644
index 00000000000..6184df0fdc2
--- /dev/null
+++ b/spec/frontend/environments/environment_item_spec.js
@@ -0,0 +1,131 @@
+import { mount } from '@vue/test-utils';
+import { format } from 'timeago.js';
+import EnvironmentItem from '~/environments/components/environment_item.vue';
+import { environment, folder, tableData } from './mock_data';
+
+describe('Environment item', () => {
+ let wrapper;
+
+ const factory = (options = {}) => {
+ // This destroys any wrappers created before a nested call to factory reassigns it
+ if (wrapper && wrapper.destroy) {
+ wrapper.destroy();
+ }
+ wrapper = mount(EnvironmentItem, {
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ factory({
+ propsData: {
+ model: environment,
+ canReadEnvironment: true,
+ tableData,
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when item is not folder', () => {
+ it('should render environment name', () => {
+ expect(wrapper.find('.environment-name').text()).toContain(environment.name);
+ });
+
+ describe('With deployment', () => {
+ it('should render deployment internal id', () => {
+ expect(wrapper.find('.deployment-column span').text()).toContain(
+ environment.last_deployment.iid,
+ );
+
+ expect(wrapper.find('.deployment-column span').text()).toContain('#');
+ });
+
+ it('should render last deployment date', () => {
+ const formatedDate = format(environment.last_deployment.deployed_at);
+
+ expect(wrapper.find('.environment-created-date-timeago').text()).toContain(formatedDate);
+ });
+
+ describe('With user information', () => {
+ it('should render user avatar with link to profile', () => {
+ expect(wrapper.find('.js-deploy-user-container').attributes('href')).toEqual(
+ environment.last_deployment.user.web_url,
+ );
+ });
+ });
+
+ describe('With build url', () => {
+ it('should link to build url provided', () => {
+ expect(wrapper.find('.build-link').attributes('href')).toEqual(
+ environment.last_deployment.deployable.build_path,
+ );
+ });
+
+ it('should render deployable name and id', () => {
+ expect(wrapper.find('.build-link').attributes('href')).toEqual(
+ environment.last_deployment.deployable.build_path,
+ );
+ });
+ });
+
+ describe('With commit information', () => {
+ it('should render commit component', () => {
+ expect(wrapper.find('.js-commit-component')).toBeDefined();
+ });
+ });
+ });
+
+ describe('With manual actions', () => {
+ it('should render actions component', () => {
+ expect(wrapper.find('.js-manual-actions-container')).toBeDefined();
+ });
+ });
+
+ describe('With external URL', () => {
+ it('should render external url component', () => {
+ expect(wrapper.find('.js-external-url-container')).toBeDefined();
+ });
+ });
+
+ describe('With stop action', () => {
+ it('should render stop action component', () => {
+ expect(wrapper.find('.js-stop-component-container')).toBeDefined();
+ });
+ });
+
+ describe('With retry action', () => {
+ it('should render rollback component', () => {
+ expect(wrapper.find('.js-rollback-component-container')).toBeDefined();
+ });
+ });
+ });
+
+ describe('When item is folder', () => {
+ beforeEach(() => {
+ factory({
+ propsData: {
+ model: folder,
+ canReadEnvironment: true,
+ tableData,
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should render folder icon and name', () => {
+ expect(wrapper.find('.folder-name').text()).toContain(folder.name);
+ expect(wrapper.find('.folder-icon')).toBeDefined();
+ });
+
+ it('should render the number of children in a badge', () => {
+ expect(wrapper.find('.folder-name .badge').text()).toContain(folder.size);
+ });
+ });
+});
diff --git a/spec/javascripts/environments/environment_table_spec.js b/spec/frontend/environments/environment_table_spec.js
index e8c6fb1845d..b8ef40e2568 100644
--- a/spec/javascripts/environments/environment_table_spec.js
+++ b/spec/frontend/environments/environment_table_spec.js
@@ -1,44 +1,44 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import environmentTableComp from '~/environments/components/environments_table.vue';
+import { mount } from '@vue/test-utils';
+import EnvironmentTable from '~/environments/components/environments_table.vue';
+import { folder } from './mock_data';
+
+const eeOnlyProps = {
+ canaryDeploymentFeatureId: 'canary_deployment',
+ showCanaryDeploymentCallout: true,
+ userCalloutsPath: '/callouts',
+ lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',
+ helpCanaryDeploymentsPath: 'help/canary-deployments',
+};
describe('Environment table', () => {
- let Component;
- let vm;
+ let wrapper;
- const eeOnlyProps = {
- canaryDeploymentFeatureId: 'canary_deployment',
- showCanaryDeploymentCallout: true,
- userCalloutsPath: '/callouts',
- lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',
- helpCanaryDeploymentsPath: 'help/canary-deployments',
+ const factory = (options = {}) => {
+ // This destroys any wrappers created before a nested call to factory reassigns it
+ if (wrapper && wrapper.destroy) {
+ wrapper.destroy();
+ }
+ wrapper = mount(EnvironmentTable, {
+ ...options,
+ });
};
beforeEach(() => {
- Component = Vue.extend(environmentTableComp);
+ factory({
+ propsData: {
+ environments: [folder],
+ canReadEnvironment: true,
+ ...eeOnlyProps,
+ },
+ });
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
it('Should render a table', () => {
- const mockItem = {
- name: 'review',
- size: 3,
- isFolder: true,
- latest: {
- environment_path: 'url',
- },
- };
-
- vm = mountComponent(Component, {
- environments: [mockItem],
- canReadEnvironment: true,
- ...eeOnlyProps,
- });
-
- expect(vm.$el.getAttribute('class')).toContain('ci-table');
+ expect(wrapper.classes()).toContain('ci-table');
});
describe('sortEnvironments', () => {
@@ -73,15 +73,17 @@ describe('Environment table', () => {
},
];
- vm = mountComponent(Component, {
- environments: mockItems,
- canReadEnvironment: true,
- ...eeOnlyProps,
+ factory({
+ propsData: {
+ environments: mockItems,
+ canReadEnvironment: true,
+ ...eeOnlyProps,
+ },
});
const [old, newer, older, noDeploy] = mockItems;
- expect(vm.sortEnvironments(mockItems)).toEqual([newer, old, older, noDeploy]);
+ expect(wrapper.vm.sortEnvironments(mockItems)).toEqual([newer, old, older, noDeploy]);
});
it('should push environments with no deployments to the bottom', () => {
@@ -137,15 +139,17 @@ describe('Environment table', () => {
},
];
- vm = mountComponent(Component, {
- environments: mockItems,
- canReadEnvironment: true,
- ...eeOnlyProps,
+ factory({
+ propsData: {
+ environments: mockItems,
+ canReadEnvironment: true,
+ ...eeOnlyProps,
+ },
});
const [prod, review, staging] = mockItems;
- expect(vm.sortEnvironments(mockItems)).toEqual([review, staging, prod]);
+ expect(wrapper.vm.sortEnvironments(mockItems)).toEqual([review, staging, prod]);
});
it('should sort environments by folder first', () => {
@@ -174,15 +178,17 @@ describe('Environment table', () => {
},
];
- vm = mountComponent(Component, {
- environments: mockItems,
- canReadEnvironment: true,
- ...eeOnlyProps,
+ factory({
+ propsData: {
+ environments: mockItems,
+ canReadEnvironment: true,
+ ...eeOnlyProps,
+ },
});
const [old, newer, older] = mockItems;
- expect(vm.sortEnvironments(mockItems)).toEqual([older, newer, old]);
+ expect(wrapper.vm.sortEnvironments(mockItems)).toEqual([older, newer, old]);
});
it('should break ties by name', () => {
@@ -201,15 +207,17 @@ describe('Environment table', () => {
},
];
- vm = mountComponent(Component, {
- environments: mockItems,
- canReadEnvironment: true,
- ...eeOnlyProps,
+ factory({
+ propsData: {
+ environments: mockItems,
+ canReadEnvironment: true,
+ ...eeOnlyProps,
+ },
});
const [old, newer, older] = mockItems;
- expect(vm.sortEnvironments(mockItems)).toEqual([older, newer, old]);
+ expect(wrapper.vm.sortEnvironments(mockItems)).toEqual([older, newer, old]);
});
});
@@ -250,19 +258,21 @@ describe('Environment table', () => {
const [production, review, staging] = mockItems;
const [addcibuildstatus, master] = mockItems[1].children;
- vm = mountComponent(Component, {
- environments: mockItems,
- canReadEnvironment: true,
- ...eeOnlyProps,
+ factory({
+ propsData: {
+ environments: mockItems,
+ canReadEnvironment: true,
+ ...eeOnlyProps,
+ },
});
- expect(vm.sortedEnvironments.map(env => env.name)).toEqual([
+ expect(wrapper.vm.sortedEnvironments.map(env => env.name)).toEqual([
review.name,
staging.name,
production.name,
]);
- expect(vm.sortedEnvironments[0].children).toEqual([master, addcibuildstatus]);
+ expect(wrapper.vm.sortedEnvironments[0].children).toEqual([master, addcibuildstatus]);
});
});
});
diff --git a/spec/frontend/environments/mock_data.js b/spec/frontend/environments/mock_data.js
new file mode 100644
index 00000000000..a014108b898
--- /dev/null
+++ b/spec/frontend/environments/mock_data.js
@@ -0,0 +1,106 @@
+const environment = {
+ name: 'production',
+ size: 1,
+ state: 'stopped',
+ external_url: 'http://external.com',
+ environment_type: null,
+ last_deployment: {
+ id: 66,
+ iid: 6,
+ sha: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
+ ref: {
+ name: 'master',
+ ref_url: 'root/ci-folders/tree/master',
+ },
+ tag: true,
+ 'last?': true,
+ user: {
+ name: 'Administrator',
+ username: 'root',
+ id: 1,
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ web_url: 'http://localhost:3000/root',
+ },
+ commit: {
+ id: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
+ short_id: '500aabcb',
+ title: 'Update .gitlab-ci.yml',
+ author_name: 'Administrator',
+ author_email: 'admin@example.com',
+ created_at: '2016-11-07T18:28:13.000+00:00',
+ message: 'Update .gitlab-ci.yml',
+ author: {
+ name: 'Administrator',
+ username: 'root',
+ id: 1,
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ web_url: 'http://localhost:3000/root',
+ },
+ commit_path: '/root/ci-folders/tree/500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
+ },
+ deployable: {
+ id: 1279,
+ name: 'deploy',
+ build_path: '/root/ci-folders/builds/1279',
+ retry_path: '/root/ci-folders/builds/1279/retry',
+ created_at: '2016-11-29T18:11:58.430Z',
+ updated_at: '2016-11-29T18:11:58.430Z',
+ },
+ manual_actions: [
+ {
+ name: 'action',
+ play_path: '/play',
+ },
+ ],
+ deployed_at: '2016-11-29T18:11:58.430Z',
+ },
+ has_stop_action: true,
+ environment_path: 'root/ci-folders/environments/31',
+ log_path: 'root/ci-folders/environments/31/logs',
+ created_at: '2016-11-07T11:11:16.525Z',
+ updated_at: '2016-11-10T15:55:58.778Z',
+};
+
+const folder = {
+ name: 'review',
+ folderName: 'review',
+ size: 3,
+ isFolder: true,
+ environment_path: 'url',
+ log_path: 'url',
+ latest: {
+ environment_path: 'url',
+ },
+};
+
+const tableData = {
+ name: {
+ title: 'Environment',
+ spacing: 'section-15',
+ },
+ deploy: {
+ title: 'Deployment',
+ spacing: 'section-10',
+ },
+ build: {
+ title: 'Job',
+ spacing: 'section-15',
+ },
+ commit: {
+ title: 'Commit',
+ spacing: 'section-20',
+ },
+ date: {
+ title: 'Updated',
+ spacing: 'section-10',
+ },
+ actions: {
+ spacing: 'section-25',
+ },
+};
+
+export { environment, folder, tableData };
diff --git a/spec/javascripts/environments/environment_item_spec.js b/spec/javascripts/environments/environment_item_spec.js
deleted file mode 100644
index 09209ba2513..00000000000
--- a/spec/javascripts/environments/environment_item_spec.js
+++ /dev/null
@@ -1,230 +0,0 @@
-import { format } from 'timeago.js';
-import Vue from 'vue';
-import environmentItemComp from '~/environments/components/environment_item.vue';
-
-const tableData = {
- name: {
- title: 'Environment',
- spacing: 'section-15',
- },
- deploy: {
- title: 'Deployment',
- spacing: 'section-10',
- },
- build: {
- title: 'Job',
- spacing: 'section-15',
- },
- commit: {
- title: 'Commit',
- spacing: 'section-20',
- },
- date: {
- title: 'Updated',
- spacing: 'section-10',
- },
- actions: {
- spacing: 'section-25',
- },
-};
-
-describe('Environment item', () => {
- let EnvironmentItem;
-
- beforeEach(() => {
- EnvironmentItem = Vue.extend(environmentItemComp);
- });
-
- describe('When item is folder', () => {
- let mockItem;
- let component;
-
- beforeEach(() => {
- mockItem = {
- name: 'review',
- folderName: 'review',
- size: 3,
- isFolder: true,
- environment_path: 'url',
- log_path: 'url',
- };
-
- component = new EnvironmentItem({
- propsData: {
- model: mockItem,
- canReadEnvironment: true,
- tableData,
- },
- }).$mount();
- });
-
- it('should render folder icon and name', () => {
- expect(component.$el.querySelector('.folder-name').textContent).toContain(mockItem.name);
- expect(component.$el.querySelector('.folder-icon')).toBeDefined();
- });
-
- it('should render the number of children in a badge', () => {
- expect(component.$el.querySelector('.folder-name .badge').textContent).toContain(
- mockItem.size,
- );
- });
- });
-
- describe('when item is not folder', () => {
- let environment;
- let component;
-
- beforeEach(() => {
- environment = {
- name: 'production',
- size: 1,
- state: 'stopped',
- external_url: 'http://external.com',
- environment_type: null,
- last_deployment: {
- id: 66,
- iid: 6,
- sha: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
- ref: {
- name: 'master',
- ref_url: 'root/ci-folders/tree/master',
- },
- tag: true,
- 'last?': true,
- user: {
- name: 'Administrator',
- username: 'root',
- id: 1,
- state: 'active',
- avatar_url:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
- web_url: 'http://localhost:3000/root',
- },
- commit: {
- id: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
- short_id: '500aabcb',
- title: 'Update .gitlab-ci.yml',
- author_name: 'Administrator',
- author_email: 'admin@example.com',
- created_at: '2016-11-07T18:28:13.000+00:00',
- message: 'Update .gitlab-ci.yml',
- author: {
- name: 'Administrator',
- username: 'root',
- id: 1,
- state: 'active',
- avatar_url:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
- web_url: 'http://localhost:3000/root',
- },
- commit_path: '/root/ci-folders/tree/500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
- },
- deployable: {
- id: 1279,
- name: 'deploy',
- build_path: '/root/ci-folders/builds/1279',
- retry_path: '/root/ci-folders/builds/1279/retry',
- created_at: '2016-11-29T18:11:58.430Z',
- updated_at: '2016-11-29T18:11:58.430Z',
- },
- manual_actions: [
- {
- name: 'action',
- play_path: '/play',
- },
- ],
- deployed_at: '2016-11-29T18:11:58.430Z',
- },
- has_stop_action: true,
- environment_path: 'root/ci-folders/environments/31',
- log_path: 'root/ci-folders/environments/31/logs',
- created_at: '2016-11-07T11:11:16.525Z',
- updated_at: '2016-11-10T15:55:58.778Z',
- };
-
- component = new EnvironmentItem({
- propsData: {
- model: environment,
- canReadEnvironment: true,
- tableData,
- },
- }).$mount();
- });
-
- it('should render environment name', () => {
- expect(component.$el.querySelector('.environment-name').textContent).toContain(
- environment.name,
- );
- });
-
- describe('With deployment', () => {
- it('should render deployment internal id', () => {
- expect(component.$el.querySelector('.deployment-column span').textContent).toContain(
- environment.last_deployment.iid,
- );
-
- expect(component.$el.querySelector('.deployment-column span').textContent).toContain('#');
- });
-
- it('should render last deployment date', () => {
- const formatedDate = format(environment.last_deployment.deployed_at);
-
- expect(
- component.$el.querySelector('.environment-created-date-timeago').textContent,
- ).toContain(formatedDate);
- });
-
- describe('With user information', () => {
- it('should render user avatar with link to profile', () => {
- expect(
- component.$el.querySelector('.js-deploy-user-container').getAttribute('href'),
- ).toEqual(environment.last_deployment.user.web_url);
- });
- });
-
- describe('With build url', () => {
- it('should link to build url provided', () => {
- expect(component.$el.querySelector('.build-link').getAttribute('href')).toEqual(
- environment.last_deployment.deployable.build_path,
- );
- });
-
- it('should render deployable name and id', () => {
- expect(component.$el.querySelector('.build-link').getAttribute('href')).toEqual(
- environment.last_deployment.deployable.build_path,
- );
- });
- });
-
- describe('With commit information', () => {
- it('should render commit component', () => {
- expect(component.$el.querySelector('.js-commit-component')).toBeDefined();
- });
- });
- });
-
- describe('With manual actions', () => {
- it('should render actions component', () => {
- expect(component.$el.querySelector('.js-manual-actions-container')).toBeDefined();
- });
- });
-
- describe('With external URL', () => {
- it('should render external url component', () => {
- expect(component.$el.querySelector('.js-external-url-container')).toBeDefined();
- });
- });
-
- describe('With stop action', () => {
- it('should render stop action component', () => {
- expect(component.$el.querySelector('.js-stop-component-container')).toBeDefined();
- });
- });
-
- describe('With retry action', () => {
- it('should render rollback component', () => {
- expect(component.$el.querySelector('.js-rollback-component-container')).toBeDefined();
- });
- });
- });
-});
diff --git a/spec/javascripts/pipelines/graph/linked_pipelines_column_spec.js b/spec/javascripts/pipelines/graph/linked_pipelines_column_spec.js
index 1f835dc4dee..0584a118f81 100644
--- a/spec/javascripts/pipelines/graph/linked_pipelines_column_spec.js
+++ b/spec/javascripts/pipelines/graph/linked_pipelines_column_spec.js
@@ -35,4 +35,8 @@ describe('Linked Pipelines Column', () => {
expect(linkedPipelineElements.length).toBe(props.linkedPipelines.length);
});
+
+ it('renders cross project triangle when column is upstream', () => {
+ expect(vm.$el.querySelector('.cross-project-triangle')).toBeDefined();
+ });
});
diff --git a/spec/lib/banzai/reference_parser/mentioned_users_by_group_parser_spec.rb b/spec/lib/banzai/reference_parser/mentioned_group_parser_spec.rb
index 99d607629eb..30b99f3eda7 100644
--- a/spec/lib/banzai/reference_parser/mentioned_users_by_group_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/mentioned_group_parser_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-describe Banzai::ReferenceParser::MentionedUsersByGroupParser do
+describe Banzai::ReferenceParser::MentionedGroupParser do
include ReferenceParserHelpers
let(:group) { create(:group, :private) }
diff --git a/spec/lib/banzai/reference_parser/mentioned_users_by_project_parser_spec.rb b/spec/lib/banzai/reference_parser/mentioned_project_parser_spec.rb
index 155f2189d9e..154f7c4dc36 100644
--- a/spec/lib/banzai/reference_parser/mentioned_users_by_project_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/mentioned_project_parser_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-describe Banzai::ReferenceParser::MentionedUsersByProjectParser do
+describe Banzai::ReferenceParser::MentionedProjectParser do
include ReferenceParserHelpers
let(:group) { create(:group, :private) }
diff --git a/spec/lib/gitlab/ci/config/entry/default_spec.rb b/spec/lib/gitlab/ci/config/entry/default_spec.rb
index ffd24aa56b9..391d594bc02 100644
--- a/spec/lib/gitlab/ci/config/entry/default_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/default_spec.rb
@@ -27,7 +27,7 @@ describe Gitlab::Ci::Config::Entry::Default do
expect(described_class.nodes.keys)
.to match_array(%i[before_script image services
after_script cache interruptible
- timeout retry])
+ timeout retry tags])
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb
index 4d6245c2d86..6e077aa00d7 100644
--- a/spec/lib/gitlab/ci/config/entry/job_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb
@@ -24,7 +24,7 @@ describe Gitlab::Ci::Config::Entry::Job do
let(:result) do
%i[before_script script stage type after_script cache
image services only except rules needs variables artifacts
- environment coverage retry interruptible timeout]
+ environment coverage retry interruptible timeout tags]
end
it { is_expected.to match_array result }
diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb
index ed2d97b1a38..ff8849f84d5 100644
--- a/spec/lib/gitlab/ci/yaml_processor_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb
@@ -1849,7 +1849,7 @@ module Gitlab
config = YAML.dump({ rspec: { script: "test", tags: "mysql" } })
expect do
Gitlab::Ci::YamlProcessor.new(config)
- end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec tags should be an array of strings")
+ end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:tags config should be an array of strings")
end
it "returns errors if before_script parameter is invalid" do
@@ -2197,7 +2197,7 @@ module Gitlab
context "when the tags parameter is invalid" do
let(:content) { YAML.dump({ rspec: { script: "test", tags: "mysql" } }) }
- it { is_expected.to eq "jobs:rspec tags should be an array of strings" }
+ it { is_expected.to eq "jobs:rspec:tags config should be an array of strings" }
end
context "when YAML content is empty" do
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index ab17d9993f6..26793f28bd8 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -34,6 +34,7 @@ issues:
- zoom_meetings
- vulnerability_links
- related_vulnerabilities
+- user_mentions
events:
- author
- project
@@ -82,6 +83,7 @@ snippets:
- notes
- award_emoji
- user_agent_detail
+- user_mentions
releases:
- author
- project
@@ -142,6 +144,7 @@ merge_requests:
- description_versions
- deployment_merge_requests
- deployments
+- user_mentions
external_pull_requests:
- project
merge_request_diff:
@@ -539,6 +542,7 @@ design: &design
- actions
- versions
- notes
+- user_mentions
designs: *design
actions:
- design
diff --git a/spec/models/broadcast_message_spec.rb b/spec/models/broadcast_message_spec.rb
index b06fa845777..67d8284bebe 100644
--- a/spec/models/broadcast_message_spec.rb
+++ b/spec/models/broadcast_message_spec.rb
@@ -20,65 +20,71 @@ describe BroadcastMessage do
it { is_expected.to allow_value(triplet).for(:font) }
it { is_expected.to allow_value(hex).for(:font) }
it { is_expected.not_to allow_value('000').for(:font) }
+
+ it { is_expected.to allow_value(1).for(:broadcast_type) }
+ it { is_expected.not_to allow_value(nil).for(:broadcast_type) }
end
- describe '.current', :use_clean_rails_memory_store_caching do
+ shared_examples 'time constrainted' do |broadcast_type|
it 'returns message if time match' do
- message = create(:broadcast_message)
+ message = create(:broadcast_message, broadcast_type: broadcast_type)
- expect(described_class.current).to include(message)
+ expect(subject.call).to include(message)
end
it 'returns multiple messages if time match' do
- message1 = create(:broadcast_message)
- message2 = create(:broadcast_message)
+ message1 = create(:broadcast_message, broadcast_type: broadcast_type)
+ message2 = create(:broadcast_message, broadcast_type: broadcast_type)
- expect(described_class.current).to contain_exactly(message1, message2)
+ expect(subject.call).to contain_exactly(message1, message2)
end
it 'returns empty list if time not come' do
- create(:broadcast_message, :future)
+ create(:broadcast_message, :future, broadcast_type: broadcast_type)
- expect(described_class.current).to be_empty
+ expect(subject.call).to be_empty
end
it 'returns empty list if time has passed' do
- create(:broadcast_message, :expired)
+ create(:broadcast_message, :expired, broadcast_type: broadcast_type)
- expect(described_class.current).to be_empty
+ expect(subject.call).to be_empty
end
+ end
+ shared_examples 'message cache' do |broadcast_type|
it 'caches the output of the query for two weeks' do
- create(:broadcast_message)
+ create(:broadcast_message, broadcast_type: broadcast_type)
expect(described_class).to receive(:current_and_future_messages).and_call_original.twice
- described_class.current
+ subject.call
Timecop.travel(3.weeks) do
- described_class.current
+ subject.call
end
end
it 'does not create new records' do
- create(:broadcast_message)
+ create(:broadcast_message, broadcast_type: broadcast_type)
- expect { described_class.current }.not_to change { described_class.count }
+ expect { subject.call }.not_to change { described_class.count }
end
it 'includes messages that need to be displayed in the future' do
- create(:broadcast_message)
+ create(:broadcast_message, broadcast_type: broadcast_type)
future = create(
:broadcast_message,
starts_at: Time.now + 10.minutes,
- ends_at: Time.now + 20.minutes
+ ends_at: Time.now + 20.minutes,
+ broadcast_type: broadcast_type
)
- expect(described_class.current.length).to eq(1)
+ expect(subject.call.length).to eq(1)
Timecop.travel(future.starts_at) do
- expect(described_class.current.length).to eq(2)
+ expect(subject.call.length).to eq(2)
end
end
@@ -86,43 +92,90 @@ describe BroadcastMessage do
create(:broadcast_message, :future)
expect(Rails.cache).not_to receive(:delete).with(described_class::CACHE_KEY)
- expect(described_class.current.length).to eq(0)
+ expect(subject.call.length).to eq(0)
end
+ end
+ shared_examples "matches with current path" do |broadcast_type|
it 'returns message if it matches the target path' do
- message = create(:broadcast_message, target_path: "*/onboarding_completed")
+ message = create(:broadcast_message, target_path: "*/onboarding_completed", broadcast_type: broadcast_type)
- expect(described_class.current('/users/onboarding_completed')).to include(message)
+ expect(subject.call('/users/onboarding_completed')).to include(message)
end
it 'returns message if part of the target path matches' do
- create(:broadcast_message, target_path: "/users/*/issues")
+ create(:broadcast_message, target_path: "/users/*/issues", broadcast_type: broadcast_type)
- expect(described_class.current('/users/name/issues').length).to eq(1)
+ expect(subject.call('/users/name/issues').length).to eq(1)
end
it 'returns the message for empty target path' do
- create(:broadcast_message, target_path: "")
+ create(:broadcast_message, target_path: "", broadcast_type: broadcast_type)
- expect(described_class.current('/users/name/issues').length).to eq(1)
+ expect(subject.call('/users/name/issues').length).to eq(1)
end
it 'returns the message if target path is nil' do
- create(:broadcast_message, target_path: nil)
+ create(:broadcast_message, target_path: nil, broadcast_type: broadcast_type)
- expect(described_class.current('/users/name/issues').length).to eq(1)
+ expect(subject.call('/users/name/issues').length).to eq(1)
end
it 'does not return message if target path does not match' do
- create(:broadcast_message, target_path: "/onboarding_completed")
+ create(:broadcast_message, target_path: "/onboarding_completed", broadcast_type: broadcast_type)
- expect(described_class.current('/welcome').length).to eq(0)
+ expect(subject.call('/welcome').length).to eq(0)
end
it 'does not return message if target path does not match when using wildcard' do
- create(:broadcast_message, target_path: "/users/*/issues")
+ create(:broadcast_message, target_path: "/users/*/issues", broadcast_type: broadcast_type)
+
+ expect(subject.call('/group/groupname/issues').length).to eq(0)
+ end
+ end
+
+ describe '.current', :use_clean_rails_memory_store_caching do
+ subject { -> (path = nil) { described_class.current(path) } }
+
+ it_behaves_like 'time constrainted', :banner
+ it_behaves_like 'message cache', :banner
+ it_behaves_like 'matches with current path', :banner
+
+ it 'returns both types' do
+ banner_message = create(:broadcast_message, broadcast_type: :banner)
+ notification_message = create(:broadcast_message, broadcast_type: :notification)
+
+ expect(subject.call).to contain_exactly(banner_message, notification_message)
+ end
+ end
+
+ describe '.current_banner_messages', :use_clean_rails_memory_store_caching do
+ subject { -> (path = nil) { described_class.current_banner_messages(path) } }
+
+ it_behaves_like 'time constrainted', :banner
+ it_behaves_like 'message cache', :banner
+ it_behaves_like 'matches with current path', :banner
+
+ it 'only returns banners' do
+ banner_message = create(:broadcast_message, broadcast_type: :banner)
+ create(:broadcast_message, broadcast_type: :notification)
+
+ expect(subject.call).to contain_exactly(banner_message)
+ end
+ end
+
+ describe '.current_notification_messages', :use_clean_rails_memory_store_caching do
+ subject { -> (path = nil) { described_class.current_notification_messages(path) } }
+
+ it_behaves_like 'time constrainted', :notification
+ it_behaves_like 'message cache', :notification
+ it_behaves_like 'matches with current path', :notification
+
+ it 'only returns notifications' do
+ notification_message = create(:broadcast_message, broadcast_type: :notification)
+ create(:broadcast_message, broadcast_type: :banner)
- expect(described_class.current('/group/groupname/issues').length).to eq(0)
+ expect(subject.call).to contain_exactly(notification_message)
end
end
@@ -193,6 +246,8 @@ describe BroadcastMessage do
message = create(:broadcast_message)
expect(Rails.cache).to receive(:delete).with(described_class::CACHE_KEY)
+ expect(Rails.cache).to receive(:delete).with(described_class::BANNER_CACHE_KEY)
+ expect(Rails.cache).to receive(:delete).with(described_class::NOTIFICATION_CACHE_KEY)
message.flush_redis_cache
end
diff --git a/spec/models/concerns/mentionable_spec.rb b/spec/models/concerns/mentionable_spec.rb
index 6034344d034..883f678b8f5 100644
--- a/spec/models/concerns/mentionable_spec.rb
+++ b/spec/models/concerns/mentionable_spec.rb
@@ -166,6 +166,21 @@ describe Issue, "Mentionable" do
create(:issue, project: project, description: description, author: author)
end
end
+
+ describe '#store_mentions!' do
+ it_behaves_like 'mentions in description', :issue
+ it_behaves_like 'mentions in notes', :issue do
+ let(:note) { create(:note_on_issue) }
+ let(:mentionable) { note.noteable }
+ end
+ end
+
+ describe 'load mentions' do
+ it_behaves_like 'load mentions from DB', :issue do
+ let(:note) { create(:note_on_issue) }
+ let(:mentionable) { note.noteable }
+ end
+ end
end
describe Commit, 'Mentionable' do
@@ -221,4 +236,56 @@ describe Commit, 'Mentionable' do
end
end
end
+
+ describe '#store_mentions!' do
+ it_behaves_like 'mentions in notes', :commit do
+ let(:note) { create(:note_on_commit) }
+ let(:mentionable) { note.noteable }
+ end
+ end
+
+ describe 'load mentions' do
+ it_behaves_like 'load mentions from DB', :commit do
+ let(:note) { create(:note_on_commit) }
+ let(:mentionable) { note.noteable }
+ end
+ end
+end
+
+describe MergeRequest, 'Mentionable' do
+ describe '#store_mentions!' do
+ it_behaves_like 'mentions in description', :merge_request
+ it_behaves_like 'mentions in notes', :merge_request do
+ let(:project) { create(:project) }
+ let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
+ let(:note) { create(:note_on_merge_request, noteable: merge_request, project: merge_request.project) }
+ let(:mentionable) { note.noteable }
+ end
+ end
+
+ describe 'load mentions' do
+ it_behaves_like 'load mentions from DB', :merge_request do
+ let(:project) { create(:project) }
+ let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
+ let(:note) { create(:note_on_merge_request, noteable: merge_request, project: merge_request.project) }
+ let(:mentionable) { note.noteable }
+ end
+ end
+end
+
+describe Snippet, 'Mentionable' do
+ describe '#store_mentions!' do
+ it_behaves_like 'mentions in description', :project_snippet
+ it_behaves_like 'mentions in notes', :project_snippet do
+ let(:note) { create(:note_on_project_snippet) }
+ let(:mentionable) { note.noteable }
+ end
+ end
+
+ describe 'load mentions' do
+ it_behaves_like 'load mentions from DB', :project_snippet do
+ let(:note) { create(:note_on_project_snippet) }
+ let(:mentionable) { note.noteable }
+ end
+ end
end
diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb
index 4d115be9524..d1ed06dd04d 100644
--- a/spec/models/issue_spec.rb
+++ b/spec/models/issue_spec.rb
@@ -12,6 +12,7 @@ describe Issue do
it { is_expected.to belong_to(:duplicated_to).class_name('Issue') }
it { is_expected.to belong_to(:closed_by).class_name('User') }
it { is_expected.to have_many(:assignees) }
+ it { is_expected.to have_many(:user_mentions).class_name("IssueUserMention") }
it { is_expected.to have_one(:sentry_issue) }
end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index bec817f2416..72e8294e237 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -17,6 +17,7 @@ describe MergeRequest do
it { is_expected.to belong_to(:merge_user).class_name("User") }
it { is_expected.to have_many(:assignees).through(:merge_request_assignees) }
it { is_expected.to have_many(:merge_request_diffs) }
+ it { is_expected.to have_many(:user_mentions).class_name("MergeRequestUserMention") }
context 'for forks' do
let!(:project) { create(:project) }
diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb
index 82836dac1d7..9c549a6d56d 100644
--- a/spec/models/snippet_spec.rb
+++ b/spec/models/snippet_spec.rb
@@ -18,6 +18,7 @@ describe Snippet do
it { is_expected.to belong_to(:project) }
it { is_expected.to have_many(:notes).dependent(:destroy) }
it { is_expected.to have_many(:award_emoji).dependent(:destroy) }
+ it { is_expected.to have_many(:user_mentions).class_name("SnippetUserMention") }
end
describe 'validation' do
diff --git a/spec/models/user_mentions/commit_user_mention_spec.rb b/spec/models/user_mentions/commit_user_mention_spec.rb
new file mode 100644
index 00000000000..ebad3902d6b
--- /dev/null
+++ b/spec/models/user_mentions/commit_user_mention_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe CommitUserMention do
+ describe 'associations' do
+ it { is_expected.to belong_to(:note) }
+ end
+
+ it_behaves_like 'has user mentions'
+end
diff --git a/spec/models/user_mentions/issue_user_mention_spec.rb b/spec/models/user_mentions/issue_user_mention_spec.rb
new file mode 100644
index 00000000000..ac29f3084b4
--- /dev/null
+++ b/spec/models/user_mentions/issue_user_mention_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe IssueUserMention do
+ describe 'associations' do
+ it { is_expected.to belong_to(:issue) }
+ it { is_expected.to belong_to(:note) }
+ end
+
+ it_behaves_like 'has user mentions'
+end
diff --git a/spec/models/user_mentions/merge_request_user_mention_spec.rb b/spec/models/user_mentions/merge_request_user_mention_spec.rb
new file mode 100644
index 00000000000..c5c7cebfaa5
--- /dev/null
+++ b/spec/models/user_mentions/merge_request_user_mention_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe MergeRequestUserMention do
+ describe 'associations' do
+ it { is_expected.to belong_to(:merge_request) }
+ it { is_expected.to belong_to(:note) }
+ end
+
+ it_behaves_like 'has user mentions'
+end
diff --git a/spec/models/user_mentions/snippet_user_mention_spec.rb b/spec/models/user_mentions/snippet_user_mention_spec.rb
new file mode 100644
index 00000000000..0e34a2dd5a1
--- /dev/null
+++ b/spec/models/user_mentions/snippet_user_mention_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe SnippetUserMention do
+ describe 'associations' do
+ it { is_expected.to belong_to(:snippet) }
+ it { is_expected.to belong_to(:note) }
+ end
+
+ it_behaves_like 'has user mentions'
+end
diff --git a/spec/support/shared_examples/mentionable_shared_examples.rb b/spec/support/shared_examples/mentionable_shared_examples.rb
index 93a8c4709a6..6efc471ce75 100644
--- a/spec/support/shared_examples/mentionable_shared_examples.rb
+++ b/spec/support/shared_examples/mentionable_shared_examples.rb
@@ -195,3 +195,153 @@ shared_examples 'an editable mentionable' do
subject.create_new_cross_references!(author)
end
end
+
+shared_examples_for 'mentions in description' do |mentionable_type|
+ describe 'when store_mentioned_users_to_db feature disabled' do
+ before do
+ stub_feature_flags(store_mentioned_users_to_db: false)
+ mentionable.store_mentions!
+ end
+
+ context 'when mentionable description contains mentions' do
+ let(:user) { create(:user) }
+ let(:mentionable) { create(mentionable_type, description: "#{user.to_reference} some description") }
+
+ it 'stores no mentions' do
+ expect(mentionable.user_mentions.count).to eq 0
+ end
+ end
+ end
+
+ describe 'when store_mentioned_users_to_db feature enabled' do
+ before do
+ stub_feature_flags(store_mentioned_users_to_db: true)
+ mentionable.store_mentions!
+ end
+
+ context 'when mentionable description has no mentions' do
+ let(:mentionable) { create(mentionable_type, description: "just some description") }
+
+ it 'stores no mentions' do
+ expect(mentionable.user_mentions.count).to eq 0
+ end
+ end
+
+ context 'when mentionable description contains mentions' do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+
+ let(:mentionable_desc) { "#{user.to_reference} some description #{group.to_reference(full: true)} and @all" }
+ let(:mentionable) { create(mentionable_type, description: mentionable_desc) }
+
+ it 'stores mentions' do
+ add_member(user)
+
+ expect(mentionable.user_mentions.count).to eq 1
+ expect(mentionable.referenced_users).to match_array([user])
+ expect(mentionable.referenced_projects(user)).to match_array([mentionable.project].compact) # epic.project is nil, and we want empty []
+ expect(mentionable.referenced_groups(user)).to match_array([group])
+ end
+ end
+ end
+end
+
+shared_examples_for 'mentions in notes' do |mentionable_type|
+ context 'when mentionable notes contain mentions' do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:note_desc) { "#{user.to_reference} and #{group.to_reference(full: true)} and @all" }
+ let!(:mentionable) { note.noteable }
+
+ before do
+ note.update(note: note_desc)
+ note.store_mentions!
+ add_member(user)
+ end
+
+ it 'returns all mentionable mentions' do
+ expect(mentionable.user_mentions.count).to eq 1
+ expect(mentionable.referenced_users).to eq [user]
+ expect(mentionable.referenced_projects(user)).to eq [mentionable.project].compact # epic.project is nil, and we want empty []
+ expect(mentionable.referenced_groups(user)).to eq [group]
+ end
+ end
+end
+
+shared_examples_for 'load mentions from DB' do |mentionable_type|
+ context 'load stored mentions' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:mentioned_user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:note_desc) { "#{mentioned_user.to_reference} and #{group.to_reference(full: true)} and @all" }
+
+ before do
+ note.update(note: note_desc)
+ note.store_mentions!
+ add_member(user)
+ end
+
+ context 'when stored user mention contains ids of inexistent records' do
+ before do
+ user_mention = note.send(:model_user_mention)
+ mention_ids = {
+ mentioned_users_ids: user_mention.mentioned_users_ids.to_a << User.maximum(:id).to_i.succ,
+ mentioned_projects_ids: user_mention.mentioned_projects_ids.to_a << Project.maximum(:id).to_i.succ,
+ mentioned_groups_ids: user_mention.mentioned_groups_ids.to_a << Group.maximum(:id).to_i.succ
+ }
+ user_mention.update(mention_ids)
+ end
+
+ it 'filters out inexistent mentions' do
+ expect(mentionable.referenced_users).to match_array([mentioned_user])
+ expect(mentionable.referenced_projects(user)).to match_array([mentionable.project].compact) # epic.project is nil, and we want empty []
+ expect(mentionable.referenced_groups(user)).to match_array([group])
+ end
+ end
+
+ context 'when private projects and groups are mentioned' do
+ let(:mega_user) { create(:user) }
+ let(:private_project) { create(:project, :private) }
+ let(:project_member) { create(:project_member, user: create(:user), project: private_project) }
+ let(:private_group) { create(:group, :private) }
+ let(:group_member) { create(:group_member, user: create(:user), group: private_group) }
+
+ before do
+ user_mention = note.send(:model_user_mention)
+ mention_ids = {
+ mentioned_projects_ids: user_mention.mentioned_projects_ids.to_a << private_project.id,
+ mentioned_groups_ids: user_mention.mentioned_groups_ids.to_a << private_group.id
+ }
+ user_mention.update(mention_ids)
+
+ add_member(mega_user)
+ private_project.add_developer(mega_user)
+ private_group.add_developer(mega_user)
+ end
+
+ context 'when user has no access to some mentions' do
+ it 'filters out inaccessible mentions' do
+ expect(mentionable.referenced_projects(user)).to match_array([mentionable.project].compact) # epic.project is nil, and we want empty []
+ expect(mentionable.referenced_groups(user)).to match_array([group])
+ end
+ end
+
+ context 'when user has access to all mentions' do
+ it 'returns all mentions' do
+ expect(mentionable.referenced_projects(mega_user)).to match_array([mentionable.project, private_project].compact) # epic.project is nil, and we want empty []
+ expect(mentionable.referenced_groups(mega_user)).to match_array([group, private_group])
+ end
+ end
+ end
+ end
+end
+
+def add_member(user)
+ issuable_parent = if mentionable.is_a?(Epic)
+ mentionable.group
+ else
+ mentionable.project
+ end
+
+ issuable_parent&.add_developer(user)
+end
diff --git a/spec/support/shared_examples/models/user_mentions_shared_examples.rb b/spec/support/shared_examples/models/user_mentions_shared_examples.rb
new file mode 100644
index 00000000000..b94994ea712
--- /dev/null
+++ b/spec/support/shared_examples/models/user_mentions_shared_examples.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+shared_examples_for 'has user mentions' do
+ describe '#has_mentions?' do
+ context 'when no mentions' do
+ it 'returns false' do
+ expect(subject.mentioned_users_ids).to be nil
+ expect(subject.mentioned_projects_ids).to be nil
+ expect(subject.mentioned_groups_ids).to be nil
+ expect(subject.has_mentions?).to be false
+ end
+ end
+
+ context 'when mentioned_users_ids not null' do
+ subject { described_class.new(mentioned_users_ids: [1, 2, 3]) }
+
+ it 'returns true' do
+ expect(subject.has_mentions?).to be true
+ end
+ end
+
+ context 'when mentioned projects' do
+ subject { described_class.new(mentioned_projects_ids: [1, 2, 3]) }
+
+ it 'returns true' do
+ expect(subject.has_mentions?).to be true
+ end
+ end
+
+ context 'when mentioned groups' do
+ subject { described_class.new(mentioned_groups_ids: [1, 2, 3]) }
+
+ it 'returns true' do
+ expect(subject.has_mentions?).to be true
+ end
+ end
+ end
+end