diff options
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 |