Commit 27d91a62 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 5e11c9b7
...@@ -100,9 +100,6 @@ export default { ...@@ -100,9 +100,6 @@ export default {
hasOnlyOneJob(stage) { hasOnlyOneJob(stage) {
return stage.groups.length === 1; return stage.groups.length === 1;
}, },
hasDownstream(index, length) {
return index === length - 1 && this.hasTriggered;
},
hasUpstream(index) { hasUpstream(index) {
return index === 0 && this.hasTriggeredBy; return index === 0 && this.hasTriggeredBy;
}, },
...@@ -160,7 +157,6 @@ export default { ...@@ -160,7 +157,6 @@ export default {
:key="stage.name" :key="stage.name"
:class="{ :class="{
'has-upstream prepend-left-64': hasUpstream(index), 'has-upstream prepend-left-64': hasUpstream(index),
'has-downstream': hasDownstream(index, graph.length),
'has-only-one-job': hasOnlyOneJob(stage), 'has-only-one-job': hasOnlyOneJob(stage),
'append-right-46': shouldAddRightMargin(index), 'append-right-46': shouldAddRightMargin(index),
}" }"
......
<script> <script>
import LinkedPipeline from './linked_pipeline.vue'; import LinkedPipeline from './linked_pipeline.vue';
import { __ } from '~/locale';
export default { export default {
components: { components: {
...@@ -27,6 +28,9 @@ export default { ...@@ -27,6 +28,9 @@ export default {
}; };
return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`;
}, },
isUpstream() {
return this.columnTitle === __('Upstream');
},
}, },
}; };
</script> </script>
...@@ -34,13 +38,12 @@ export default { ...@@ -34,13 +38,12 @@ export default {
<template> <template>
<div :class="columnClass" class="stage-column linked-pipelines-column"> <div :class="columnClass" class="stage-column linked-pipelines-column">
<div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div> <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> <ul>
<linked-pipeline <linked-pipeline
v-for="(pipeline, index) in linkedPipelines" v-for="(pipeline, index) in linkedPipelines"
:key="pipeline.id" :key="pipeline.id"
:class="{ :class="{
'flat-connector-before': index === 0 && graphPosition === 'right',
active: pipeline.isExpanded, active: pipeline.isExpanded,
'left-connector': pipeline.isExpanded && graphPosition === 'left', 'left-connector': pipeline.isExpanded && graphPosition === 'left',
}" }"
......
...@@ -473,6 +473,7 @@ table.code { ...@@ -473,6 +473,7 @@ table.code {
text-align: right; text-align: right;
width: 50px; width: 50px;
position: relative; position: relative;
white-space: nowrap;
a { a {
transition: none; transition: none;
......
...@@ -61,6 +61,7 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController ...@@ -61,6 +61,7 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
message message
starts_at starts_at
target_path target_path
broadcast_type
)) ))
end end
end end
...@@ -9,6 +9,7 @@ class BroadcastMessage < ApplicationRecord ...@@ -9,6 +9,7 @@ class BroadcastMessage < ApplicationRecord
validates :message, presence: true validates :message, presence: true
validates :starts_at, presence: true validates :starts_at, presence: true
validates :ends_at, presence: true validates :ends_at, presence: true
validates :broadcast_type, presence: true
validates :color, allow_blank: true, color: true validates :color, allow_blank: true, color: true
validates :font, allow_blank: true, color: true validates :font, allow_blank: true, color: true
...@@ -17,35 +18,62 @@ class BroadcastMessage < ApplicationRecord ...@@ -17,35 +18,62 @@ class BroadcastMessage < ApplicationRecord
default_value_for :font, '#FFFFFF' default_value_for :font, '#FFFFFF'
CACHE_KEY = 'broadcast_message_current_json' 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 after_commit :flush_redis_cache
def self.current(current_path = nil) enum broadcast_type: {
messages = cache.fetch(CACHE_KEY, as: BroadcastMessage, expires_in: cache_expires_in) do banner: 1,
current_and_future_messages 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 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 def current_and_future_messages
# cache so we don't keep running this code all the time. where('ends_at > :now', now: Time.current).order_id_asc
cache.expire(CACHE_KEY) if now_or_future.empty? end
now_or_future.select(&:now?).select { |message| message.matches_current_path(current_path) } def cache
end Gitlab::JsonCache.new(cache_key_with_version: false)
end
def self.current_and_future_messages def cache_expires_in
where('ends_at > :now', now: Time.zone.now).order_id_asc 2.weeks
end end
def self.cache private
Gitlab::JsonCache.new(cache_key_with_version: false)
end 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 # If there are cached entries but none are to be displayed we'll purge the
2.weeks # 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 end
def active? def active?
...@@ -53,19 +81,19 @@ class BroadcastMessage < ApplicationRecord ...@@ -53,19 +81,19 @@ class BroadcastMessage < ApplicationRecord
end end
def started? def started?
Time.zone.now >= starts_at Time.current >= starts_at
end end
def ended? def ended?
ends_at < Time.zone.now ends_at < Time.current
end end
def now? def now?
(starts_at..ends_at).cover?(Time.zone.now) (starts_at..ends_at).cover?(Time.current)
end end
def future? def future?
starts_at > Time.zone.now starts_at > Time.current
end end
def now_or_future? def now_or_future?
...@@ -79,7 +107,9 @@ class BroadcastMessage < ApplicationRecord ...@@ -79,7 +107,9 @@ class BroadcastMessage < ApplicationRecord
end end
def flush_redis_cache 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
end end
......
...@@ -281,6 +281,10 @@ class Commit ...@@ -281,6 +281,10 @@ class Commit
project.notes.for_commit_id(self.id) project.notes.for_commit_id(self.id)
end end
def user_mentions
CommitUserMention.where(commit_id: self.id)
end
def discussion_notes def discussion_notes
notes.non_diff_notes notes.non_diff_notes
end end
......
# frozen_string_literal: true
class CommitUserMention < UserMention
belongs_to :note
end
...@@ -80,6 +80,66 @@ module Mentionable ...@@ -80,6 +80,66 @@ module Mentionable
all_references(current_user).users all_references(current_user).users
end 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) def directly_addressed_users(current_user = nil)
all_references(current_user).directly_addressed_users all_references(current_user).directly_addressed_users
end end
...@@ -171,6 +231,26 @@ module Mentionable ...@@ -171,6 +231,26 @@ module Mentionable
def mentionable_params def mentionable_params
{} {}
end 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 end
Mentionable.prepend_if_ee('EE::Mentionable') Mentionable.prepend_if_ee('EE::Mentionable')
...@@ -42,6 +42,7 @@ class Issue < ApplicationRecord ...@@ -42,6 +42,7 @@ class Issue < ApplicationRecord
has_many :issue_assignees has_many :issue_assignees
has_many :assignees, class_name: "User", through: :issue_assignees has_many :assignees, class_name: "User", through: :issue_assignees
has_many :zoom_meetings has_many :zoom_meetings
has_many :user_mentions, class_name: "IssueUserMention"
has_one :sentry_issue has_one :sentry_issue
validates :project, presence: true validates :project, presence: true
......
# frozen_string_literal: true
class IssueUserMention < UserMention
belongs_to :issue
belongs_to :note
end
...@@ -71,6 +71,7 @@ class MergeRequest < ApplicationRecord ...@@ -71,6 +71,7 @@ class MergeRequest < ApplicationRecord
has_many :merge_request_assignees has_many :merge_request_assignees
has_many :assignees, class_name: "User", through: :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 has_many :deployment_merge_requests
......
# frozen_string_literal: true
class MergeRequestUserMention < UserMention
belongs_to :merge_request
belongs_to :note
end
...@@ -499,8 +499,18 @@ class Note < ApplicationRecord ...@@ -499,8 +499,18 @@ class Note < ApplicationRecord
project project
end end
def user_mentions
noteable.user_mentions.where(note: self)
end
private 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) def system_note_viewable_by?(user)
return true unless system_note_metadata return true unless system_note_metadata
......
...@@ -37,6 +37,7 @@ class Snippet < ApplicationRecord ...@@ -37,6 +37,7 @@ class Snippet < ApplicationRecord
belongs_to :project belongs_to :project
has_many :notes, as: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent 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 delegate :name, :email, to: :author, prefix: true, allow_nil: true
...@@ -69,6 +70,8 @@ class Snippet < ApplicationRecord ...@@ -69,6 +70,8 @@ class Snippet < ApplicationRecord
scope :inc_author, -> { includes(:author) } scope :inc_author, -> { includes(:author) }
scope :inc_relations_for_view, -> { includes(author: :status) } scope :inc_relations_for_view, -> { includes(author: :status) }
attr_mentionable :description
participant :author participant :author
participant :notes_with_associations participant :notes_with_associations
......
# frozen_string_literal: true
class SnippetUserMention < UserMention
belongs_to :snippet
belongs_to :note
end
# 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
...@@ -21,7 +21,11 @@ class CreateSnippetService < BaseService ...@@ -21,7 +21,11 @@ class CreateSnippetService < BaseService
spam_check(snippet, current_user) 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 UserAgentDetailService.new(snippet, @request).create
Gitlab::UsageDataCounters::SnippetCounter.count(:create) Gitlab::UsageDataCounters::SnippetCounter.count(:create)
end end
......
...@@ -163,7 +163,11 @@ class IssuableBaseService < BaseService ...@@ -163,7 +163,11 @@ class IssuableBaseService < BaseService
before_create(issuable) 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) Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, is_update: false)
after_create(issuable) after_create(issuable)
...@@ -224,7 +228,11 @@ class IssuableBaseService < BaseService ...@@ -224,7 +228,11 @@ class IssuableBaseService < BaseService
update_project_counters = issuable.project && update_project_counter_caches?(issuable) update_project_counters = issuable.project && update_project_counter_caches?(issuable)
ensure_milestone_available(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]) Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: old_associations[:labels])
handle_changes(issuable, old_associations: old_associations) handle_changes(issuable, old_associations: old_associations)
......
...@@ -33,7 +33,11 @@ module Notes ...@@ -33,7 +33,11 @@ module Notes
NewNoteWorker.perform_async(note.id) NewNoteWorker.perform_async(note.id)
end 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? if note.part_of_discussion? && note.discussion.can_convert_to_discussion?
note.discussion.convert_to_discussion!(save: true) note.discussion.convert_to_discussion!(save: true)
end end
......
...@@ -7,7 +7,11 @@ module Notes ...@@ -7,7 +7,11 @@ module Notes
old_mentioned_users = note.mentioned_users(current_user).to_a 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 only_commands = false
......
...@@ -25,8 +25,12 @@ class UpdateSnippetService < BaseService ...@@ -25,8 +25,12 @@ class UpdateSnippetService < BaseService
snippet.assign_attributes(params) snippet.assign_attributes(params)
spam_check(snippet, current_user) spam_check(snippet, current_user)
snippet.save.tap do |succeeded| snippet_saved = snippet.with_transaction_returning_status do
Gitlab::UsageDataCounters::SnippetCounter.count(:update) if succeeded snippet.save && snippet.store_mentions!
end
if snippet_saved
Gitlab::UsageDataCounters::SnippetCounter.count(:update)
end end
end end
end end
...@@ -27,7 +27,7 @@ ...@@ -27,7 +27,7 @@
= search_filter_link 'snippet_blobs', _("Snippet Contents"), search: { snippets: true, group_id: nil, project_id: nil } = 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 } = search_filter_link 'snippet_titles', _("Titles and Filenames"), search: { snippets: true, group_id: nil, project_id: nil }
- else - else
= search_filter_link 'projects', _("Projects") = search_filter_link 'projects', _("Projects"), data: { qa_selector: 'projects_tab' }
= search_filter_link 'issues', _("Issues") = search_filter_link 'issues', _("Issues")
= search_filter_link 'merge_requests', _("Merge requests") = search_filter_link 'merge_requests', _("Merge requests")
= search_filter_link 'milestones', _("Milestones") = search_filter_link 'milestones', _("Milestones")
......
...@@ -26,7 +26,7 @@ ...@@ -26,7 +26,7 @@
= image_tag avatar_icon_for_user(project.creator, 48), class: "avatar s48", alt:'' = image_tag avatar_icon_for_user(project.creator, 48), class: "avatar s48", alt:''
- else - else
= project_icon(project, alt: '', class: 'avatar project-avatar s48', width: 48, height: 48) = 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 .flex-wrapper
.d-flex.align-items-center.flex-wrap.project-title .d-flex.align-items-center.flex-wrap.project-title
%h2.d-flex.prepend-top-8 %h2.d-flex.prepend-top-8
......
---
title: Store users, groups, projects mentioned in Markdown to DB tables
merge_request: 19088
author:
type: added
---
title: Add type to broadcast messages
merge_request: 21038
author:
type: added
---
title: Remove downstream pipeline connecting lines
merge_request: 21196
author:
type: removed
...@@ -141,6 +141,10 @@ class Gitlab::Seeder::Projects ...@@ -141,6 +141,10 @@ class Gitlab::Seeder::Projects
# the `after_commit` queue to ensure the job is run now. # the `after_commit` queue to ensure the job is run now.
project.send(:_run_after_commit_queue) project.send(:_run_after_commit_queue)
project.import_state.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 end
if project.valid? && project.valid_repo? if project.valid? && project.valid_repo?
......
# 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
...@@ -575,6 +575,7 @@ ActiveRecord::Schema.define(version: 2019_12_06_122926) do ...@@ -575,6 +575,7 @@ ActiveRecord::Schema.define(version: 2019_12_06_122926) do
t.text "message_html", null: false t.text "message_html", null: false
t.integer "cached_markdown_version" t.integer "cached_markdown_version"
t.string "target_path", limit: 255 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" t.index ["starts_at", "ends_at", "id"], name: "index_broadcast_messages_on_starts_at_and_ends_at_and_id"
end end
......
...@@ -135,6 +135,7 @@ The following job parameters can be defined inside a `default:` block: ...@@ -135,6 +135,7 @@ The following job parameters can be defined inside a `default:` block:
- [`services`](#services) - [`services`](#services)
- [`before_script`](#before_script-and-after_script) - [`before_script`](#before_script-and-after_script)
- [`after_script`](#before_script-and-after_script) - [`after_script`](#before_script-and-after_script)
- [`tags`](#tags)
- [`cache`](#cache) - [`cache`](#cache)
- [`retry`](#retry) - [`retry`](#retry)
- [`timeout`](#timeout) - [`timeout`](#timeout)
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
module Banzai module Banzai
module ReferenceParser module ReferenceParser
class MentionedUsersByGroupParser < BaseParser class MentionedGroupParser < BaseParser
GROUP_ATTR = 'data-group' GROUP_ATTR = 'data-group'
self.reference_type = :user self.reference_type = :user
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
module Banzai module Banzai
module ReferenceParser module ReferenceParser
class MentionedUsersByProjectParser < ProjectParser class MentionedProjectParser < ProjectParser
PROJECT_ATTR = 'data-project' PROJECT_ATTR = 'data-project'
self.reference_type = :user self.reference_type = :user
......
# 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
...@@ -15,7 +15,7 @@ module Gitlab ...@@ -15,7 +15,7 @@ module Gitlab
ALLOWED_KEYS = %i[before_script image services ALLOWED_KEYS = %i[before_script image services
after_script cache interruptible after_script cache interruptible
timeout retry].freeze timeout retry tags].freeze
validations do validations do
validates :config, allowed_keys: ALLOWED_KEYS validates :config, allowed_keys: ALLOWED_KEYS
...@@ -41,7 +41,7 @@ module Gitlab ...@@ -41,7 +41,7 @@ module Gitlab
description: 'Configure caching between build jobs.', description: 'Configure caching between build jobs.',
inherit: true inherit: true
entry :interruptible, Entry::Boolean, entry :interruptible, ::Gitlab::Config::Entry::Boolean,
description: 'Set jobs interruptible default value.', description: 'Set jobs interruptible default value.',
inherit: false inherit: false
...@@ -53,7 +53,12 @@ module Gitlab ...@@ -53,7 +53,12 @@ module Gitlab
description: 'Set retry default value.', description: 'Set retry default value.',
inherit: false 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 private
......
...@@ -36,7 +36,6 @@ module Gitlab ...@@ -36,7 +36,6 @@ module Gitlab
if: :has_rules? if: :has_rules?
with_options allow_nil: true do with_options allow_nil: true do
validates :tags, array_of_strings: true
validates :allow_failure, boolean: true validates :allow_failure, boolean: true
validates :parallel, numericality: { only_integer: true, validates :parallel, numericality: { only_integer: true,
greater_than_or_equal_to: 2, greater_than_or_equal_to: 2,
...@@ -97,7 +96,7 @@ module Gitlab ...@@ -97,7 +96,7 @@ module Gitlab
description: 'Services that will be used to execute this job.', description: 'Services that will be used to execute this job.',
inherit: true inherit: true
entry :interruptible, Entry::Boolean, entry :interruptible, ::Gitlab::Config::Entry::Boolean,
description: 'Set jobs interruptible value.', description: 'Set jobs interruptible value.',
inherit: true inherit: true
...@@ -109,6 +108,10 @@ module Gitlab ...@@ -109,6 +108,10 @@ module Gitlab
description: 'Retry configuration for this job.', description: 'Retry configuration for this job.',
inherit: true inherit: true
entry :tags, ::Gitlab::Config::Entry::ArrayOfStrings,
description: 'Set the tags.',
inherit: true
entry :only, Entry::Policy, entry :only, Entry::Policy,
description: 'Refs policy this job will be executed for.', description: 'Refs policy this job will be executed for.',
default: Entry::Policy::DEFAULT_ONLY, default: Entry::Policy::DEFAULT_ONLY,
......
# 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
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
module Gitlab module Gitlab
# Extract possible GFM references from an arbitrary String for further processing. # Extract possible GFM references from an arbitrary String for further processing.
class ReferenceExtractor < Banzai::ReferenceExtractor 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 merge_request snippet commit commit_range directly_addressed_user epic).freeze
attr_accessor :project, :current_user, :author attr_accessor :project, :current_user, :author
......
...@@ -5,6 +5,7 @@ module QA::Page ...@@ -5,6 +5,7 @@ module QA::Page
class Results < QA::Page::Base class Results < QA::Page::Base
view 'app/views/search/_category.html.haml' do view 'app/views/search/_category.html.haml' do
element :code_tab element :code_tab
element :projects_tab
end end
view 'app/views/search/results/_blob_data.html.haml' do view 'app/views/search/results/_blob_data.html.haml' do
...@@ -13,21 +14,33 @@ module QA::Page ...@@ -13,21 +14,33 @@ module QA::Page
element :file_text_content element :file_text_content
end end
view 'app/views/shared/projects/_project.html.haml' do
element :project
end
def switch_to_code def switch_to_code
click_element(:code_tab) click_element(:code_tab)
end end
def switch_to_projects
click_element(:projects_tab)
end
def has_file_in_project?(file_name, project_name) 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 end
def has_file_with_content?(file_name, file_text) def has_file_with_content?(file_name, file_text)
within_element_by_index :result_item_content, 0 do within_element_by_index(:result_item_content, 0) do
false unless has_element? :file_title_content, text: file_name 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
end end
def has_project?(project_name)
has_element?(:project, project_name: project_name)
end
end end
end end
end end
...@@ -19,8 +19,8 @@ module QA ...@@ -19,8 +19,8 @@ module QA
def api_support? def api_support?
respond_to?(:api_get_path) && respond_to?(:api_get_path) &&
respond_to?(:api_post_path) && (respond_to?(:api_post_path) && respond_to?(:api_post_body)) ||
respond_to?(:api_post_body) (respond_to?(:api_put_path) && respond_to?(:api_put_body))
end end
def fabricate_via_api! def fabricate_via_api!
...@@ -84,6 +84,18 @@ module QA ...@@ -84,6 +84,18 @@ module QA
process_api_response(parse_body(response)) process_api_response(parse_body(response))
end 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 def api_delete
url = Runtime::API::Request.new(api_client, api_delete_path).url url = Runtime::API::Request.new(api_client, api_delete_path).url
response = delete(url) response = delete(url)
......
...@@ -25,6 +25,23 @@ module QA ...@@ -25,6 +25,23 @@ module QA
end end
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 private
def enable_ip_limits def enable_ip_limits
......
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);
});
});
});
import Vue from 'vue'; import { mount } from '@vue/test-utils';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import EnvironmentTable from '~/environments/components/environments_table.vue';
import environmentTableComp 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', () => { describe('Environment table', () => {
let Component; let wrapper;
let vm;
const eeOnlyProps = { const factory = (options = {}) => {
canaryDeploymentFeatureId: 'canary_deployment', // This destroys any wrappers created before a nested call to factory reassigns it
showCanaryDeploymentCallout: true, if (wrapper && wrapper.destroy) {
userCalloutsPath: '/callouts', wrapper.destroy();
lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', }
helpCanaryDeploymentsPath: 'help/canary-deployments', wrapper = mount(EnvironmentTable, {
...options,
});
}; };
beforeEach(() => { beforeEach(() => {
Component = Vue.extend(environmentTableComp); factory({
propsData: {
environments: [folder],
canReadEnvironment: true,
...eeOnlyProps,
},
});
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); wrapper.destroy();
}); });
it('Should render a table', () => { it('Should render a table', () => {
const mockItem = { expect(wrapper.classes()).toContain('ci-table');
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');
}); });
describe('sortEnvironments', () => { describe('sortEnvironments', () => {
...@@ -73,15 +73,17 @@ describe('Environment table', () => { ...@@ -73,15 +73,17 @@ describe('Environment table', () => {
}, },
]; ];
vm = mountComponent(Component, { factory({
environments: mockItems, propsData: {
canReadEnvironment: true, environments: mockItems,
...eeOnlyProps, canReadEnvironment: true,
...eeOnlyProps,
},
}); });
const [old, newer, older, noDeploy] = mockItems; 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', () => { it('should push environments with no deployments to the bottom', () => {
...@@ -137,15 +139,17 @@ describe('Environment table', () => { ...@@ -137,15 +139,17 @@ describe('Environment table', () => {
}, },
]; ];
vm = mountComponent(Component, { factory({
environments: mockItems, propsData: {
canReadEnvironment: true, environments: mockItems,
...eeOnlyProps, canReadEnvironment: true,
...eeOnlyProps,
},
}); });
const [prod, review, staging] = mockItems; 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', () => { it('should sort environments by folder first', () => {
...@@ -174,15 +178,17 @@ describe('Environment table', () => { ...@@ -174,15 +178,17 @@ describe('Environment table', () => {
}, },
]; ];
vm = mountComponent(Component, { factory({
environments: mockItems, propsData: {
canReadEnvironment: true, environments: mockItems,
...eeOnlyProps, canReadEnvironment: true,
...eeOnlyProps,
},
}); });
const [old, newer, older] = mockItems; 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', () => { it('should break ties by name', () => {
...@@ -201,15 +207,17 @@ describe('Environment table', () => { ...@@ -201,15 +207,17 @@ describe('Environment table', () => {
}, },
]; ];
vm = mountComponent(Component, { factory({
environments: mockItems, propsData: {
canReadEnvironment: true, environments: mockItems,
...eeOnlyProps, canReadEnvironment: true,
...eeOnlyProps,
},
}); });
const [old, newer, older] = mockItems; 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', () => { ...@@ -250,19 +258,21 @@ describe('Environment table', () => {
const [production, review, staging] = mockItems; const [production, review, staging] = mockItems;
const [addcibuildstatus, master] = mockItems[1].children; const [addcibuildstatus, master] = mockItems[1].children;
vm = mountComponent(Component, { factory({
environments: mockItems, propsData: {
canReadEnvironment: true, environments: mockItems,
...eeOnlyProps, canReadEnvironment: true,
...eeOnlyProps,
},
}); });
expect(vm.sortedEnvironments.map(env => env.name)).toEqual([ expect(wrapper.vm.sortedEnvironments.map(env => env.name)).toEqual([
review.name, review.name,
staging.name, staging.name,
production.name, production.name,
]); ]);
expect(vm.sortedEnvironments[0].children).toEqual([master, addcibuildstatus]); expect(wrapper.vm.sortedEnvironments[0].children).toEqual([master, addcibuildstatus]);
}); });
}); });
}); });
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 };
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();
});
});
});
});
...@@ -35,4 +35,8 @@ describe('Linked Pipelines Column', () => { ...@@ -35,4 +35,8 @@ describe('Linked Pipelines Column', () => {
expect(linkedPipelineElements.length).toBe(props.linkedPipelines.length); expect(linkedPipelineElements.length).toBe(props.linkedPipelines.length);
}); });
it('renders cross project triangle when column is upstream', () => {
expect(vm.$el.querySelector('.cross-project-triangle')).toBeDefined();
});
}); });
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
describe Banzai::ReferenceParser::MentionedUsersByGroupParser do describe Banzai::ReferenceParser::MentionedGroupParser do
include ReferenceParserHelpers include ReferenceParserHelpers
let(:group) { create(:group, :private) } let(:group) { create(:group, :private) }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
describe Banzai::ReferenceParser::MentionedUsersByProjectParser do describe Banzai::ReferenceParser::MentionedProjectParser do
include ReferenceParserHelpers include ReferenceParserHelpers
let(:group) { create(:group, :private) } let(:group) { create(:group, :private) }
......
...@@ -27,7 +27,7 @@ describe Gitlab::Ci::Config::Entry::Default do ...@@ -27,7 +27,7 @@ describe Gitlab::Ci::Config::Entry::Default do
expect(described_class.nodes.keys) expect(described_class.nodes.keys)
.to match_array(%i[before_script image services .to match_array(%i[before_script image services
after_script cache interruptible after_script cache interruptible
timeout retry]) timeout retry tags])
end end
end end
end end
......
...@@ -24,7 +24,7 @@ describe Gitlab::Ci::Config::Entry::Job do ...@@ -24,7 +24,7 @@ describe Gitlab::Ci::Config::Entry::Job do
let(:result) do let(:result) do
%i[before_script script stage type after_script cache %i[before_script script stage type after_script cache
image services only except rules needs variables artifacts image services only except rules needs variables artifacts
environment coverage retry interruptible timeout] environment coverage retry interruptible timeout tags]
end end
it { is_expected.to match_array result } it { is_expected.to match_array result }
......
...@@ -1849,7 +1849,7 @@ module Gitlab ...@@ -1849,7 +1849,7 @@ module Gitlab
config = YAML.dump({ rspec: { script: "test", tags: "mysql" } }) config = YAML.dump({ rspec: { script: "test", tags: "mysql" } })
expect do expect do
Gitlab::Ci::YamlProcessor.new(config) 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 end
it "returns errors if before_script parameter is invalid" do it "returns errors if before_script parameter is invalid" do
...@@ -2197,7 +2197,7 @@ module Gitlab ...@@ -2197,7 +2197,7 @@ module Gitlab
context "when the tags parameter is invalid" do context "when the tags parameter is invalid" do
let(:content) { YAML.dump({ rspec: { script: "test", tags: "mysql" } }) } 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 end
context "when YAML content is empty" do context "when YAML content is empty" do
......
...@@ -34,6 +34,7 @@ issues: ...@@ -34,6 +34,7 @@ issues:
- zoom_meetings - zoom_meetings
- vulnerability_links - vulnerability_links
- related_vulnerabilities - related_vulnerabilities
- user_mentions
events: events:
- author - author
- project - project
...@@ -82,6 +83,7 @@ snippets: ...@@ -82,6 +83,7 @@ snippets:
- notes - notes
- award_emoji - award_emoji
- user_agent_detail - user_agent_detail
- user_mentions
releases: releases:
- author - author
- project - project
...@@ -142,6 +144,7 @@ merge_requests: ...@@ -142,6 +144,7 @@ merge_requests:
- description_versions - description_versions
- deployment_merge_requests - deployment_merge_requests
- deployments - deployments
- user_mentions
external_pull_requests: external_pull_requests:
- project - project
merge_request_diff: merge_request_diff:
...@@ -539,6 +542,7 @@ design: &design ...@@ -539,6 +542,7 @@ design: &design
- actions - actions
- versions - versions
- notes - notes
- user_mentions
designs: *design designs: *design
actions: actions:
- design - design
......
...@@ -20,65 +20,71 @@ describe BroadcastMessage do ...@@ -20,65 +20,71 @@ describe BroadcastMessage do
it { is_expected.to allow_value(triplet).for(:font) } it { is_expected.to allow_value(triplet).for(:font) }
it { is_expected.to allow_value(hex).for(:font) } it { is_expected.to allow_value(hex).for(:font) }
it { is_expected.not_to allow_value('000').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 end
describe '.current', :use_clean_rails_memory_store_caching do shared_examples 'time constrainted' do |broadcast_type|
it 'returns message if time match' do 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 end
it 'returns multiple messages if time match' do it 'returns multiple messages if time match' do
message1 = create(:broadcast_message) message1 = create(:broadcast_message, broadcast_type: broadcast_type)
message2 = create(:broadcast_message) 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 end
it 'returns empty list if time not come' do 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 end
it 'returns empty list if time has passed' do 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
end
shared_examples 'message cache' do |broadcast_type|
it 'caches the output of the query for two weeks' do 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 expect(described_class).to receive(:current_and_future_messages).and_call_original.twice
described_class.current subject.call
Timecop.travel(3.weeks) do Timecop.travel(3.weeks) do
described_class.current subject.call
end end
end end
it 'does not create new records' do 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 end
it 'includes messages that need to be displayed in the future' do it 'includes messages that need to be displayed in the future' do
create(:broadcast_message) create(:broadcast_message, broadcast_type: broadcast_type)
future = create( future = create(
:broadcast_message, :broadcast_message,
starts_at: Time.now + 10.minutes, 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 Timecop.travel(future.starts_at) do
expect(described_class.current.length).to eq(2) expect(subject.call.length).to eq(2)
end end
end end
...@@ -86,43 +92,90 @@ describe BroadcastMessage do ...@@ -86,43 +92,90 @@ describe BroadcastMessage do
create(:broadcast_message, :future) create(:broadcast_message, :future)
expect(Rails.cache).not_to receive(:delete).with(described_class::CACHE_KEY) 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
end
shared_examples "matches with current path" do |broadcast_type|
it 'returns message if it matches the target path' do 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 end
it 'returns message if part of the target path matches' do 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 end
it 'returns the message for empty target path' do 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 end
it 'returns the message if target path is nil' do 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 end
it 'does not return message if target path does not match' do 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 end
it 'does not return message if target path does not match when using wildcard' do 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
end end
...@@ -193,6 +246,8 @@ describe BroadcastMessage do ...@@ -193,6 +246,8 @@ describe BroadcastMessage do
message = create(:broadcast_message) message = create(:broadcast_message)
expect(Rails.cache).to receive(:delete).with(described_class::CACHE_KEY) 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 message.flush_redis_cache
end end
......
...@@ -166,6 +166,21 @@ describe Issue, "Mentionable" do ...@@ -166,6 +166,21 @@ describe Issue, "Mentionable" do
create(:issue, project: project, description: description, author: author) create(:issue, project: project, description: description, author: author)
end end
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 end
describe Commit, 'Mentionable' do describe Commit, 'Mentionable' do
...@@ -221,4 +236,56 @@ describe Commit, 'Mentionable' do ...@@ -221,4 +236,56 @@ describe Commit, 'Mentionable' do
end end
end 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 end
...@@ -12,6 +12,7 @@ describe Issue do ...@@ -12,6 +12,7 @@ describe Issue do
it { is_expected.to belong_to(:duplicated_to).class_name('Issue') } 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 belong_to(:closed_by).class_name('User') }
it { is_expected.to have_many(:assignees) } 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) } it { is_expected.to have_one(:sentry_issue) }
end end
......
...@@ -17,6 +17,7 @@ describe MergeRequest do ...@@ -17,6 +17,7 @@ describe MergeRequest do
it { is_expected.to belong_to(:merge_user).class_name("User") } 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(:assignees).through(:merge_request_assignees) }
it { is_expected.to have_many(:merge_request_diffs) } it { is_expected.to have_many(:merge_request_diffs) }
it { is_expected.to have_many(:user_mentions).class_name("MergeRequestUserMention") }
context 'for forks' do context 'for forks' do
let!(:project) { create(:project) } let!(:project) { create(:project) }
......
...@@ -18,6 +18,7 @@ describe Snippet do ...@@ -18,6 +18,7 @@ describe Snippet do
it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:project) }
it { is_expected.to have_many(:notes).dependent(:destroy) } 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(:award_emoji).dependent(:destroy) }
it { is_expected.to have_many(:user_mentions).class_name("SnippetUserMention") }
end end
describe 'validation' do describe 'validation' do
......
# 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
# 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
# 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
# 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
...@@ -195,3 +195,153 @@ shared_examples 'an editable mentionable' do ...@@ -195,3 +195,153 @@ shared_examples 'an editable mentionable' do
subject.create_new_cross_references!(author) subject.create_new_cross_references!(author)
end end
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
# 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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment