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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue28
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js13
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js7
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js1
-rw-r--r--app/assets/stylesheets/pages/prometheus.scss14
-rw-r--r--app/controllers/groups/application_controller.rb8
-rw-r--r--app/controllers/groups/milestones_controller.rb13
-rw-r--r--app/models/project.rb3
-rw-r--r--changelogs/unreleased/21801-migrate-epic-and-epic-notes-mentions-to-epic-user-mentions-table.yml5
-rw-r--r--changelogs/unreleased/24779-super-group-milestone-view-doesn-t-include-milestones-from-projects.yml5
-rw-r--r--config/initializers/console_message.rb13
-rw-r--r--config/sidekiq_queues.yml2
-rw-r--r--db/post_migrate/20191115115043_migrate_epic_mentions_to_db.rb36
-rw-r--r--db/post_migrate/20191115115522_migrate_epic_notes_mentions_to_db.rb45
-rw-r--r--db/schema.rb1
-rw-r--r--doc/development/README.md1
-rw-r--r--doc/development/renaming_features.md24
-rw-r--r--lib/gitlab/background_migration/user_mentions/create_resource_user_mention.rb42
-rw-r--r--lib/gitlab/background_migration/user_mentions/models/epic.rb50
-rw-r--r--lib/gitlab/background_migration/user_mentions/models/epic_user_mention.rb18
-rw-r--r--lib/gitlab/background_migration/user_mentions/models/isolated_mentionable.rb95
-rw-r--r--lib/gitlab/background_migration/user_mentions/models/note.rb65
-rw-r--r--spec/controllers/groups/milestones_controller_spec.rb47
-rw-r--r--spec/frontend/monitoring/components/dashboard_spec.js24
-rw-r--r--spec/frontend/monitoring/store/actions_spec.js72
-rw-r--r--spec/models/project_spec.rb19
-rw-r--r--spec/support/shared_examples/models/mentionable_shared_examples.rb10
28 files changed, 628 insertions, 35 deletions
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index c34623cf858..6178d1fab67 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -6,8 +6,11 @@ import {
GlButton,
GlDropdown,
GlDropdownItem,
+ GlDropdownHeader,
+ GlDropdownDivider,
GlFormGroup,
GlModal,
+ GlLoadingIcon,
GlSearchBoxByType,
GlModalDirective,
GlTooltipDirective,
@@ -41,7 +44,10 @@ export default {
Icon,
GlButton,
GlDropdown,
+ GlLoadingIcon,
GlDropdownItem,
+ GlDropdownHeader,
+ GlDropdownDivider,
GlSearchBoxByType,
GlFormGroup,
GlModal,
@@ -210,6 +216,7 @@ export default {
'useDashboardEndpoint',
'allDashboards',
'additionalPanelTypesEnabled',
+ 'environmentsLoading',
]),
...mapGetters('monitoringDashboard', ['getMetricStates', 'filteredEnvironments']),
firstDashboard() {
@@ -235,6 +242,9 @@ export default {
shouldRenderSearchableEnvironmentsDropdown() {
return this.glFeatures.searchableEnvironmentsDropdown;
},
+ shouldShowEnvironmentsDropdownNoMatchedMsg() {
+ return !this.environmentsLoading && this.filteredEnvironments.length === 0;
+ },
},
created() {
this.setEndpoints({
@@ -262,7 +272,7 @@ export default {
'setGettingStartedEmptyState',
'setEndpoints',
'setPanelGroupMetrics',
- 'setEnvironmentsSearchTerm',
+ 'filterEnvironments',
]),
updatePanels(key, panels) {
this.setPanelGroupMetrics({
@@ -305,7 +315,7 @@ export default {
this.formIsValid = isValid;
},
debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) {
- this.setEnvironmentsSearchTerm(searchTerm);
+ this.filterEnvironments(searchTerm);
}, 500),
submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit();
@@ -390,16 +400,22 @@ export default {
toggle-class="dropdown-menu-toggle"
menu-class="monitor-environment-dropdown-menu"
:text="currentEnvironmentName"
- :disabled="filteredEnvironments.length === 0"
>
<div class="d-flex flex-column overflow-hidden">
+ <gl-dropdown-header class="text-center">{{ __('Environment') }}</gl-dropdown-header>
+ <gl-dropdown-divider />
<gl-search-box-by-type
v-if="shouldRenderSearchableEnvironmentsDropdown"
ref="monitorEnvironmentsDropdownSearch"
class="m-2"
@input="debouncedEnvironmentsSearch"
/>
- <div class="flex-fill overflow-auto">
+ <gl-loading-icon
+ v-if="environmentsLoading"
+ ref="monitorEnvironmentsDropdownLoading"
+ :inline="true"
+ />
+ <div v-else class="flex-fill overflow-auto">
<gl-dropdown-item
v-for="environment in filteredEnvironments"
:key="environment.id"
@@ -411,11 +427,11 @@ export default {
</div>
<div
v-if="shouldRenderSearchableEnvironmentsDropdown"
- v-show="filteredEnvironments.length === 0"
+ v-show="shouldShowEnvironmentsDropdownNoMatchedMsg"
ref="monitorEnvironmentsDropdownMsg"
class="text-secondary no-matches-message"
>
- {{ s__('No matching results') }}
+ {{ __('No matching results') }}
</div>
</div>
</gl-dropdown>
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index e26e1457f55..29000475bd4 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -32,8 +32,9 @@ export const setEndpoints = ({ commit }, endpoints) => {
commit(types.SET_ENDPOINTS, endpoints);
};
-export const setEnvironmentsSearchTerm = ({ commit }, searchTerm) => {
- commit(types.SET_ENVIRONMENTS_SEARCH_TERM, searchTerm);
+export const filterEnvironments = ({ commit, dispatch }, searchTerm) => {
+ commit(types.SET_ENVIRONMENTS_FILTER, searchTerm);
+ dispatch('fetchEnvironmentsData');
};
export const setShowErrorBanner = ({ commit }, enabled) => {
@@ -56,6 +57,7 @@ export const receiveDeploymentsDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, data);
export const receiveDeploymentsDataFailure = ({ commit }) =>
commit(types.RECEIVE_DEPLOYMENTS_DATA_FAILURE);
+export const requestEnvironmentsData = ({ commit }) => commit(types.REQUEST_ENVIRONMENTS_DATA);
export const receiveEnvironmentsDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data);
export const receiveEnvironmentsDataFailure = ({ commit }) =>
@@ -189,8 +191,9 @@ export const fetchDeploymentsData = ({ state, dispatch }) => {
});
};
-export const fetchEnvironmentsData = ({ state, dispatch }) =>
- gqClient
+export const fetchEnvironmentsData = ({ state, dispatch }) => {
+ dispatch('requestEnvironmentsData');
+ return gqClient
.mutate({
mutation: getEnvironments,
variables: {
@@ -207,12 +210,14 @@ export const fetchEnvironmentsData = ({ state, dispatch }) =>
s__('Metrics|There was an error fetching the environments data, please try again'),
);
}
+
dispatch('receiveEnvironmentsDataSuccess', environments);
})
.catch(() => {
dispatch('receiveEnvironmentsDataFailure');
createFlash(s__('Metrics|There was an error getting environments information.'));
});
+};
/**
* Set a new array of metrics to a panel group
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
index 73d402ac6df..bdfaf42b35c 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -22,4 +22,4 @@ export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE';
export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER';
export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS';
-export const SET_ENVIRONMENTS_SEARCH_TERM = 'SET_ENVIRONMENTS_SEARCH_TERM';
+export const SET_ENVIRONMENTS_FILTER = 'SET_ENVIRONMENTS_FILTER';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index f0390bfc636..2a86a6a26d8 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -123,10 +123,15 @@ export default {
[types.RECEIVE_DEPLOYMENTS_DATA_FAILURE](state) {
state.deploymentData = [];
},
+ [types.REQUEST_ENVIRONMENTS_DATA](state) {
+ state.environmentsLoading = true;
+ },
[types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, environments) {
+ state.environmentsLoading = false;
state.environments = environments;
},
[types.RECEIVE_ENVIRONMENTS_DATA_FAILURE](state) {
+ state.environmentsLoading = false;
state.environments = [];
},
@@ -195,7 +200,7 @@ export default {
const panelGroup = state.dashboard.panel_groups.find(pg => payload.key === pg.key);
panelGroup.panels = payload.panels;
},
- [types.SET_ENVIRONMENTS_SEARCH_TERM](state, searchTerm) {
+ [types.SET_ENVIRONMENTS_FILTER](state, searchTerm) {
state.environmentsSearchTerm = searchTerm;
},
};
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index 2a2a7c9c88d..9d3227e8aae 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -15,6 +15,7 @@ export default () => ({
deploymentData: [],
environments: [],
environmentsSearchTerm: '',
+ environmentsLoading: false,
allDashboards: [],
currentDashboard: null,
projectPath: null,
diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss
index 3e6313173b8..7269effd38d 100644
--- a/app/assets/stylesheets/pages/prometheus.scss
+++ b/app/assets/stylesheets/pages/prometheus.scss
@@ -46,6 +46,20 @@
}
}
+.prometheus-graphs-header {
+ .monitor-environment-dropdown-menu {
+ &.show {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+
+ .no-matches-message {
+ padding: $gl-padding-8 $gl-padding-12;
+ }
+ }
+}
+
.prometheus-panel {
margin-top: 20px;
}
diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb
index d03a50f6f77..0760bdf1e01 100644
--- a/app/controllers/groups/application_controller.rb
+++ b/app/controllers/groups/application_controller.rb
@@ -20,6 +20,14 @@ class Groups::ApplicationController < ApplicationController
@projects ||= GroupProjectsFinder.new(group: group, current_user: current_user).execute
end
+ def group_projects_with_subgroups
+ @group_projects_with_subgroups ||= GroupProjectsFinder.new(
+ group: group,
+ current_user: current_user,
+ options: { include_subgroups: true }
+ ).execute
+ end
+
def authorize_admin_group!
unless can?(current_user, :admin_group, group)
return render_404
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index 7eba73daa3c..a478e9fffb8 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -103,8 +103,15 @@ class Groups::MilestonesController < Groups::ApplicationController
end
def group_projects_with_access
- group_projects.with_issues_available_for_user(current_user)
- .or(group_projects.with_merge_requests_available_for_user(current_user))
+ group_projects_with_subgroups.with_issues_or_mrs_available_for_user(current_user)
+ end
+
+ def group_ids(include_ancestors: false)
+ if include_ancestors
+ group.self_and_hierarchy.public_or_visible_to_user(current_user).select(:id)
+ else
+ group.self_and_descendants.public_or_visible_to_user(current_user).select(:id)
+ end
end
def milestone
@@ -119,7 +126,7 @@ class Groups::MilestonesController < Groups::ApplicationController
end
def search_params
- groups = request.format.json? ? group.self_and_ancestors.select(:id) : group.id
+ groups = request.format.json? ? group_ids(include_ancestors: true) : group_ids
params.permit(:state, :search_title).merge(group_ids: groups)
end
diff --git a/app/models/project.rb b/app/models/project.rb
index b2de2b32ae0..f8c201d73e5 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -453,6 +453,9 @@ class Project < ApplicationRecord
scope :with_issues_enabled, -> { with_feature_enabled(:issues) }
scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) }
scope :with_merge_requests_available_for_user, ->(current_user) { with_feature_available_for_user(:merge_requests, current_user) }
+ scope :with_issues_or_mrs_available_for_user, -> (user) do
+ with_issues_available_for_user(user).or(with_merge_requests_available_for_user(user))
+ end
scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) }
scope :with_remote_mirrors, -> { joins(:remote_mirrors).where(remote_mirrors: { enabled: true }).distinct }
scope :with_limit, -> (maximum) { limit(maximum) }
diff --git a/changelogs/unreleased/21801-migrate-epic-and-epic-notes-mentions-to-epic-user-mentions-table.yml b/changelogs/unreleased/21801-migrate-epic-and-epic-notes-mentions-to-epic-user-mentions-table.yml
new file mode 100644
index 00000000000..168a8c7ac12
--- /dev/null
+++ b/changelogs/unreleased/21801-migrate-epic-and-epic-notes-mentions-to-epic-user-mentions-table.yml
@@ -0,0 +1,5 @@
+---
+title: Migrate epic, epic notes mentions to respective DB table
+merge_request: 22333
+author:
+type: changed
diff --git a/changelogs/unreleased/24779-super-group-milestone-view-doesn-t-include-milestones-from-projects.yml b/changelogs/unreleased/24779-super-group-milestone-view-doesn-t-include-milestones-from-projects.yml
new file mode 100644
index 00000000000..7e4a7c49511
--- /dev/null
+++ b/changelogs/unreleased/24779-super-group-milestone-view-doesn-t-include-milestones-from-projects.yml
@@ -0,0 +1,5 @@
+---
+title: Include milestones from subgroups in the list of Group Milestones.
+merge_request: 22922
+author:
+type: fixed
diff --git a/config/initializers/console_message.rb b/config/initializers/console_message.rb
index 04c109aa844..74d98dec79a 100644
--- a/config/initializers/console_message.rb
+++ b/config/initializers/console_message.rb
@@ -6,12 +6,15 @@ if defined?(Rails::Console)
puts '-' * 80
puts " GitLab:".ljust(justify) + "#{Gitlab::VERSION} (#{Gitlab.revision})"
puts " GitLab Shell:".ljust(justify) + "#{Gitlab::VersionInfo.parse(Gitlab::Shell.new.version)}"
- puts " #{Gitlab::Database.human_adapter_name}:".ljust(justify) + Gitlab::Database.version
- Gitlab.ee do
- if Gitlab::Geo.enabled?
- puts " Geo enabled:".ljust(justify) + 'yes'
- puts " Geo server:".ljust(justify) + EE::GeoHelper.current_node_human_status
+ if Gitlab::Database.exists?
+ puts " #{Gitlab::Database.human_adapter_name}:".ljust(justify) + Gitlab::Database.version
+
+ Gitlab.ee do
+ if Gitlab::Geo.connected? && Gitlab::Geo.enabled?
+ puts " Geo enabled:".ljust(justify) + 'yes'
+ puts " Geo server:".ljust(justify) + EE::GeoHelper.current_node_human_status
+ end
end
end
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index c184551f510..8974b646cd9 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -152,6 +152,8 @@
- 1
- - object_storage
- 1
+- - package_repositories
+ - 1
- - pages
- 1
- - pages_domain_ssl_renewal
diff --git a/db/post_migrate/20191115115043_migrate_epic_mentions_to_db.rb b/db/post_migrate/20191115115043_migrate_epic_mentions_to_db.rb
new file mode 100644
index 00000000000..97f2e568a7e
--- /dev/null
+++ b/db/post_migrate/20191115115043_migrate_epic_mentions_to_db.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+class MigrateEpicMentionsToDb < ActiveRecord::Migration[5.2]
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ DELAY = 2.minutes.to_i
+ BATCH_SIZE = 10000
+ MIGRATION = 'UserMentions::CreateResourceUserMention'
+
+ JOIN = "LEFT JOIN epic_user_mentions on epics.id = epic_user_mentions.epic_id"
+ QUERY_CONDITIONS = "(description like '%@%' OR title like '%@%') AND epic_user_mentions.epic_id is null"
+
+ class Epic < ActiveRecord::Base
+ include EachBatch
+
+ self.table_name = 'epics'
+ end
+
+ def up
+ return unless Gitlab.ee?
+
+ Epic
+ .joins(JOIN)
+ .where(QUERY_CONDITIONS)
+ .each_batch(of: BATCH_SIZE) do |batch, index|
+ range = batch.pluck(Arel.sql('MIN(epics.id)'), Arel.sql('MAX(epics.id)')).first
+ BackgroundMigrationWorker.perform_in(index * DELAY, MIGRATION, ['Epic', JOIN, QUERY_CONDITIONS, false, *range])
+ end
+ end
+
+ def down
+ # no-op
+ end
+end
diff --git a/db/post_migrate/20191115115522_migrate_epic_notes_mentions_to_db.rb b/db/post_migrate/20191115115522_migrate_epic_notes_mentions_to_db.rb
new file mode 100644
index 00000000000..e0b3c36b57d
--- /dev/null
+++ b/db/post_migrate/20191115115522_migrate_epic_notes_mentions_to_db.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class MigrateEpicNotesMentionsToDb < ActiveRecord::Migration[5.2]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ DELAY = 2.minutes.to_i
+ BATCH_SIZE = 10000
+ MIGRATION = 'UserMentions::CreateResourceUserMention'
+
+ INDEX_NAME = 'epic_mentions_temp_index'
+ INDEX_CONDITION = "note LIKE '%@%'::text AND notes.noteable_type = 'Epic'"
+ QUERY_CONDITIONS = "#{INDEX_CONDITION} AND epic_user_mentions.epic_id IS NULL"
+ JOIN = 'LEFT JOIN epic_user_mentions ON notes.id = epic_user_mentions.note_id'
+
+ class Note < ActiveRecord::Base
+ include EachBatch
+
+ self.table_name = 'notes'
+ end
+
+ def up
+ return unless Gitlab.ee?
+
+ # create temporary index for notes with mentions, may take well over 1h
+ add_concurrent_index(:notes, :id, where: INDEX_CONDITION, name: INDEX_NAME)
+
+ Note
+ .joins(JOIN)
+ .where(QUERY_CONDITIONS)
+ .each_batch(of: BATCH_SIZE) do |batch, index|
+ range = batch.pluck(Arel.sql('MIN(notes.id)'), Arel.sql('MAX(notes.id)')).first
+ BackgroundMigrationWorker.perform_in(index * DELAY, MIGRATION, ['Epic', JOIN, QUERY_CONDITIONS, true, *range])
+ end
+ end
+
+ def down
+ # no-op
+ # temporary index is to be dropped in a different migration in an upcoming release:
+ # https://gitlab.com/gitlab-org/gitlab/issues/196842
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 3248768aa0b..f48ead215bc 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -2778,6 +2778,7 @@ ActiveRecord::Schema.define(version: 2020_01_27_090233) do
t.index ["commit_id"], name: "index_notes_on_commit_id"
t.index ["created_at"], name: "index_notes_on_created_at"
t.index ["discussion_id"], name: "index_notes_on_discussion_id"
+ t.index ["id"], name: "epic_mentions_temp_index", where: "((note ~~ '%@%'::text) AND ((noteable_type)::text = 'Epic'::text))"
t.index ["line_code"], name: "index_notes_on_line_code"
t.index ["note"], name: "index_notes_on_note_trigram", opclass: :gin_trgm_ops, using: :gin
t.index ["noteable_id", "noteable_type"], name: "index_notes_on_noteable_id_and_noteable_type"
diff --git a/doc/development/README.md b/doc/development/README.md
index 5338db38430..84d4fb5519f 100644
--- a/doc/development/README.md
+++ b/doc/development/README.md
@@ -137,6 +137,7 @@ Complementary reads:
- [Database helper modules](database_helpers.md)
- [Code comments](code_comments.md)
- [Creating enums](creating_enums.md)
+- [Renaming features](renaming_features.md)
### Case studies
diff --git a/doc/development/renaming_features.md b/doc/development/renaming_features.md
new file mode 100644
index 00000000000..ca204bd420e
--- /dev/null
+++ b/doc/development/renaming_features.md
@@ -0,0 +1,24 @@
+# Renaming features
+
+Sometimes the business asks to change the name of a feature. Broadly speaking, there are 2 approaches to that task. They basically trade between immediate effort and future complexity/bug risk:
+
+- Complete, rename everything in the repo.
+ - Pros: does not increase code complexity.
+ - Cons: more work to execute, and higher risk of immediate bugs.
+- Façade, rename as little as possible; only the user-facing content like interfaces,
+ documentation, error messages, etc.
+ - Pros: less work to execute.
+ - Cons: increases code complexity, creating higher risk of future bugs.
+
+## When to choose the façade approach
+
+The more of the following that are true, the more likely you should choose the façade approach:
+
+- You are not confident the new name is permanent.
+- The feature is susceptible to bugs (large, complex, needing refactor, etc).
+- The renaming will be difficult to review (feature spans many lines/files/repos).
+- The renaming will be disruptive in some way (database table renaming).
+
+## Consider a façade-first approach
+
+The façade approach is not necessarily a final step. It can (and possibly *should*) be treated as the first step, where later iterations will accomplish the complete rename.
diff --git a/lib/gitlab/background_migration/user_mentions/create_resource_user_mention.rb b/lib/gitlab/background_migration/user_mentions/create_resource_user_mention.rb
new file mode 100644
index 00000000000..e951b44b036
--- /dev/null
+++ b/lib/gitlab/background_migration/user_mentions/create_resource_user_mention.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+# rubocop:disable Style/Documentation
+
+module Gitlab
+ module BackgroundMigration
+ module UserMentions
+ class CreateResourceUserMention
+ # Resources that have mentions to be migrated:
+ # issue, merge_request, epic, commit, snippet, design
+
+ BULK_INSERT_SIZE = 5000
+ ISOLATION_MODULE = 'Gitlab::BackgroundMigration::UserMentions::Models'
+
+ def perform(resource_model, join, conditions, with_notes, start_id, end_id)
+ resource_model = "#{ISOLATION_MODULE}::#{resource_model}".constantize if resource_model.is_a?(String)
+ model = with_notes ? "#{ISOLATION_MODULE}::Note".constantize : resource_model
+ resource_user_mention_model = resource_model.user_mention_model
+
+ records = model.joins(join).where(conditions).where(id: start_id..end_id)
+
+ records.in_groups_of(BULK_INSERT_SIZE, false).each do |records|
+ mentions = []
+ records.each do |record|
+ mentions << record.build_mention_values
+ end
+
+ no_quote_columns = [:note_id]
+ no_quote_columns << resource_user_mention_model.resource_foreign_key
+
+ Gitlab::Database.bulk_insert(
+ resource_user_mention_model.table_name,
+ mentions,
+ return_ids: true,
+ disable_quote: no_quote_columns,
+ on_conflict: :do_nothing
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/user_mentions/models/epic.rb b/lib/gitlab/background_migration/user_mentions/models/epic.rb
new file mode 100644
index 00000000000..019d8f0ea8b
--- /dev/null
+++ b/lib/gitlab/background_migration/user_mentions/models/epic.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+# rubocop:disable Style/Documentation
+
+module Gitlab
+ module BackgroundMigration
+ module UserMentions
+ module Models
+ class Epic < ActiveRecord::Base
+ include IsolatedMentionable
+ include CacheMarkdownField
+
+ attr_mentionable :title, pipeline: :single_line
+ attr_mentionable :description
+ cache_markdown_field :title, pipeline: :single_line
+ cache_markdown_field :description, issuable_state_filter_enabled: true
+
+ self.table_name = 'epics'
+
+ belongs_to :author, class_name: "User"
+ belongs_to :project
+ belongs_to :group
+
+ def self.user_mention_model
+ Gitlab::BackgroundMigration::UserMentions::Models::EpicUserMention
+ end
+
+ def user_mention_model
+ self.class.user_mention_model
+ end
+
+ def project
+ nil
+ end
+
+ def mentionable_params
+ { group: group, label_url_method: :group_epics_url }
+ end
+
+ def user_mention_resource_id
+ id
+ end
+
+ def user_mention_note_id
+ 'NULL'
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/user_mentions/models/epic_user_mention.rb b/lib/gitlab/background_migration/user_mentions/models/epic_user_mention.rb
new file mode 100644
index 00000000000..4e3ce9bf3a7
--- /dev/null
+++ b/lib/gitlab/background_migration/user_mentions/models/epic_user_mention.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+# rubocop:disable Style/Documentation
+
+module Gitlab
+ module BackgroundMigration
+ module UserMentions
+ module Models
+ class EpicUserMention < ActiveRecord::Base
+ self.table_name = 'epic_user_mentions'
+
+ def self.resource_foreign_key
+ :epic_id
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/user_mentions/models/isolated_mentionable.rb b/lib/gitlab/background_migration/user_mentions/models/isolated_mentionable.rb
new file mode 100644
index 00000000000..40aab896212
--- /dev/null
+++ b/lib/gitlab/background_migration/user_mentions/models/isolated_mentionable.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module BackgroundMigration
+ module UserMentions
+ module Models
+ # == IsolatedMentionable concern
+ #
+ # Shortcutted for isolation version of Mentionable to be used in mentions migrations
+ #
+ module IsolatedMentionable
+ extend ::ActiveSupport::Concern
+
+ class_methods do
+ # Indicate which attributes of the Mentionable to search for GFM references.
+ def attr_mentionable(attr, options = {})
+ attr = attr.to_s
+ mentionable_attrs << [attr, options]
+ end
+ end
+
+ included do
+ # Accessor for attributes marked mentionable.
+ cattr_accessor :mentionable_attrs, instance_accessor: false do
+ []
+ end
+
+ if self < Participable
+ participant -> (user, ext) { all_references(user, extractor: ext) }
+ end
+ end
+
+ def all_references(current_user = nil, extractor: nil)
+ # Use custom extractor if it's passed in the function parameters.
+ if extractor
+ extractors[current_user] = extractor
+ else
+ extractor = extractors[current_user] ||= ::Gitlab::ReferenceExtractor.new(project, current_user)
+
+ extractor.reset_memoized_values
+ end
+
+ self.class.mentionable_attrs.each do |attr, options|
+ text = __send__(attr) # rubocop:disable GitlabSecurity/PublicSend
+ options = options.merge(
+ cache_key: [self, attr],
+ author: author,
+ skip_project_check: skip_project_check?
+ ).merge(mentionable_params)
+
+ cached_html = self.try(:updated_cached_html_for, attr.to_sym)
+ options[:rendered] = cached_html if cached_html
+
+ extractor.analyze(text, options)
+ end
+
+ extractor
+ end
+
+ def extractors
+ @extractors ||= {}
+ end
+
+ def skip_project_check?
+ false
+ end
+
+ def build_mention_values
+ refs = all_references(author)
+
+ {
+ "#{self.user_mention_model.resource_foreign_key}": user_mention_resource_id,
+ note_id: user_mention_note_id,
+ mentioned_users_ids: array_to_sql(refs.mentioned_users.pluck(:id)),
+ mentioned_projects_ids: array_to_sql(refs.mentioned_projects.pluck(:id)),
+ mentioned_groups_ids: array_to_sql(refs.mentioned_groups.pluck(:id))
+ }
+ end
+
+ def array_to_sql(ids_array)
+ return unless ids_array.present?
+
+ '{' + ids_array.join(", ") + '}'
+ end
+
+ private
+
+ def mentionable_params
+ {}
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/user_mentions/models/note.rb b/lib/gitlab/background_migration/user_mentions/models/note.rb
new file mode 100644
index 00000000000..c2828202907
--- /dev/null
+++ b/lib/gitlab/background_migration/user_mentions/models/note.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+# rubocop:disable Style/Documentation
+
+module Gitlab
+ module BackgroundMigration
+ module UserMentions
+ module Models
+ class Note < ActiveRecord::Base
+ include IsolatedMentionable
+ include CacheMarkdownField
+
+ self.table_name = 'notes'
+ self.inheritance_column = :_type_disabled
+
+ attr_mentionable :note, pipeline: :note
+ cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true
+
+ belongs_to :author, class_name: "User"
+ belongs_to :noteable, polymorphic: true
+ belongs_to :project
+
+ def user_mention_model
+ "#{CreateResourceUserMention::ISOLATION_MODULE}::#{noteable.class}".constantize.user_mention_model
+ end
+
+ def for_personal_snippet?
+ noteable.class.name == 'PersonalSnippet'
+ end
+
+ def for_project_noteable?
+ !for_personal_snippet?
+ end
+
+ def skip_project_check?
+ !for_project_noteable?
+ end
+
+ def for_epic?
+ noteable.class.name == 'Epic'
+ end
+
+ def user_mention_resource_id
+ noteable_id || commit_id
+ end
+
+ def user_mention_note_id
+ id
+ end
+
+ private
+
+ def mentionable_params
+ return super unless for_epic?
+
+ super.merge(banzai_context_params)
+ end
+
+ def banzai_context_params
+ { group: noteable.group, label_url_method: :group_epics_url }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/groups/milestones_controller_spec.rb b/spec/controllers/groups/milestones_controller_spec.rb
index 8fb9f0c516c..068ed7fd380 100644
--- a/spec/controllers/groups/milestones_controller_spec.rb
+++ b/spec/controllers/groups/milestones_controller_spec.rb
@@ -130,6 +130,40 @@ describe Groups::MilestonesController do
end
end
end
+
+ context 'when subgroup milestones are present' do
+ let(:subgroup) { create(:group, :private, parent: group) }
+ let(:sub_project) { create(:project, :private, group: subgroup) }
+ let!(:group_milestone) { create(:milestone, group: group, title: 'Group milestone') }
+ let!(:sub_project_milestone) { create(:milestone, project: sub_project, title: 'Sub Project Milestone') }
+ let!(:subgroup_milestone) { create(:milestone, title: 'Subgroup Milestone', group: subgroup) }
+
+ it 'shows subgroup milestones that user has access to' do
+ get :index, params: { group_id: group.to_param }
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response.body).to include(group_milestone.title)
+ expect(response.body).to include(sub_project_milestone.title)
+ expect(response.body).to include(subgroup_milestone.title)
+ end
+
+ context 'when user has no access to subgroups' do
+ let(:non_member) { create(:user) }
+
+ before do
+ sign_in(non_member)
+ end
+
+ it 'does not show subgroup milestones' do
+ get :index, params: { group_id: group.to_param }
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response.body).to include(group_milestone.title)
+ expect(response.body).not_to include(sub_project_milestone.title)
+ expect(response.body).not_to include(subgroup_milestone.title)
+ end
+ end
+ end
end
context 'as JSON' do
@@ -149,6 +183,19 @@ describe Groups::MilestonesController do
expect(response.content_type).to eq 'application/json'
end
+ context 'with subgroup milestones' do
+ it 'lists descendants group milestones' do
+ subgroup = create(:group, :public, parent: group)
+ create(:milestone, group: subgroup, title: 'subgroup milestone')
+
+ get :index, params: { group_id: group.to_param }, format: :json
+ milestones = json_response
+
+ expect(milestones.count).to eq(3)
+ expect(milestones.second["title"]).to eq("subgroup milestone")
+ end
+ end
+
context 'for a subgroup' do
let(:subgroup) { create(:group, parent: group) }
diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js
index 1fd30757937..29338ee204e 100644
--- a/spec/frontend/monitoring/components/dashboard_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_spec.js
@@ -31,10 +31,7 @@ describe('Dashboard', () => {
const findEnvironmentsDropdown = () => wrapper.find({ ref: 'monitorEnvironmentsDropdown' });
const findAllEnvironmentsDropdownItems = () => findEnvironmentsDropdown().findAll(GlDropdownItem);
const setSearchTerm = searchTerm => {
- wrapper.vm.$store.commit(
- `monitoringDashboard/${types.SET_ENVIRONMENTS_SEARCH_TERM}`,
- searchTerm,
- );
+ wrapper.vm.$store.commit(`monitoringDashboard/${types.SET_ENVIRONMENTS_FILTER}`, searchTerm);
};
const createShallowWrapper = (props = {}, options = {}) => {
@@ -313,6 +310,25 @@ describe('Dashboard', () => {
expect(wrapper.find({ ref: 'monitorEnvironmentsDropdownMsg' }).isVisible()).toBe(true);
});
});
+
+ it('shows loading element when environments fetch is still loading', () => {
+ wrapper.vm.$store.commit(`monitoringDashboard/${types.REQUEST_ENVIRONMENTS_DATA}`);
+
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(wrapper.find({ ref: 'monitorEnvironmentsDropdownLoading' }).exists()).toBe(true);
+ })
+ .then(() => {
+ wrapper.vm.$store.commit(
+ `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`,
+ environmentData,
+ );
+ })
+ .then(() => {
+ expect(wrapper.find({ ref: 'monitorEnvironmentsDropdownLoading' }).exists()).toBe(false);
+ });
+ });
});
describe('drag and drop function', () => {
diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js
index 7ac00bee99c..11d3109fcd1 100644
--- a/spec/frontend/monitoring/store/actions_spec.js
+++ b/spec/frontend/monitoring/store/actions_spec.js
@@ -17,10 +17,12 @@ import {
fetchPrometheusMetrics,
fetchPrometheusMetric,
setEndpoints,
+ filterEnvironments,
setGettingStartedEmptyState,
duplicateSystemDashboard,
} from '~/monitoring/stores/actions';
import { gqClient, parseEnvironmentsResponse } from '~/monitoring/stores/utils';
+import getEnvironments from '~/monitoring/queries/getEnvironments.query.graphql';
import storeState from '~/monitoring/stores/state';
import {
deploymentData,
@@ -105,12 +107,70 @@ describe('Monitoring store actions', () => {
.catch(done.fail);
});
});
+
describe('fetchEnvironmentsData', () => {
- it('commits RECEIVE_ENVIRONMENTS_DATA_SUCCESS on error', () => {
- const dispatch = jest.fn();
- const { state } = store;
- state.projectPath = '/gitlab-org/gitlab-test';
+ const dispatch = jest.fn();
+ const { state } = store;
+ state.projectPath = 'gitlab-org/gitlab-test';
+ afterEach(() => {
+ resetStore(store);
+ jest.restoreAllMocks();
+ });
+
+ it('setting SET_ENVIRONMENTS_FILTER should dispatch fetchEnvironmentsData', () => {
+ jest.spyOn(gqClient, 'mutate').mockReturnValue(
+ Promise.resolve({
+ data: {
+ project: {
+ data: {
+ environments: [],
+ },
+ },
+ },
+ }),
+ );
+
+ return testAction(
+ filterEnvironments,
+ {},
+ state,
+ [
+ {
+ type: 'SET_ENVIRONMENTS_FILTER',
+ payload: {},
+ },
+ ],
+ [
+ {
+ type: 'fetchEnvironmentsData',
+ },
+ ],
+ );
+ });
+
+ it('fetch environments data call takes in search param', () => {
+ const mockMutate = jest.spyOn(gqClient, 'mutate');
+ const searchTerm = 'Something';
+ const mutationVariables = {
+ mutation: getEnvironments,
+ variables: {
+ projectPath: state.projectPath,
+ search: searchTerm,
+ },
+ };
+ state.environmentsSearchTerm = searchTerm;
+ mockMutate.mockReturnValue(Promise.resolve());
+
+ return fetchEnvironmentsData({
+ state,
+ dispatch,
+ }).then(() => {
+ expect(mockMutate).toHaveBeenCalledWith(mutationVariables);
+ });
+ });
+
+ it('commits RECEIVE_ENVIRONMENTS_DATA_SUCCESS on success', () => {
jest.spyOn(gqClient, 'mutate').mockReturnValue(
Promise.resolve({
data: {
@@ -135,9 +195,6 @@ describe('Monitoring store actions', () => {
});
it('commits RECEIVE_ENVIRONMENTS_DATA_FAILURE on error', () => {
- const dispatch = jest.fn();
- const { state } = store;
- state.projectPath = '/gitlab-org/gitlab-test';
jest.spyOn(gqClient, 'mutate').mockReturnValue(Promise.reject());
return fetchEnvironmentsData({
@@ -148,6 +205,7 @@ describe('Monitoring store actions', () => {
});
});
});
+
describe('Set endpoints', () => {
let mockedState;
beforeEach(() => {
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 0bb19609e27..a1c38a3e668 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -5587,6 +5587,25 @@ describe Project do
end
end
+ describe 'with_issues_or_mrs_available_for_user' do
+ before do
+ Project.delete_all
+ end
+
+ it 'returns correct projects' do
+ user = create(:user)
+ project1 = create(:project, :public, :merge_requests_disabled, :issues_enabled)
+ project2 = create(:project, :public, :merge_requests_disabled, :issues_disabled)
+ project3 = create(:project, :public, :issues_enabled, :merge_requests_enabled)
+ project4 = create(:project, :private, :issues_private, :merge_requests_private)
+
+ [project1, project2, project3, project4].each { |project| project.add_developer(user) }
+
+ expect(described_class.with_issues_or_mrs_available_for_user(user))
+ .to contain_exactly(project1, project3, project4)
+ end
+ end
+
def rugged_config
rugged_repo(project.repository).config
end
diff --git a/spec/support/shared_examples/models/mentionable_shared_examples.rb b/spec/support/shared_examples/models/mentionable_shared_examples.rb
index 0c55e9de045..0e8ee6f66f5 100644
--- a/spec/support/shared_examples/models/mentionable_shared_examples.rb
+++ b/spec/support/shared_examples/models/mentionable_shared_examples.rb
@@ -229,16 +229,17 @@ RSpec.shared_examples 'mentions in description' do |mentionable_type|
context 'when mentionable description contains mentions' do
let(:user) { create(:user) }
+ let(:user2) { create(:user) }
let(:group) { create(:group) }
- let(:mentionable_desc) { "#{user.to_reference} some description #{group.to_reference(full: true)} and @all" }
+ let(:mentionable_desc) { "#{user.to_reference} #{user2.to_reference} #{user.to_reference} some description #{group.to_reference(full: true)} and #{user2.to_reference} @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_users).to match_array([user, user2])
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
@@ -249,8 +250,9 @@ end
RSpec.shared_examples 'mentions in notes' do |mentionable_type|
context 'when mentionable notes contain mentions' do
let(:user) { create(:user) }
+ let(:user2) { create(:user) }
let(:group) { create(:group) }
- let(:note_desc) { "#{user.to_reference} and #{group.to_reference(full: true)} and @all" }
+ let(:note_desc) { "#{user.to_reference} #{user2.to_reference} #{user.to_reference} and #{group.to_reference(full: true)} and #{user2.to_reference} @all" }
let!(:mentionable) { note.noteable }
before do
@@ -261,7 +263,7 @@ RSpec.shared_examples 'mentions in notes' do |mentionable_type|
it 'returns all mentionable mentions' do
expect(mentionable.user_mentions.count).to eq 1
- expect(mentionable.referenced_users).to eq [user]
+ expect(mentionable.referenced_users).to eq [user, user2]
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