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