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--.rubocop_todo.yml11
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_list.vue59
-rw-r--r--app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql9
-rw-r--r--app/assets/javascripts/alert_management/list.js16
-rw-r--r--app/assets/javascripts/diffs/components/app.vue8
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue75
-rw-r--r--app/assets/stylesheets/framework/common.scss4
-rw-r--r--app/assets/stylesheets/pages/commits.scss12
-rw-r--r--app/controllers/projects/merge_requests_controller.rb1
-rw-r--r--app/models/ci/build.rb8
-rw-r--r--app/models/concerns/noteable.rb8
-rw-r--r--app/models/namespace.rb1
-rw-r--r--app/models/note.rb10
-rw-r--r--app/presenters/ci/build_runner_presenter.rb8
-rw-r--r--app/services/design_management/delete_designs_service.rb66
-rw-r--r--app/services/design_management/design_service.rb31
-rw-r--r--app/services/design_management/generate_image_versions_service.rb99
-rw-r--r--app/services/design_management/on_success_callbacks.rb23
-rw-r--r--app/services/design_management/runs_design_actions.rb35
-rw-r--r--app/services/design_management/save_designs_service.rb114
-rw-r--r--app/services/lfs/file_transformer.rb3
-rw-r--r--app/services/notes/post_process_service.rb8
-rw-r--r--app/services/projects/hashed_storage/base_repository_service.rb28
-rw-r--r--app/services/projects/transfer_service.rb24
-rw-r--r--app/services/projects/update_repository_storage_service.rb13
-rw-r--r--app/services/system_note_service.rb28
-rw-r--r--app/services/system_notes/design_management_service.rb83
-rw-r--r--app/services/wikis/create_attachment_service.rb11
-rw-r--r--app/views/help/_shortcuts.html.haml12
-rw-r--r--app/views/projects/commits/_commit.html.haml2
-rw-r--r--changelogs/unreleased/cat-duplicate-ci-pipelines-index-215790.yml5
-rw-r--r--changelogs/unreleased/feature-next-prev-commit-buttons.yml5
-rw-r--r--changelogs/unreleased/mwaw-214581-star-metrics-dashboard-endpoint.yml5
-rw-r--r--changelogs/unreleased/update-ado-image-to-0-14-0.yml6
-rw-r--r--db/migrate/20191229140154_drop_index_ci_pipelines_on_project_id.rb7
-rw-r--r--doc/api/api_resources.md1
-rw-r--r--doc/api/metrics_user_starred_dashboards.md34
-rw-r--r--doc/development/fe_guide/style/scss.md2
-rw-r--r--doc/development/go_guide/index.md1
-rw-r--r--doc/topics/autodevops/customize.md1
-rw-r--r--doc/topics/autodevops/upgrading_postgresql.md8
-rw-r--r--doc/user/permissions.md2
-rw-r--r--doc/user/project/integrations/img/panel_context_menu_v12_10.pngbin21057 -> 0 bytes
-rw-r--r--doc/user/project/integrations/img/panel_context_menu_v13_0.pngbin0 -> 34737 bytes
-rw-r--r--doc/user/project/integrations/prometheus.md15
-rw-r--r--lib/api/api.rb1
-rw-r--r--lib/api/entities/job_request/artifacts.rb1
-rw-r--r--lib/api/entities/metrics/user_starred_dashboard.rb11
-rw-r--r--lib/api/metrics/user_starred_dashboards.rb28
-rw-r--r--lib/api/wikis.rb8
-rw-r--r--lib/gitlab/ci/config/entry/artifacts.rb8
-rw-r--r--lib/gitlab/ci/features.rb14
-rw-r--r--lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml2
-rw-r--r--lib/gitlab/database/migration_helpers.rb2
-rw-r--r--locale/gitlab.pot21
-rw-r--r--rubocop/cop/rspec/empty_line_after_shared_example.rb64
-rw-r--r--spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb10
-rw-r--r--spec/frontend/alert_management/components/alert_management_list_spec.js62
-rw-r--r--spec/frontend/alert_management/mocks/alerts.json6
-rw-r--r--spec/frontend/diffs/components/app_spec.js145
-rw-r--r--spec/frontend/diffs/components/commit_item_spec.js144
-rw-r--r--spec/lib/gitlab/ci/config/entry/artifacts_spec.rb50
-rw-r--r--spec/lib/gitlab/ci/yaml_processor_spec.rb18
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb14
-rw-r--r--spec/mailers/notify_spec.rb20
-rw-r--r--spec/models/ci/build_spec.rb22
-rw-r--r--spec/models/note_spec.rb32
-rw-r--r--spec/presenters/ci/build_runner_presenter_spec.rb41
-rw-r--r--spec/requests/api/metrics/user_starred_dashboards_spec.rb92
-rw-r--r--spec/requests/api/runner_spec.rb47
-rw-r--r--spec/rubocop/cop/rspec/empty_line_after_shared_example_spec.rb86
-rw-r--r--spec/services/design_management/delete_designs_service_spec.rb195
-rw-r--r--spec/services/design_management/design_user_notes_count_service_spec.rb9
-rw-r--r--spec/services/design_management/generate_image_versions_service_spec.rb77
-rw-r--r--spec/services/design_management/save_designs_service_spec.rb356
-rw-r--r--spec/services/git/wiki_push_service_spec.rb2
-rw-r--r--spec/services/lfs/file_transformer_spec.rb17
-rw-r--r--spec/services/notes/create_service_spec.rb54
-rw-r--r--spec/services/notes/post_process_service_spec.rb27
-rw-r--r--spec/services/notification_service_spec.rb51
-rw-r--r--spec/services/projects/hashed_storage/migrate_repository_service_spec.rb10
-rw-r--r--spec/services/projects/hashed_storage/rollback_repository_service_spec.rb8
-rw-r--r--spec/services/projects/transfer_service_spec.rb84
-rw-r--r--spec/services/projects/update_repository_storage_service_spec.rb13
-rw-r--r--spec/services/system_note_service_spec.rb25
-rw-r--r--spec/services/system_notes/design_management_service_spec.rb155
-rw-r--r--spec/services/todo_service_spec.rb30
-rw-r--r--spec/services/wikis/create_attachment_service_spec.rb67
-rw-r--r--spec/support/helpers/wiki_helpers.rb7
-rw-r--r--spec/support/shared_examples/services/wikis/create_attachment_service_shared_examples.rb62
-rw-r--r--spec/workers/design_management/new_version_worker_spec.rb22
91 files changed, 2908 insertions, 214 deletions
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index bd0f9184cd6..c359670759e 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -208,6 +208,17 @@ Naming/RescuedExceptionsVariableName:
RSpec/ContextWording:
Enabled: false
+RSpec/EmptyLineAfterSharedExample:
+ Exclude:
+ - 'ee/spec/mailers/notify_spec.rb'
+ - 'ee/spec/services/quick_actions/interpret_service_spec.rb'
+ - 'spec/controllers/repositories/git_http_controller_spec.rb'
+ - 'spec/finders/projects/serverless/functions_finder_spec.rb'
+ - 'spec/lib/gitlab/hook_data/issuable_builder_spec.rb'
+ - 'spec/lib/gitlab/legacy_github_import/importer_spec.rb'
+ - 'spec/models/event_spec.rb'
+ - 'spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb'
+
# Offense count: 879
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle.
diff --git a/app/assets/javascripts/alert_management/components/alert_management_list.vue b/app/assets/javascripts/alert_management/components/alert_management_list.vue
index b4c68767ab3..741f04cfd9b 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_list.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_list.vue
@@ -5,17 +5,21 @@ import {
GlLoadingIcon,
GlTable,
GlAlert,
- GlNewDropdown,
- GlNewDropdownItem,
+ GlIcon,
+ GlDropdown,
+ GlDropdownItem,
GlTabs,
GlTab,
GlBadge,
} from '@gitlab/ui';
+import createFlash from '~/flash';
import { s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import getAlerts from '../graphql/queries/getAlerts.query.graphql';
import { ALERTS_STATUS, ALERTS_STATUS_TABS } from '../constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
const tdClass = 'table-col d-flex d-md-table-cell align-items-center';
@@ -53,6 +57,8 @@ export default {
},
{
key: 'status',
+ thClass: 'w-15p',
+ trClass: 'w-15p',
label: s__('AlertManagement|Status'),
tdClass: `${tdClass} rounded-bottom text-capitalize`,
},
@@ -70,8 +76,9 @@ export default {
GlAlert,
GlDeprecatedButton,
TimeAgo,
- GlNewDropdown,
- GlNewDropdownItem,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
GlTabs,
GlTab,
GlBadge,
@@ -140,6 +147,25 @@ export default {
filterALertsByStatus(tabIndex) {
this.statusFilter = this.$options.statusTabs[tabIndex].status;
},
+ capitalizeFirstCharacter,
+ updateAlertStatus(status, iid) {
+ this.$apollo
+ .mutate({
+ mutation: updateAlertStatus,
+ variables: {
+ iid,
+ status: status.toUpperCase(),
+ projectPath: this.projectPath,
+ },
+ })
+ .catch(() => {
+ createFlash(
+ s__(
+ 'AlertManagement|There was an error while updating the status of the alert. Please try again.',
+ ),
+ );
+ });
+ },
},
};
</script>
@@ -190,11 +216,26 @@ export default {
</template>
<template #cell(status)="{ item }">
- <gl-new-dropdown :text="item.status">
- <gl-new-dropdown-item v-for="(label, field) in $options.statuses" :key="field">
- {{ label }}
- </gl-new-dropdown-item>
- </gl-new-dropdown>
+ <gl-dropdown
+ :text="capitalizeFirstCharacter(item.status.toLowerCase())"
+ class="w-100"
+ right
+ >
+ <gl-dropdown-item
+ v-for="(label, field) in $options.statuses"
+ :key="field"
+ @click="updateAlertStatus(label, item.iid)"
+ >
+ <span class="d-flex">
+ <gl-icon
+ class="flex-shrink-0 append-right-4"
+ :class="{ invisible: label.toUpperCase() !== item.status }"
+ name="mobile-issue-close"
+ />
+ {{ label }}
+ </span>
+ </gl-dropdown-item>
+ </gl-dropdown>
</template>
<template #empty>
diff --git a/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql
new file mode 100644
index 00000000000..009ae0b2930
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql
@@ -0,0 +1,9 @@
+mutation ($projectPath: ID!, $status: AlertManagementStatus!, $iid: String!) {
+ updateAlertStatus(input: { iid: $iid, status: $status, projectPath: $projectPath }) {
+ errors
+ alert {
+ iid,
+ status,
+ }
+ }
+}
diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js
index ba3e60354de..cae6a536b56 100644
--- a/app/assets/javascripts/alert_management/list.js
+++ b/app/assets/javascripts/alert_management/list.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import { parseBoolean } from '~/lib/utils/common_utils';
import AlertManagementList from './components/alert_management_list.vue';
@@ -17,7 +18,20 @@ export default () => {
userCanEnableAlertManagement = parseBoolean(userCanEnableAlertManagement);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ dataIdFromObject: object => {
+ // eslint-disable-next-line no-underscore-dangle
+ if (object.__typename === 'AlertManagementAlert') {
+ return object.iid;
+ }
+ return defaultDataIdFromObject(object);
+ },
+ },
+ },
+ ),
});
return new Vue({
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 98c660077bb..941365d9d1d 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -224,6 +224,7 @@ export default {
methods: {
...mapActions(['startTaskList']),
...mapActions('diffs', [
+ 'moveToNeighboringCommit',
'setBaseConfig',
'fetchDiffFiles',
'fetchDiffFilesMeta',
@@ -344,9 +345,16 @@ export default {
break;
}
});
+
+ if (this.commit && this.glFeatures.mrCommitNeighborNav) {
+ Mousetrap.bind('c', () => this.moveToNeighboringCommit({ direction: 'next' }));
+ Mousetrap.bind('x', () => this.moveToNeighboringCommit({ direction: 'previous' }));
+ }
},
removeEventListeners() {
Mousetrap.unbind(['[', 'k', ']', 'j']);
+ Mousetrap.unbind('c');
+ Mousetrap.unbind('x');
},
jumpToFile(step) {
const targetIndex = this.currentDiffIndex + step;
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 9d4edd84f25..ee93ca020e8 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -1,10 +1,18 @@
<script>
+import { mapActions } from 'vuex';
+import { GlButtonGroup, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import Icon from '~/vue_shared/components/icon.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
+
import initUserPopovers from '../../user_popovers';
+import { setUrlParams } from '../../lib/utils/url_utility';
/**
* CommitItem
@@ -18,7 +26,16 @@ import initUserPopovers from '../../user_popovers';
* coexist, but there is an issue to remove the duplication.
* https://gitlab.com/gitlab-org/gitlab-foss/issues/51613
*
+ * EXCEPTION WARNING
+ * 1. The commit navigation buttons (next neighbor, previous neighbor)
+ * are not duplicated because:
+ * - We don't have the same data available on the Rails side (yet,
+ * without backend work)
+ * - This Vue component should always be what's used when in the
+ * context of an MR diff, so the HAML should never have any idea
+ * about navigating among commits.
*/
+
export default {
components: {
UserAvatarLink,
@@ -26,7 +43,14 @@ export default {
ClipboardButton,
TimeAgoTooltip,
CommitPipelineStatus,
+ GlButtonGroup,
+ GlButton,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
commit: {
type: Object,
@@ -54,12 +78,28 @@ export default {
authorAvatar() {
return this.author.avatar_url || this.commit.author_gravatar_url;
},
+ nextCommitUrl() {
+ return this.commit.next_commit_id
+ ? setUrlParams({ commit_id: this.commit.next_commit_id })
+ : '';
+ },
+ previousCommitUrl() {
+ return this.commit.prev_commit_id
+ ? setUrlParams({ commit_id: this.commit.prev_commit_id })
+ : '';
+ },
+ hasNeighborCommits() {
+ return this.commit.next_commit_id || this.commit.prev_commit_id;
+ },
},
created() {
this.$nextTick(() => {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
+ methods: {
+ ...mapActions('diffs', ['moveToNeighboringCommit']),
+ },
};
</script>
@@ -123,6 +163,41 @@ export default {
class="btn btn-default"
/>
</div>
+ <div
+ v-if="hasNeighborCommits && glFeatures.mrCommitNeighborNav"
+ class="commit-nav-buttons ml-3"
+ >
+ <gl-button-group>
+ <gl-button
+ :href="previousCommitUrl"
+ :disabled="!commit.prev_commit_id"
+ @click.prevent="moveToNeighboringCommit({ direction: 'previous' })"
+ >
+ <span
+ v-if="!commit.prev_commit_id"
+ v-gl-tooltip
+ class="h-100 w-100 position-absolute"
+ :title="__('You\'re at the first commit')"
+ ></span>
+ <gl-icon name="chevron-left" />
+ {{ __('Prev') }}
+ </gl-button>
+ <gl-button
+ :href="nextCommitUrl"
+ :disabled="!commit.next_commit_id"
+ @click.prevent="moveToNeighboringCommit({ direction: 'next' })"
+ >
+ <span
+ v-if="!commit.next_commit_id"
+ v-gl-tooltip
+ class="h-100 w-100 position-absolute"
+ :title="__('You\'re at the last commit')"
+ ></span>
+ {{ __('Next') }}
+ <gl-icon name="chevron-right" />
+ </gl-button>
+ </gl-button-group>
+ </div>
</div>
</div>
</li>
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index dfa65656739..1422959a10a 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -392,6 +392,10 @@ img.emoji {
}
/** COMMON CLASSES **/
+/**
+ 🚨 Do not use these classes — they are deprecated and being removed. 🚨
+ See https://gitlab.com/gitlab-org/gitlab/-/issues/217418 for more details.
+**/
.prepend-top-0 { margin-top: 0; }
.prepend-top-2 { margin-top: 2px; }
.prepend-top-4 { margin-top: $gl-padding-4; }
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 230f390aa90..9a69afc6044 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -208,6 +208,18 @@
}
}
+.commit-nav-buttons {
+ a.btn,
+ button {
+ // See: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/730
+ &:last-child > svg {
+ margin-left: 0.25rem;
+ margin-right: 0;
+ }
+ }
+}
+
+
.clipboard-group,
.commit-sha-group {
display: inline-flex;
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 398d46316f7..87b69291559 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -33,6 +33,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true)
push_frontend_feature_flag(:merge_ref_head_comments, @project)
push_frontend_feature_flag(:accessibility_merge_request_widget, @project)
+ push_frontend_feature_flag(:mr_commit_neighbor_nav, @project, default_enabled: true)
end
before_action do
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 1b512419e5c..3f970f7c4a1 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -25,7 +25,8 @@ module Ci
RUNNER_FEATURES = {
upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? },
- refspecs: -> (build) { build.merge_request_ref? }
+ refspecs: -> (build) { build.merge_request_ref? },
+ artifacts_exclude: -> (build) { build.supports_artifacts_exclude? }
}.freeze
DEFAULT_RETRIES = {
@@ -920,6 +921,11 @@ module Ci
failure_reason: :data_integrity_failure)
end
+ def supports_artifacts_exclude?
+ options&.dig(:artifacts, :exclude)&.any? &&
+ Gitlab::Ci::Features.artifacts_exclude_enabled?
+ end
+
def degradation_threshold
var = yaml_variables.find { |v| v[:key] == DEGRADATION_THRESHOLD_VARIABLE_NAME }
var[:value]&.to_i if var
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index 247ba0822fe..933a0b167e2 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -146,6 +146,14 @@ module Noteable
target_id: id
)
end
+
+ def after_note_created(_note)
+ # no-op
+ end
+
+ def after_note_destroyed(_note)
+ # no-op
+ end
end
Noteable.extend(Noteable::ClassMethods)
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index e2d1a433935..8116f7a256f 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -14,6 +14,7 @@ class Namespace < ApplicationRecord
include IgnorableColumns
ignore_column :plan_id, remove_with: '13.1', remove_after: '2020-06-22'
+ ignore_column :trial_ends_on, remove_with: '13.2', remove_after: '2020-07-22'
# Prevent users from creating unreasonably deep level of nesting.
# The number 20 was taken based on maximum nesting level of
diff --git a/app/models/note.rb b/app/models/note.rb
index 9d0cd30f5dc..d174ba8fe83 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -159,6 +159,8 @@ class Note < ApplicationRecord
after_save :touch_noteable, unless: :importing?
after_destroy :expire_etag_cache
after_save :store_mentions!, if: :any_mentionable_attributes_changed?
+ after_commit :notify_after_create, on: :create
+ after_commit :notify_after_destroy, on: :destroy
class << self
def model_name
@@ -509,6 +511,14 @@ class Note < ApplicationRecord
noteable_object
end
+ def notify_after_create
+ noteable&.after_note_created(self)
+ end
+
+ def notify_after_destroy
+ noteable&.after_note_destroyed(self)
+ end
+
def banzai_render_context(field)
super.merge(noteable: noteable, system_note: system?)
end
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 61fcbaf691a..f63fd3c50c4 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -52,7 +52,7 @@ module Ci
def create_archive(artifacts)
return unless artifacts[:untracked] || artifacts[:paths]
- {
+ archive = {
artifact_type: :archive,
artifact_format: :zip,
name: artifacts[:name],
@@ -61,6 +61,12 @@ module Ci
when: artifacts[:when],
expire_in: artifacts[:expire_in]
}
+
+ if artifacts.dig(:exclude).present? && ::Gitlab::Ci::Features.artifacts_exclude_enabled?
+ archive.merge(exclude: artifacts[:exclude])
+ else
+ archive
+ end
end
def create_reports(reports, expire_in:)
diff --git a/app/services/design_management/delete_designs_service.rb b/app/services/design_management/delete_designs_service.rb
new file mode 100644
index 00000000000..e69f07db5bf
--- /dev/null
+++ b/app/services/design_management/delete_designs_service.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class DeleteDesignsService < DesignService
+ include RunsDesignActions
+ include OnSuccessCallbacks
+
+ def initialize(project, user, params = {})
+ super
+
+ @designs = params.fetch(:designs)
+ end
+
+ def execute
+ return error('Forbidden!') unless can_delete_designs?
+
+ version = delete_designs!
+
+ success(version: version)
+ end
+
+ def commit_message
+ n = designs.size
+
+ <<~MSG
+ Removed #{n} #{'designs'.pluralize(n)}
+
+ #{formatted_file_list}
+ MSG
+ end
+
+ private
+
+ attr_reader :designs
+
+ def delete_designs!
+ DesignManagement::Version.with_lock(project.id, repository) do
+ run_actions(build_actions)
+ end
+ end
+
+ def can_delete_designs?
+ Ability.allowed?(current_user, :destroy_design, issue)
+ end
+
+ def build_actions
+ designs.map { |d| design_action(d) }
+ end
+
+ def design_action(design)
+ on_success { counter.count(:delete) }
+
+ DesignManagement::DesignAction.new(design, :delete)
+ end
+
+ def counter
+ ::Gitlab::UsageDataCounters::DesignsCounter
+ end
+
+ def formatted_file_list
+ designs.map { |design| "- #{design.full_path}" }.join("\n")
+ end
+ end
+end
+
+DesignManagement::DeleteDesignsService.prepend_if_ee('EE::DesignManagement::DeleteDesignsService')
diff --git a/app/services/design_management/design_service.rb b/app/services/design_management/design_service.rb
new file mode 100644
index 00000000000..54e53609646
--- /dev/null
+++ b/app/services/design_management/design_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class DesignService < ::BaseService
+ def initialize(project, user, params = {})
+ super
+
+ @issue = params.fetch(:issue)
+ end
+
+ # Accessors common to all subclasses:
+
+ attr_reader :issue
+
+ def target_branch
+ repository.root_ref || "master"
+ end
+
+ def collection
+ issue.design_collection
+ end
+
+ def repository
+ collection.repository
+ end
+
+ def project
+ issue.project
+ end
+ end
+end
diff --git a/app/services/design_management/generate_image_versions_service.rb b/app/services/design_management/generate_image_versions_service.rb
new file mode 100644
index 00000000000..213aac164ff
--- /dev/null
+++ b/app/services/design_management/generate_image_versions_service.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ # This service generates smaller image versions for `DesignManagement::Design`
+ # records within a given `DesignManagement::Version`.
+ class GenerateImageVersionsService < DesignService
+ # We limit processing to only designs with file sizes that don't
+ # exceed `MAX_DESIGN_SIZE`.
+ #
+ # Note, we may be able to remove checking this limit, if when we come to
+ # implement a file size limit for designs, there are no designs that
+ # exceed 40MB on GitLab.com
+ #
+ # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22860#note_281780387
+ MAX_DESIGN_SIZE = 40.megabytes.freeze
+
+ def initialize(version)
+ super(version.project, version.author, issue: version.issue)
+
+ @version = version
+ end
+
+ def execute
+ # rubocop: disable CodeReuse/ActiveRecord
+ version.actions.includes(:design).each do |action|
+ generate_image(action)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ success(version: version)
+ end
+
+ private
+
+ attr_reader :version
+
+ def generate_image(action)
+ raw_file = get_raw_file(action)
+
+ unless raw_file
+ log_error("No design file found for Action: #{action.id}")
+ return
+ end
+
+ # Skip attempting to process images that would be rejected by CarrierWave.
+ return unless DesignManagement::DesignV432x230Uploader::MIME_TYPE_WHITELIST.include?(raw_file.content_type)
+
+ # Store and process the file
+ action.image_v432x230.store!(raw_file)
+ action.save!
+ rescue CarrierWave::UploadError => e
+ Gitlab::ErrorTracking.track_exception(e, project_id: project.id, design_id: action.design_id, version_id: action.version_id)
+ log_error(e.message)
+ end
+
+ # Returns the `CarrierWave::SanitizedFile` of the original design file
+ def get_raw_file(action)
+ raw_files_by_path[action.design.full_path]
+ end
+
+ # Returns the `Carrierwave:SanitizedFile` instances for all of the original
+ # design files, mapping to { design.filename => `Carrierwave::SanitizedFile` }.
+ #
+ # As design files are stored in Git LFS, the only way to retrieve their original
+ # files is to first fetch the LFS pointer file data from the Git design repository.
+ # The LFS pointer file data contains an "OID" that lets us retrieve `LfsObject`
+ # records, which have an Uploader (`LfsObjectUploader`) for the original design file.
+ def raw_files_by_path
+ @raw_files_by_path ||= begin
+ LfsObject.for_oids(blobs_by_oid.keys).each_with_object({}) do |lfs_object, h|
+ blob = blobs_by_oid[lfs_object.oid]
+ file = lfs_object.file.file
+ # The `CarrierWave::SanitizedFile` is loaded without knowing the `content_type`
+ # of the file, due to the file not having an extension.
+ #
+ # Set the content_type from the `Blob`.
+ file.content_type = blob.content_type
+ h[blob.path] = file
+ end
+ end
+ end
+
+ # Returns the `Blob`s that correspond to the design files in the repository.
+ #
+ # All design `Blob`s are LFS Pointer files, and are therefore small amounts
+ # of data to load.
+ #
+ # `Blob`s whose size are above a certain threshold: `MAX_DESIGN_SIZE`
+ # are filtered out.
+ def blobs_by_oid
+ @blobs ||= begin
+ items = version.designs.map { |design| [version.sha, design.full_path] }
+ blobs = repository.blobs_at(items)
+ blobs.reject! { |blob| blob.lfs_size > MAX_DESIGN_SIZE }
+ blobs.index_by(&:lfs_oid)
+ end
+ end
+ end
+end
diff --git a/app/services/design_management/on_success_callbacks.rb b/app/services/design_management/on_success_callbacks.rb
new file mode 100644
index 00000000000..be55890a02d
--- /dev/null
+++ b/app/services/design_management/on_success_callbacks.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ module OnSuccessCallbacks
+ def on_success(&block)
+ success_callbacks.push(block)
+ end
+
+ def success(*_)
+ while cb = success_callbacks.pop
+ cb.call
+ end
+
+ super
+ end
+
+ private
+
+ def success_callbacks
+ @success_callbacks ||= []
+ end
+ end
+end
diff --git a/app/services/design_management/runs_design_actions.rb b/app/services/design_management/runs_design_actions.rb
new file mode 100644
index 00000000000..4bd6bb45658
--- /dev/null
+++ b/app/services/design_management/runs_design_actions.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ module RunsDesignActions
+ NoActions = Class.new(StandardError)
+
+ # this concern requires the following methods to be implemented:
+ # current_user, target_branch, repository, commit_message
+ #
+ # Before calling `run_actions`, you should ensure the repository exists, by
+ # calling `repository.create_if_not_exists`.
+ #
+ # @raise [NoActions] if actions are empty
+ def run_actions(actions)
+ raise NoActions if actions.empty?
+
+ sha = repository.multi_action(current_user,
+ branch_name: target_branch,
+ message: commit_message,
+ actions: actions.map(&:gitaly_action))
+
+ ::DesignManagement::Version
+ .create_for_designs(actions, sha, current_user)
+ .tap { |version| post_process(version) }
+ end
+
+ private
+
+ def post_process(version)
+ version.run_after_commit_or_now do
+ ::DesignManagement::NewVersionWorker.perform_async(id)
+ end
+ end
+ end
+end
diff --git a/app/services/design_management/save_designs_service.rb b/app/services/design_management/save_designs_service.rb
new file mode 100644
index 00000000000..a09c19bc885
--- /dev/null
+++ b/app/services/design_management/save_designs_service.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class SaveDesignsService < DesignService
+ include RunsDesignActions
+ include OnSuccessCallbacks
+
+ MAX_FILES = 10
+
+ def initialize(project, user, params = {})
+ super
+
+ @files = params.fetch(:files)
+ end
+
+ def execute
+ return error("Not allowed!") unless can_create_designs?
+ return error("Only #{MAX_FILES} files are allowed simultaneously") if files.size > MAX_FILES
+
+ uploaded_designs, version = upload_designs!
+ skipped_designs = designs - uploaded_designs
+
+ success({ designs: uploaded_designs, version: version, skipped_designs: skipped_designs })
+ rescue ::ActiveRecord::RecordInvalid => e
+ error(e.message)
+ end
+
+ private
+
+ attr_reader :files
+
+ def upload_designs!
+ ::DesignManagement::Version.with_lock(project.id, repository) do
+ actions = build_actions
+
+ [actions.map(&:design), actions.presence && run_actions(actions)]
+ end
+ end
+
+ # Returns `Design` instances that correspond with `files`.
+ # New `Design`s will be created where a file name does not match
+ # an existing `Design`
+ def designs
+ @designs ||= files.map do |file|
+ collection.find_or_create_design!(filename: file.original_filename)
+ end
+ end
+
+ def build_actions
+ files.zip(designs).flat_map do |(file, design)|
+ Array.wrap(build_design_action(file, design))
+ end
+ end
+
+ def build_design_action(file, design)
+ content = file_content(file, design.full_path)
+ return if design_unchanged?(design, content)
+
+ action = new_file?(design) ? :create : :update
+ on_success { ::Gitlab::UsageDataCounters::DesignsCounter.count(action) }
+
+ DesignManagement::DesignAction.new(design, action, content)
+ end
+
+ # Returns true if the design file is the same as its latest version
+ def design_unchanged?(design, content)
+ content == existing_blobs[design]&.data
+ end
+
+ def commit_message
+ <<~MSG
+ Updated #{files.size} #{'designs'.pluralize(files.size)}
+
+ #{formatted_file_list}
+ MSG
+ end
+
+ def formatted_file_list
+ filenames.map { |name| "- #{name}" }.join("\n")
+ end
+
+ def filenames
+ @filenames ||= files.map(&:original_filename)
+ end
+
+ def can_create_designs?
+ Ability.allowed?(current_user, :create_design, issue)
+ end
+
+ def new_file?(design)
+ !existing_blobs[design]
+ end
+
+ def file_content(file, full_path)
+ transformer = ::Lfs::FileTransformer.new(project, repository, target_branch)
+ transformer.new_file(full_path, file.to_io).content
+ end
+
+ # Returns the latest blobs for the designs as a Hash of `{ Design => Blob }`
+ def existing_blobs
+ @existing_blobs ||= begin
+ items = designs.map { |d| ['HEAD', d.full_path] }
+
+ repository.blobs_at(items).each_with_object({}) do |blob, h|
+ design = designs.find { |d| d.full_path == blob.path }
+
+ h[design] = blob
+ end
+ end
+ end
+ end
+end
+
+DesignManagement::SaveDesignsService.prepend_if_ee('EE::DesignManagement::SaveDesignsService')
diff --git a/app/services/lfs/file_transformer.rb b/app/services/lfs/file_transformer.rb
index 88f59b820a4..69d33e1c873 100644
--- a/app/services/lfs/file_transformer.rb
+++ b/app/services/lfs/file_transformer.rb
@@ -5,8 +5,7 @@ module Lfs
# return a transformed result with `content` and `encoding` to commit.
#
# The `repository` passed to the initializer can be a Repository or
- # a DesignManagement::Repository (an EE-specific class that inherits
- # from Repository).
+ # class that inherits from Repository.
#
# The `repository_type` property will be one of the types named in
# `Gitlab::GlRepository.types`, and is recorded on the `LfsObjectsProject`
diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb
index 53b3b57f4af..bc86118a150 100644
--- a/app/services/notes/post_process_service.rb
+++ b/app/services/notes/post_process_service.rb
@@ -16,10 +16,18 @@ module Notes
return if @note.for_personal_snippet?
@note.create_cross_references!
+ ::SystemNoteService.design_discussion_added(@note) if create_design_discussion_system_note?
+
execute_note_hooks
end
end
+ private
+
+ def create_design_discussion_system_note?
+ @note && @note.for_design? && @note.start_of_discussion?
+ end
+
def hook_data
Gitlab::DataBuilder::Note.build(@note, @note.author)
end
diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb
index d81aa4de9f1..065bf8725be 100644
--- a/app/services/projects/hashed_storage/base_repository_service.rb
+++ b/app/services/projects/hashed_storage/base_repository_service.rb
@@ -8,13 +8,15 @@ module Projects
class BaseRepositoryService < BaseService
include Gitlab::ShellAdapter
- attr_reader :old_disk_path, :new_disk_path, :old_storage_version, :logger, :move_wiki
+ attr_reader :old_disk_path, :new_disk_path, :old_storage_version,
+ :logger, :move_wiki, :move_design
def initialize(project:, old_disk_path:, logger: nil)
@project = project
@logger = logger || Gitlab::AppLogger
@old_disk_path = old_disk_path
@move_wiki = has_wiki?
+ @move_design = has_design?
end
protected
@@ -23,6 +25,10 @@ module Projects
gitlab_shell.repository_exists?(project.repository_storage, "#{old_wiki_disk_path}.git")
end
+ def has_design?
+ gitlab_shell.repository_exists?(project.repository_storage, "#{old_design_disk_path}.git")
+ end
+
def move_repository(from_name, to_name)
from_exists = gitlab_shell.repository_exists?(project.repository_storage, "#{from_name}.git")
to_exists = gitlab_shell.repository_exists?(project.repository_storage, "#{to_name}.git")
@@ -58,12 +64,18 @@ module Projects
project.clear_memoization(:wiki)
end
+ if move_design
+ result &&= move_repository(old_design_disk_path, new_design_disk_path)
+ project.clear_memoization(:design_repository)
+ end
+
result
end
def rollback_folder_move
move_repository(new_disk_path, old_disk_path)
move_repository(new_wiki_disk_path, old_wiki_disk_path)
+ move_repository(new_design_disk_path, old_design_disk_path) if move_design
end
def try_to_set_repository_read_only!
@@ -87,8 +99,18 @@ module Projects
def new_wiki_disk_path
@new_wiki_disk_path ||= "#{new_disk_path}#{wiki_path_suffix}"
end
+
+ def design_path_suffix
+ @design_path_suffix ||= ::Gitlab::GlRepository::DESIGN.path_suffix
+ end
+
+ def old_design_disk_path
+ @old_design_disk_path ||= "#{old_disk_path}#{design_path_suffix}"
+ end
+
+ def new_design_disk_path
+ @new_design_disk_path ||= "#{new_disk_path}#{design_path_suffix}"
+ end
end
end
end
-
-Projects::HashedStorage::BaseRepositoryService.prepend_if_ee('EE::Projects::HashedStorage::BaseRepositoryService')
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index 309eab59463..60e5b7e2639 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -135,7 +135,8 @@ module Projects
return if project.hashed_storage?(:repository)
move_repo_folder(@new_path, @old_path)
- move_repo_folder("#{@new_path}.wiki", "#{@old_path}.wiki")
+ move_repo_folder(new_wiki_repo_path, old_wiki_repo_path)
+ move_repo_folder(new_design_repo_path, old_design_repo_path)
end
def move_repo_folder(from_name, to_name)
@@ -157,8 +158,9 @@ module Projects
# Disk path is changed; we need to ensure we reload it
project.reload_repository!
- # Move wiki repo also if present
- move_repo_folder("#{@old_path}.wiki", "#{@new_path}.wiki")
+ # Move wiki and design repos also if present
+ move_repo_folder(old_wiki_repo_path, new_wiki_repo_path)
+ move_repo_folder(old_design_repo_path, new_design_repo_path)
end
def move_project_uploads(project)
@@ -170,6 +172,22 @@ module Projects
@new_namespace.full_path
)
end
+
+ def old_wiki_repo_path
+ "#{old_path}#{::Gitlab::GlRepository::WIKI.path_suffix}"
+ end
+
+ def new_wiki_repo_path
+ "#{new_path}#{::Gitlab::GlRepository::WIKI.path_suffix}"
+ end
+
+ def old_design_repo_path
+ "#{old_path}#{::Gitlab::GlRepository::DESIGN.path_suffix}"
+ end
+
+ def new_design_repo_path
+ "#{new_path}#{::Gitlab::GlRepository::DESIGN.path_suffix}"
+ end
end
end
diff --git a/app/services/projects/update_repository_storage_service.rb b/app/services/projects/update_repository_storage_service.rb
index 21cd84117d7..0082b7bfed5 100644
--- a/app/services/projects/update_repository_storage_service.rb
+++ b/app/services/projects/update_repository_storage_service.rb
@@ -58,6 +58,10 @@ module Projects
if project.wiki.repository_exists?
mirror_repository(type: Gitlab::GlRepository::WIKI)
end
+
+ if project.design_repository.exists?
+ mirror_repository(type: ::Gitlab::GlRepository::DESIGN)
+ end
end
def mirror_repository(type: Gitlab::GlRepository::PROJECT)
@@ -106,6 +110,13 @@ module Projects
wiki.disk_path,
"#{new_project_path}.wiki")
end
+
+ if design_repository.exists?
+ GitlabShellWorker.perform_async(:mv_repository,
+ old_repository_storage,
+ design_repository.disk_path,
+ "#{new_project_path}.design")
+ end
end
end
@@ -140,5 +151,3 @@ module Projects
end
end
end
-
-Projects::UpdateRepositoryStorageService.prepend_if_ee('EE::Projects::UpdateRepositoryStorageService')
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 1b9f5971f73..6bf04c55415 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -245,6 +245,34 @@ module SystemNoteService
def auto_resolve_prometheus_alert(noteable, project, author)
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).auto_resolve_prometheus_alert
end
+
+ # Parameters:
+ # - version [DesignManagement::Version]
+ #
+ # Example Note text:
+ #
+ # "added [1 designs](link-to-version)"
+ # "changed [2 designs](link-to-version)"
+ #
+ # Returns [Array<Note>]: the created Note objects
+ def design_version_added(version)
+ ::SystemNotes::DesignManagementService.new(noteable: version.issue, project: version.issue.project, author: version.author).design_version_added(version)
+ end
+
+ # Called when a new discussion is created on a design
+ #
+ # discussion_note - DiscussionNote
+ #
+ # Example Note text:
+ #
+ # "started a discussion on screen.png"
+ #
+ # Returns the created Note object
+ def design_discussion_added(discussion_note)
+ design = discussion_note.noteable
+
+ ::SystemNotes::DesignManagementService.new(noteable: design.issue, project: design.project, author: discussion_note.author).design_discussion_added(discussion_note)
+ end
end
SystemNoteService.prepend_if_ee('EE::SystemNoteService')
diff --git a/app/services/system_notes/design_management_service.rb b/app/services/system_notes/design_management_service.rb
new file mode 100644
index 00000000000..a773877e25b
--- /dev/null
+++ b/app/services/system_notes/design_management_service.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+module SystemNotes
+ class DesignManagementService < ::SystemNotes::BaseService
+ include ActionView::RecordIdentifier
+
+ # Parameters:
+ # - version [DesignManagement::Version]
+ #
+ # Example Note text:
+ #
+ # "added [1 designs](link-to-version)"
+ # "changed [2 designs](link-to-version)"
+ #
+ # Returns [Array<Note>]: the created Note objects
+ def design_version_added(version)
+ events = DesignManagement::Action.events
+ link_href = designs_path(version: version.id)
+
+ version.designs_by_event.map do |(event_name, designs)|
+ note_data = self.class.design_event_note_data(events[event_name])
+ icon_name = note_data[:icon]
+ n = designs.size
+
+ body = "%s [%d %s](%s)" % [note_data[:past_tense], n, 'design'.pluralize(n), link_href]
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: icon_name))
+ end
+ end
+
+ # Called when a new discussion is created on a design
+ #
+ # discussion_note - DiscussionNote
+ #
+ # Example Note text:
+ #
+ # "started a discussion on screen.png"
+ #
+ # Returns the created Note object
+ def design_discussion_added(discussion_note)
+ design = discussion_note.noteable
+
+ body = _('started a discussion on %{design_link}') % {
+ design_link: '[%s](%s)' % [
+ design.filename,
+ designs_path(vueroute: design.filename, anchor: dom_id(discussion_note))
+ ]
+ }
+
+ action = :designs_discussion_added
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: action))
+ end
+
+ # Take one of the `DesignManagement::Action.events` and
+ # return:
+ # * an English past-tense verb.
+ # * the name of an icon used in renderin a system note
+ #
+ # We do not currently internationalize our system notes,
+ # instead we just produce English-language descriptions.
+ # See: https://gitlab.com/gitlab-org/gitlab/issues/30408
+ # See: https://gitlab.com/gitlab-org/gitlab/issues/14056
+ def self.design_event_note_data(event)
+ case event
+ when DesignManagement::Action.events[:creation]
+ { icon: 'designs_added', past_tense: 'added' }
+ when DesignManagement::Action.events[:modification]
+ { icon: 'designs_modified', past_tense: 'updated' }
+ when DesignManagement::Action.events[:deletion]
+ { icon: 'designs_removed', past_tense: 'removed' }
+ else
+ raise "Unknown event: #{event}"
+ end
+ end
+
+ private
+
+ def designs_path(params = {})
+ url_helpers.designs_project_issue_path(project, noteable, params)
+ end
+ end
+end
diff --git a/app/services/wikis/create_attachment_service.rb b/app/services/wikis/create_attachment_service.rb
index 6ef6cbc3c12..82179459345 100644
--- a/app/services/wikis/create_attachment_service.rb
+++ b/app/services/wikis/create_attachment_service.rb
@@ -5,12 +5,15 @@ module Wikis
ATTACHMENT_PATH = 'uploads'
MAX_FILENAME_LENGTH = 255
- delegate :wiki, to: :project
+ attr_reader :container
+
+ delegate :wiki, to: :container
delegate :repository, to: :wiki
- def initialize(*args)
- super
+ def initialize(container:, current_user: nil, params: {})
+ super(nil, current_user, params)
+ @container = container
@file_name = clean_file_name(params[:file_name])
@file_path = File.join(ATTACHMENT_PATH, SecureRandom.hex, @file_name) if @file_name
@commit_message ||= "Upload attachment #{@file_name}"
@@ -51,7 +54,7 @@ module Wikis
end
def validate_permissions!
- unless can?(current_user, :create_wiki, project)
+ unless can?(current_user, :create_wiki, container)
raise_error('You are not allowed to push to the wiki')
end
end
diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml
index aa63127049c..bd5424c30c6 100644
--- a/app/views/help/_shortcuts.html.haml
+++ b/app/views/help/_shortcuts.html.haml
@@ -316,6 +316,18 @@
%tbody
%tr
%th
+ %th= _('Merge Request Commits')
+ %tr
+ %td.shortcut
+ %kbd c
+ %td= _('Next commit')
+ %tr
+ %td.shortcut
+ %kbd x
+ %td= _('Previous commit')
+ %tbody
+ %tr
+ %th
%th= _('Web IDE')
%tr
%td.shortcut
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 8b659034fe6..b42eef32a76 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -1,6 +1,8 @@
-#-----------------------------------------------------------------
WARNING: Please keep changes up-to-date with the following files:
- `assets/javascripts/diffs/components/commit_item.vue`
+
+ EXCEPTION WARNING - see above `.vue` file for de-sync drift
-#-----------------------------------------------------------------
- view_details = local_assigns.fetch(:view_details, false)
- merge_request = local_assigns.fetch(:merge_request, nil)
diff --git a/changelogs/unreleased/cat-duplicate-ci-pipelines-index-215790.yml b/changelogs/unreleased/cat-duplicate-ci-pipelines-index-215790.yml
new file mode 100644
index 00000000000..a21cafe5e14
--- /dev/null
+++ b/changelogs/unreleased/cat-duplicate-ci-pipelines-index-215790.yml
@@ -0,0 +1,5 @@
+---
+title: Fix duplicate index removal on ci_pipelines.project_id
+merge_request: 31043
+author:
+type: fixed
diff --git a/changelogs/unreleased/feature-next-prev-commit-buttons.yml b/changelogs/unreleased/feature-next-prev-commit-buttons.yml
new file mode 100644
index 00000000000..3a53f803349
--- /dev/null
+++ b/changelogs/unreleased/feature-next-prev-commit-buttons.yml
@@ -0,0 +1,5 @@
+---
+title: Add Previous and Next buttons for commit-by-commit navigation
+merge_request: 28596
+author:
+type: added
diff --git a/changelogs/unreleased/mwaw-214581-star-metrics-dashboard-endpoint.yml b/changelogs/unreleased/mwaw-214581-star-metrics-dashboard-endpoint.yml
new file mode 100644
index 00000000000..3b539cd3140
--- /dev/null
+++ b/changelogs/unreleased/mwaw-214581-star-metrics-dashboard-endpoint.yml
@@ -0,0 +1,5 @@
+---
+title: New API endpoint for starring metrics dashboards
+merge_request: 31316
+author:
+type: added
diff --git a/changelogs/unreleased/update-ado-image-to-0-14-0.yml b/changelogs/unreleased/update-ado-image-to-0-14-0.yml
new file mode 100644
index 00000000000..320625d7f0a
--- /dev/null
+++ b/changelogs/unreleased/update-ado-image-to-0-14-0.yml
@@ -0,0 +1,6 @@
+---
+title: Update auto-deploy-image to v0.14.0 with helm 2.16.6, --atomic deployments
+ and improved kubernetes 1.16 support
+merge_request: 31505
+author:
+type: changed
diff --git a/db/migrate/20191229140154_drop_index_ci_pipelines_on_project_id.rb b/db/migrate/20191229140154_drop_index_ci_pipelines_on_project_id.rb
index dbfe3758cda..9e78457b007 100644
--- a/db/migrate/20191229140154_drop_index_ci_pipelines_on_project_id.rb
+++ b/db/migrate/20191229140154_drop_index_ci_pipelines_on_project_id.rb
@@ -8,10 +8,13 @@ class DropIndexCiPipelinesOnProjectId < ActiveRecord::Migration[5.2]
disable_ddl_transaction!
def up
- remove_concurrent_index :ci_pipelines, :project_id
+ remove_concurrent_index_by_name :ci_pipelines, 'index_ci_pipelines_on_project_id'
+
+ # extra (duplicate) index that already existed on some installs
+ remove_concurrent_index_by_name :ci_pipelines, 'ci_pipelines_project_id_idx'
end
def down
- add_concurrent_index :ci_pipelines, :project_id
+ add_concurrent_index :ci_pipelines, :project_id, name: 'index_ci_pipelines_on_project_id'
end
end
diff --git a/doc/api/api_resources.md b/doc/api/api_resources.md
index 34b4bf934bc..f0f3e58c61f 100644
--- a/doc/api/api_resources.md
+++ b/doc/api/api_resources.md
@@ -72,6 +72,7 @@ The following API resources are available in the project context:
| [Search](search.md) | `/projects/:id/search` (also available for groups and standalone) |
| [Services](services.md) | `/projects/:id/services` |
| [Tags](tags.md) | `/projects/:id/repository/tags` |
+| [User-starred metrics dashboards](metrics_user_starred_dashboards.md ) | `/projects/:id/metrics/user_starred_dashboards` |
| [Visual Review discussions](visual_review_discussions.md) **(STARTER)** | `/projects/:id/merge_requests/:merge_request_id/visual_review_discussions` |
| [Vulnerabilities](vulnerabilities.md) **(ULTIMATE)** | `/vulnerabilities/:id` |
| [Vulnerability exports](vulnerability_exports.md) **(ULTIMATE)** | `/projects/:id/vulnerability_exports` |
diff --git a/doc/api/metrics_user_starred_dashboards.md b/doc/api/metrics_user_starred_dashboards.md
new file mode 100644
index 00000000000..94bc1d3b4d1
--- /dev/null
+++ b/doc/api/metrics_user_starred_dashboards.md
@@ -0,0 +1,34 @@
+# User-starred metrics dashboards API
+
+The starred dashboard feature makes navigating to frequently-used dashboards easier
+by displaying favorited dashboards at the top of the select list.
+
+## Add a star to a dashboard
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/31316) in GitLab 13.0.
+
+```plaintext
+POST /projects/:id/metrics/user_starred_dashboards
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+|:---------------|:---------------|:---------|:-----------------------------------------------------------------------------|
+| `dashboard_path` | string | yes | URL-encoded path to file defining the dashboard which should be marked as favorite. |
+
+```shell
+curl --header 'Private-Token: <your_access_token>' https://gitlab.example.com/api/v4/projects/20/metrics/user_starred_dashboards \
+ --data-urlencode "dashboard_path=config/prometheus/dashboards/common_metrics.yml"
+```
+
+Example Response:
+
+```json
+{
+ "id": 5,
+ "dashboard_path": "config/prometheus/common_metrics.yml",
+ "user_id": 1,
+ "project_id": 20
+}
+```
diff --git a/doc/development/fe_guide/style/scss.md b/doc/development/fe_guide/style/scss.md
index b5e73050d6f..abfe985c389 100644
--- a/doc/development/fe_guide/style/scss.md
+++ b/doc/development/fe_guide/style/scss.md
@@ -17,7 +17,7 @@ led by the [GitLab UI WG](https://gitlab.com/gitlab-com/www-gitlab-com/-/merge_r
#### Where are utility classes defined?
- [Bootstrap's Utility Classes](https://getbootstrap.com/docs/4.3/utilities/)
-- [`common.scss`](https://gitlab.com/gitlab-org/gitlab/blob/master/app/assets/stylesheets/framework/common.scss) (old)
+- [`common.scss`](https://gitlab.com/gitlab-org/gitlab/blob/master/app/assets/stylesheets/framework/common.scss) (old, many classes are now deprecated)
- [`utilities.scss`](https://gitlab.com/gitlab-org/gitlab/blob/master/app/assets/stylesheets/utilities.scss) (new)
#### Where should I put new utility classes?
diff --git a/doc/development/go_guide/index.md b/doc/development/go_guide/index.md
index cb9b76f6e5e..8eb192a62cf 100644
--- a/doc/development/go_guide/index.md
+++ b/doc/development/go_guide/index.md
@@ -63,7 +63,6 @@ file and ask your manager to review and merge.
```yaml
projects:
gitlab: reviewer go
- gitlab-foss: reviewer go
```
## Code style and format
diff --git a/doc/topics/autodevops/customize.md b/doc/topics/autodevops/customize.md
index 526a5f9be04..ac9b2ded720 100644
--- a/doc/topics/autodevops/customize.md
+++ b/doc/topics/autodevops/customize.md
@@ -305,6 +305,7 @@ applications.
|-----------------------------------------|------------------------------------|
| `ADDITIONAL_HOSTS` | Fully qualified domain names specified as a comma-separated list that are added to the Ingress hosts. |
| `<ENVIRONMENT>_ADDITIONAL_HOSTS` | For a specific environment, the fully qualified domain names specified as a comma-separated list that are added to the Ingress hosts. This takes precedence over `ADDITIONAL_HOSTS`. |
+| `AUTO_DEVOPS_ATOMIC_RELEASE` | As of GitLab 13.0, Auto DevOps uses [`--atomic`](https://v2.helm.sh/docs/helm/#options-43) for Helm deployments by default. Set this variable to `false` to disable the use of `--atomic` |
| `AUTO_DEVOPS_BUILD_IMAGE_CNB_ENABLED` | When set to a non-empty value and no `Dockerfile` is present, Auto Build builds your application using Cloud Native Buildpacks instead of Herokuish. [More details](stages.md#auto-build-using-cloud-native-buildpacks-beta). |
| `AUTO_DEVOPS_BUILD_IMAGE_EXTRA_ARGS` | Extra arguments to be passed to the `docker build` command. Note that using quotes won't prevent word splitting. [More details](#passing-arguments-to-docker-build). |
| `AUTO_DEVOPS_BUILD_IMAGE_FORWARDED_CI_VARIABLES` | A [comma-separated list of CI variable names](#passing-secrets-to-docker-build) to be passed to the `docker build` command as secrets. |
diff --git a/doc/topics/autodevops/upgrading_postgresql.md b/doc/topics/autodevops/upgrading_postgresql.md
index ccb009905eb..2f50a897481 100644
--- a/doc/topics/autodevops/upgrading_postgresql.md
+++ b/doc/topics/autodevops/upgrading_postgresql.md
@@ -126,7 +126,7 @@ volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/)
used to store the underlying data for PostgreSQL is marked as `Delete`
when the pods and pod claims that use the volume is deleted.
-This is signficant as, when you opt into the newer 8.2.1 PostgreSQL, the older 0.7.1 PostgreSQL is
+This is significant as, when you opt into the newer 8.2.1 PostgreSQL, the older 0.7.1 PostgreSQL is
deleted causing the persistent volumes to be deleted as well.
You can verify this by using the following command:
@@ -165,12 +165,14 @@ volume](#retain-persistent-volumes).
TIP: **Tip:** You can also
[scope](../../ci/environments.md#scoping-environments-with-specs) the
-`AUTO_DEVOPS_POSTGRES_CHANNEL` and `POSTGRES_VERSION` variables to
-specific environments, e.g. `staging`.
+`AUTO_DEVOPS_POSTGRES_CHANNEL`, `AUTO_DEVOPS_POSTGRES_DELETE_V1` and
+`POSTGRES_VERSION` variables to specific environments, e.g. `staging`.
1. Set `AUTO_DEVOPS_POSTGRES_CHANNEL` to `2`. This opts into using the
newer 8.2.1-based PostgreSQL, and removes the older 0.7.1-based
PostgreSQL.
+1. Set `AUTO_DEVOPS_POSTGRES_DELETE_V1` to a non-empty value. This flag is a
+ safeguard to prevent accidental deletion of databases.
1. Set `POSTGRES_VERSION` to `9.6.16`. This is the minimum PostgreSQL
version supported.
1. Set `PRODUCTION_REPLICAS` to `0`. For other environments, use
diff --git a/doc/user/permissions.md b/doc/user/permissions.md
index 3f34695d011..baa1f9dc544 100644
--- a/doc/user/permissions.md
+++ b/doc/user/permissions.md
@@ -70,6 +70,7 @@ The following table depicts the various user permission levels in a project.
| View confidential issues | (*2*) | ✓ | ✓ | ✓ | ✓ |
| View [Releases](project/releases/index.md) | ✓ (*6*) | ✓ | ✓ | ✓ | ✓ |
| View requirements **(ULTIMATE)** | ✓ | ✓ | ✓ | ✓ | ✓ |
+| Manage user-starred metrics dashboards (*7*) | ✓ | ✓ | ✓ | ✓ | ✓ |
| Assign issues | | ✓ | ✓ | ✓ | ✓ |
| Label issues | | ✓ | ✓ | ✓ | ✓ |
| Set issue weight | | ✓ | ✓ | ✓ | ✓ |
@@ -170,6 +171,7 @@ The following table depicts the various user permission levels in a project.
1. Not allowed for Guest, Reporter, Developer, Maintainer, or Owner. See [Protected Branches](./project/protected_branches.md).
1. If the [branch is protected](./project/protected_branches.md#using-the-allowed-to-merge-and-allowed-to-push-settings), this depends on the access Developers and Maintainers are given.
1. Guest users can access GitLab [**Releases**](project/releases/index.md) for downloading assets but are not allowed to download the source code nor see repository information like tags and commits.
+1. Actions are limited only to records owned (referenced) by user.
## Project features permissions
diff --git a/doc/user/project/integrations/img/panel_context_menu_v12_10.png b/doc/user/project/integrations/img/panel_context_menu_v12_10.png
deleted file mode 100644
index 096262fea91..00000000000
--- a/doc/user/project/integrations/img/panel_context_menu_v12_10.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/integrations/img/panel_context_menu_v13_0.png b/doc/user/project/integrations/img/panel_context_menu_v13_0.png
new file mode 100644
index 00000000000..2d7cb923981
--- /dev/null
+++ b/doc/user/project/integrations/img/panel_context_menu_v13_0.png
Binary files differ
diff --git a/doc/user/project/integrations/prometheus.md b/doc/user/project/integrations/prometheus.md
index 48164a53727..2ef29caa118 100644
--- a/doc/user/project/integrations/prometheus.md
+++ b/doc/user/project/integrations/prometheus.md
@@ -663,10 +663,11 @@ When viewing a custom dashboard of a project, you can view the original
From each of the panels in the dashboard, you can access the context menu by clicking the **{ellipsis_v}** **More actions** dropdown box above the upper right corner of the panel to take actions related to the chart's data.
-![Context Menu](img/panel_context_menu_v12_10.png)
+![Context Menu](img/panel_context_menu_v13_0.png)
The options are:
+- [Expand panel](#expand-panel)
- [View logs](#view-logs-ultimate)
- [Download CSV](#downloading-data-as-csv)
- [Copy link to chart](#embedding-gitlab-managed-kubernetes-metrics)
@@ -689,6 +690,16 @@ You can create annotations by making requests to the
![Annotations UI](img/metrics_dashboard_annotations_ui_v13.0.png)
+### Expand panel
+
+> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3100) in GitLab 13.0.
+
+To view a larger version of a visualization, expand the panel by clicking the
+**{ellipsis_v}** **More actions** icon and selecting **Expand panel**.
+
+To return to the metrics dashboard, click the **Back** button in your
+browser, or pressing the <kbd>Esc</kbd> key.
+
### View Logs **(ULTIMATE)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/122013) in GitLab 12.8.
@@ -857,6 +868,8 @@ Metric charts may also be hidden:
![Show Hide](img/hide_embedded_metrics_v12_10.png)
+You can open the link directly into your browser for a [detailed view of the data](#expand-panel).
+
### Embedding metrics in issue templates
It is also possible to embed either the default dashboard metrics or individual metrics in issue templates. For charts to render side-by-side, links to the entire metrics dashboard or individual metrics should be separated by either a comma or a space.
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 5816d2db534..433edc1daba 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -162,6 +162,7 @@ module API
mount ::API::MergeRequestDiffs
mount ::API::MergeRequests
mount ::API::Metrics::Dashboard::Annotations
+ mount ::API::Metrics::UserStarredDashboards
mount ::API::Namespaces
mount ::API::Notes
mount ::API::Discussions
diff --git a/lib/api/entities/job_request/artifacts.rb b/lib/api/entities/job_request/artifacts.rb
index c6871fdd875..0d27f5a9189 100644
--- a/lib/api/entities/job_request/artifacts.rb
+++ b/lib/api/entities/job_request/artifacts.rb
@@ -7,6 +7,7 @@ module API
expose :name
expose :untracked
expose :paths
+ expose :exclude, expose_nil: false
expose :when
expose :expire_in
expose :artifact_type
diff --git a/lib/api/entities/metrics/user_starred_dashboard.rb b/lib/api/entities/metrics/user_starred_dashboard.rb
new file mode 100644
index 00000000000..d774160e3ea
--- /dev/null
+++ b/lib/api/entities/metrics/user_starred_dashboard.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module API
+ module Entities
+ module Metrics
+ class UserStarredDashboard < Grape::Entity
+ expose :id, :dashboard_path, :user_id, :project_id
+ end
+ end
+ end
+end
diff --git a/lib/api/metrics/user_starred_dashboards.rb b/lib/api/metrics/user_starred_dashboards.rb
new file mode 100644
index 00000000000..478403b40ad
--- /dev/null
+++ b/lib/api/metrics/user_starred_dashboards.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module API
+ module Metrics
+ class UserStarredDashboards < Grape::API
+ desc 'Marks selected metrics dashboard as starred' do
+ success Entities::Metrics::UserStarredDashboard
+ end
+
+ params do
+ requires :dashboard_path, type: String, allow_blank: false, coerce_with: ->(val) { CGI.unescape(val) },
+ desc: 'Url encoded path to a file defining the dashboard to which the star should be added'
+ end
+
+ resource :projects do
+ post ':id/metrics/user_starred_dashboards' do
+ result = ::Metrics::UsersStarredDashboards::CreateService.new(current_user, user_project, params[:dashboard_path]).execute
+
+ if result.success?
+ present result.payload, with: Entities::Metrics::UserStarredDashboard
+ else
+ error!({ errors: result.message }, 400)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/wikis.rb b/lib/api/wikis.rb
index a42cb9b49af..884e3019a2d 100644
--- a/lib/api/wikis.rb
+++ b/lib/api/wikis.rb
@@ -123,9 +123,11 @@ module API
post ":id/wikis/attachments" do
authorize! :create_wiki, user_project
- result = ::Wikis::CreateAttachmentService.new(user_project,
- current_user,
- commit_params(declared_params(include_missing: false))).execute
+ result = ::Wikis::CreateAttachmentService.new(
+ container: user_project,
+ current_user: current_user,
+ params: commit_params(declared_params(include_missing: false))
+ ).execute
if result[:status] == :success
status(201)
diff --git a/lib/gitlab/ci/config/entry/artifacts.rb b/lib/gitlab/ci/config/entry/artifacts.rb
index ea9d35f2ccc..a9a9636637f 100644
--- a/lib/gitlab/ci/config/entry/artifacts.rb
+++ b/lib/gitlab/ci/config/entry/artifacts.rb
@@ -12,7 +12,7 @@ module Gitlab
include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
- ALLOWED_KEYS = %i[name untracked paths reports when expire_in expose_as].freeze
+ ALLOWED_KEYS = %i[name untracked paths reports when expire_in expose_as exclude].freeze
EXPOSE_AS_REGEX = /\A\w[-\w ]*\z/.freeze
EXPOSE_AS_ERROR_MESSAGE = "can contain only letters, digits, '-', '_' and spaces"
@@ -35,6 +35,8 @@ module Gitlab
}, if: :expose_as_present?
validates :expose_as, type: String, length: { maximum: 100 }, if: :expose_as_present?
validates :expose_as, format: { with: EXPOSE_AS_REGEX, message: EXPOSE_AS_ERROR_MESSAGE }, if: :expose_as_present?
+ validates :exclude, array_of_strings: true, if: :exclude_enabled?
+ validates :exclude, absence: { message: 'feature is disabled' }, unless: :exclude_enabled?
validates :reports, type: Hash
validates :when,
inclusion: { in: %w[on_success on_failure always],
@@ -57,6 +59,10 @@ module Gitlab
!@config[:expose_as].nil?
end
+
+ def exclude_enabled?
+ ::Gitlab::Ci::Features.artifacts_exclude_enabled?
+ end
end
end
end
diff --git a/lib/gitlab/ci/features.rb b/lib/gitlab/ci/features.rb
new file mode 100644
index 00000000000..a8edb42194f
--- /dev/null
+++ b/lib/gitlab/ci/features.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ ##
+ # Ci::Features is a class that aggregates all CI/CD feature flags in one place.
+ #
+ module Features
+ def self.artifacts_exclude_enabled?
+ ::Feature.enabled?(:ci_artifacts_exclude, default_enabled: false)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml
index 7f98f0074d8..46f4aa3118f 100644
--- a/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml
@@ -1,5 +1,5 @@
.auto-deploy:
- image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v0.13.0"
+ image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v0.14.0"
review:
extends: .auto-deploy
diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb
index bf14bd4ab4b..08006657b07 100644
--- a/lib/gitlab/database/migration_helpers.rb
+++ b/lib/gitlab/database/migration_helpers.rb
@@ -502,7 +502,7 @@ module Gitlab
update_column_in_batches(table, column, default_after_type_cast, &block)
end
- add_not_null_constraint(table, column) unless allow_null
+ change_column_null(table, column, false) unless allow_null
# We want to rescue _all_ exceptions here, even those that don't inherit
# from StandardError.
rescue Exception => error # rubocop: disable all
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 0e227370741..981fc6f2b1f 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1796,6 +1796,9 @@ msgstr ""
msgid "AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear."
msgstr ""
+msgid "AlertManagement|There was an error while updating the status of the alert. Please try again."
+msgstr ""
+
msgid "AlertManagement|Tool"
msgstr ""
@@ -13076,6 +13079,9 @@ msgstr ""
msgid "Merge Request Approvals"
msgstr ""
+msgid "Merge Request Commits"
+msgstr ""
+
msgid "Merge Requests"
msgstr ""
@@ -13934,6 +13940,9 @@ msgstr ""
msgid "Next"
msgstr ""
+msgid "Next commit"
+msgstr ""
+
msgid "Next file in diff"
msgstr ""
@@ -15607,6 +15616,9 @@ msgstr ""
msgid "Press %{key}-C to copy"
msgstr ""
+msgid "Prev"
+msgstr ""
+
msgid "Prevent adding new members to project membership within this group"
msgstr ""
@@ -15643,6 +15655,9 @@ msgstr ""
msgid "Previous Artifacts"
msgstr ""
+msgid "Previous commit"
+msgstr ""
+
msgid "Previous file in diff"
msgstr ""
@@ -24618,6 +24633,12 @@ msgstr ""
msgid "You're about to reduce the visibility of the project %{strong_start}%{project_name}%{strong_end}."
msgstr ""
+msgid "You're at the first commit"
+msgstr ""
+
+msgid "You're at the last commit"
+msgstr ""
+
msgid "You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request."
msgstr ""
diff --git a/rubocop/cop/rspec/empty_line_after_shared_example.rb b/rubocop/cop/rspec/empty_line_after_shared_example.rb
new file mode 100644
index 00000000000..5d09565bd5a
--- /dev/null
+++ b/rubocop/cop/rspec/empty_line_after_shared_example.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'rubocop/rspec/final_end_location'
+require 'rubocop/rspec/blank_line_separation'
+require 'rubocop/rspec/language'
+
+module RuboCop
+ module Cop
+ module RSpec
+ # Checks if there is an empty line after shared example blocks.
+ #
+ # @example
+ # # bad
+ # RSpec.describe Foo do
+ # it_behaves_like 'do this first'
+ # it_behaves_like 'does this' do
+ # end
+ # it_behaves_like 'does that' do
+ # end
+ # it_behaves_like 'do some more'
+ # end
+ #
+ # # good
+ # RSpec.describe Foo do
+ # it_behaves_like 'do this first'
+ # it_behaves_like 'does this' do
+ # end
+ #
+ # it_behaves_like 'does that' do
+ # end
+ #
+ # it_behaves_like 'do some more'
+ # end
+ #
+ # # fair - it's ok to have non-separated without blocks
+ # RSpec.describe Foo do
+ # it_behaves_like 'do this first'
+ # it_behaves_like 'does this'
+ # end
+ #
+ class EmptyLineAfterSharedExample < RuboCop::Cop::Cop
+ include RuboCop::RSpec::BlankLineSeparation
+ include RuboCop::RSpec::Language
+
+ MSG = 'Add an empty line after `%<example>s` block.'
+
+ def_node_matcher :shared_examples,
+ (SharedGroups::ALL + Includes::ALL).block_pattern
+
+ def on_block(node)
+ shared_examples(node) do
+ break if last_child?(node)
+
+ missing_separating_line(node) do |location|
+ add_offense(node,
+ location: location,
+ message: format(MSG, example: node.method_name))
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb b/spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb
index 9a3fee5b43a..6bfec1b314e 100644
--- a/spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb
+++ b/spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb
@@ -14,16 +14,6 @@ describe Projects::DesignManagement::Designs::ResizedImageController do
let(:sha) { design.versions.first.sha }
before do
- # TODO these tests are being temporarily skipped unless run in EE,
- # as we are in the process of moving Design Management to FOSS in 13.0
- # in steps. In the current step the services have not yet been moved,
- # and the `design` factory used in this test uses the `:with_smaller_image_versions`
- # trait, which calls `GenerateImageVersionsService` to generate the
- # smaller image versions.
- #
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283.
- skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee?
-
enable_design_management
end
diff --git a/spec/frontend/alert_management/components/alert_management_list_spec.js b/spec/frontend/alert_management/components/alert_management_list_spec.js
index 662c39fc84d..df845884b03 100644
--- a/spec/frontend/alert_management/components/alert_management_list_spec.js
+++ b/spec/frontend/alert_management/components/alert_management_list_spec.js
@@ -4,16 +4,20 @@ import {
GlTable,
GlAlert,
GlLoadingIcon,
- GlNewDropdown,
+ GlDropdown,
GlBadge,
GlTab,
+ GlDropdownItem,
} from '@gitlab/ui';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import createFlash from '~/flash';
import AlertManagementList from '~/alert_management/components/alert_management_list.vue';
import { ALERTS_STATUS_TABS } from '../../../../app/assets/javascripts/alert_management/constants';
-
+import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.graphql';
import mockAlerts from '../mocks/alerts.json';
+jest.mock('~/flash');
+
describe('AlertManagementList', () => {
let wrapper;
@@ -21,10 +25,11 @@ describe('AlertManagementList', () => {
const findAlerts = () => wrapper.findAll('table tbody tr');
const findAlert = () => wrapper.find(GlAlert);
const findLoader = () => wrapper.find(GlLoadingIcon);
- const findStatusDropdown = () => wrapper.find(GlNewDropdown);
+ const findStatusDropdown = () => wrapper.find(GlDropdown);
const findStatusFilterTabs = () => wrapper.findAll(GlTab);
const findNumberOfAlertsBadge = () => wrapper.findAll(GlBadge);
const findDateFields = () => wrapper.findAll(TimeAgo);
+ const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem);
function mountComponent({
props = {
@@ -51,6 +56,7 @@ describe('AlertManagementList', () => {
},
mocks: {
$apollo: {
+ mutate: jest.fn(),
queries: {
alerts: {
loading,
@@ -191,6 +197,7 @@ describe('AlertManagementList', () => {
alerts: [
{
iid: 1,
+ status: 'acknowledged',
startedAt: '2020-03-17T23:18:14.996Z',
endedAt: '2020-04-17T23:18:14.996Z',
},
@@ -209,6 +216,7 @@ describe('AlertManagementList', () => {
alerts: [
{
iid: 1,
+ status: 'acknowledged',
startedAt: null,
endedAt: null,
},
@@ -221,4 +229,52 @@ describe('AlertManagementList', () => {
});
});
});
+
+ describe('updating the alert status', () => {
+ const iid = '1527542';
+ const mockUpdatedMutationResult = {
+ data: {
+ updateAlertStatus: {
+ errors: [],
+ alert: {
+ iid,
+ status: 'acknowledged',
+ },
+ },
+ },
+ };
+
+ beforeEach(() => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts, errored: false },
+ loading: false,
+ });
+ });
+
+ it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult);
+ findFirstStatusOption().vm.$emit('click');
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: updateAlertStatus,
+ variables: {
+ iid,
+ status: 'TRIGGERED',
+ projectPath: 'gitlab-org/gitlab',
+ },
+ });
+ });
+
+ it('calls `createFlash` when request fails', () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
+ findFirstStatusOption().vm.$emit('click');
+
+ setImmediate(() => {
+ expect(createFlash).toHaveBeenCalledWith(
+ 'There was an error while updating the status of the alert. Please try again.',
+ );
+ });
+ });
+ });
});
diff --git a/spec/frontend/alert_management/mocks/alerts.json b/spec/frontend/alert_management/mocks/alerts.json
index e0b8fa55507..5e6b96aed71 100644
--- a/spec/frontend/alert_management/mocks/alerts.json
+++ b/spec/frontend/alert_management/mocks/alerts.json
@@ -9,8 +9,8 @@
"status": "triggered"
},
{
- "iid": "1527542",
- "title": "Some otherr alert Some otherr alert Some otherr alert Some otherr alert Some otherr alert Some otherr alert",
+ "iid": "1527543",
+ "title": "Some other alert Some other alert Some other alert Some other alert Some other alert Some other alert",
"severity": "MEDIUM",
"eventCount": 1,
"startedAt": "2020-04-17T23:18:14.996Z",
@@ -18,7 +18,7 @@
"status": "acknowledged"
},
{
- "iid": "1527542",
+ "iid": "1527544",
"title": "SyntaxError: Invalid or unexpected token",
"severity": "LOW",
"eventCount": 4,
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index 1a95f586344..57e3a93c6f4 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -28,8 +28,14 @@ describe('diffs/components/app', () => {
let wrapper;
let mock;
- function createComponent(props = {}, extendStore = () => {}) {
+ function createComponent(props = {}, extendStore = () => {}, provisions = {}) {
const localVue = createLocalVue();
+ const provide = {
+ ...provisions,
+ glFeatures: {
+ ...(provisions.glFeatures || {}),
+ },
+ };
localVue.use(Vuex);
@@ -52,6 +58,7 @@ describe('diffs/components/app', () => {
showSuggestPopover: true,
...props,
},
+ provide,
store,
methods: {
isLatestVersion() {
@@ -82,7 +89,10 @@ describe('diffs/components/app', () => {
window.mrTabs = oldMrTabs;
// reset component
- wrapper.destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
mock.restore();
});
@@ -455,76 +465,109 @@ describe('diffs/components/app', () => {
});
describe('keyboard shortcut navigation', () => {
- const mappings = {
- '[': -1,
- k: -1,
- ']': +1,
- j: +1,
- };
- let spy;
+ let spies = [];
+ let jumpSpy;
+ let moveSpy;
+
+ function setup(componentProps, featureFlags) {
+ createComponent(
+ componentProps,
+ ({ state }) => {
+ state.diffs.commit = { id: 'SHA123' };
+ },
+ { glFeatures: { mrCommitNeighborNav: true, ...featureFlags } },
+ );
+
+ moveSpy = jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {});
+ jumpSpy = jest.fn();
+ spies = [jumpSpy, moveSpy];
+ wrapper.setMethods({
+ jumpToFile: jumpSpy,
+ });
+ }
describe('visible app', () => {
- beforeEach(() => {
- spy = jest.fn();
+ it.each`
+ key | name | spy | args | featureFlags
+ ${'['} | ${'jumpToFile'} | ${0} | ${[-1]} | ${{}}
+ ${'k'} | ${'jumpToFile'} | ${0} | ${[-1]} | ${{}}
+ ${']'} | ${'jumpToFile'} | ${0} | ${[+1]} | ${{}}
+ ${'j'} | ${'jumpToFile'} | ${0} | ${[+1]} | ${{}}
+ ${'x'} | ${'moveToNeighboringCommit'} | ${1} | ${[{ direction: 'previous' }]} | ${{ mrCommitNeighborNav: true }}
+ ${'c'} | ${'moveToNeighboringCommit'} | ${1} | ${[{ direction: 'next' }]} | ${{ mrCommitNeighborNav: true }}
+ `(
+ 'calls `$name()` with correct parameters whenever the "$key" key is pressed',
+ ({ key, spy, args, featureFlags }) => {
+ setup({ shouldShow: true }, featureFlags);
- createComponent({
- shouldShow: true,
- });
- wrapper.setMethods({
- jumpToFile: spy,
- });
- });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(spies[spy]).not.toHaveBeenCalled();
+
+ Mousetrap.trigger(key);
+
+ expect(spies[spy]).toHaveBeenCalledWith(...args);
+ });
+ },
+ );
+
+ it.each`
+ key | name | spy | featureFlags
+ ${'x'} | ${'moveToNeighboringCommit'} | ${1} | ${{ mrCommitNeighborNav: false }}
+ ${'c'} | ${'moveToNeighboringCommit'} | ${1} | ${{ mrCommitNeighborNav: false }}
+ `(
+ 'does not call `$name()` even when the correct key is pressed if the feature flag is disabled',
+ ({ key, spy, featureFlags }) => {
+ setup({ shouldShow: true }, featureFlags);
- it.each(Object.keys(mappings))(
- 'calls `jumpToFile()` with correct parameter whenever pre-defined %s is pressed',
- key => {
return wrapper.vm.$nextTick().then(() => {
- expect(spy).not.toHaveBeenCalled();
+ expect(spies[spy]).not.toHaveBeenCalled();
Mousetrap.trigger(key);
- expect(spy).toHaveBeenCalledWith(mappings[key]);
+ expect(spies[spy]).not.toHaveBeenCalled();
});
},
);
- it('does not call `jumpToFile()` when unknown key is pressed', done => {
- wrapper.vm
- .$nextTick()
- .then(() => {
- Mousetrap.trigger('d');
+ it.each`
+ key | name | spy | allowed
+ ${'d'} | ${'jumpToFile'} | ${0} | ${['[', ']', 'j', 'k']}
+ ${'r'} | ${'moveToNeighboringCommit'} | ${1} | ${['x', 'c']}
+ `(
+ `does not call \`$name()\` when a key that is not one of \`$allowed\` is pressed`,
+ ({ key, spy }) => {
+ setup({ shouldShow: true }, { mrCommitNeighborNav: true });
- expect(spy).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
+ return wrapper.vm.$nextTick().then(() => {
+ Mousetrap.trigger(key);
+
+ expect(spies[spy]).not.toHaveBeenCalled();
+ });
+ },
+ );
});
- describe('hideen app', () => {
+ describe('hidden app', () => {
beforeEach(() => {
- spy = jest.fn();
+ setup({ shouldShow: false }, { mrCommitNeighborNav: true });
- createComponent({
- shouldShow: false,
- });
- wrapper.setMethods({
- jumpToFile: spy,
+ return wrapper.vm.$nextTick().then(() => {
+ Mousetrap.reset();
});
});
- it('stops calling `jumpToFile()` when application is hidden', done => {
- wrapper.vm
- .$nextTick()
- .then(() => {
- Object.keys(mappings).forEach(key => {
- Mousetrap.trigger(key);
+ it.each`
+ key | name | spy
+ ${'['} | ${'jumpToFile'} | ${0}
+ ${'k'} | ${'jumpToFile'} | ${0}
+ ${']'} | ${'jumpToFile'} | ${0}
+ ${'j'} | ${'jumpToFile'} | ${0}
+ ${'x'} | ${'moveToNeighboringCommit'} | ${1}
+ ${'c'} | ${'moveToNeighboringCommit'} | ${1}
+ `('stops calling `$name()` when the app is hidden', ({ key, spy }) => {
+ Mousetrap.trigger(key);
- expect(spy).not.toHaveBeenCalled();
- });
- })
- .then(done)
- .catch(done.fail);
+ expect(spies[spy]).not.toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js
index 6bb3a0dcf21..0df951d43a7 100644
--- a/spec/frontend/diffs/components/commit_item_spec.js
+++ b/spec/frontend/diffs/components/commit_item_spec.js
@@ -13,6 +13,8 @@ const TEST_AUTHOR_EMAIL = 'test+test@gitlab.com';
const TEST_AUTHOR_GRAVATAR = `${TEST_HOST}/avatar/test?s=40`;
const TEST_SIGNATURE_HTML = '<a>Legit commit</a>';
const TEST_PIPELINE_STATUS_PATH = `${TEST_HOST}/pipeline/status`;
+const NEXT_COMMIT_URL = `${TEST_HOST}/?commit_id=next`;
+const PREV_COMMIT_URL = `${TEST_HOST}/?commit_id=prev`;
describe('diffs/components/commit_item', () => {
let wrapper;
@@ -30,12 +32,24 @@ describe('diffs/components/commit_item', () => {
const getCommitActionsElement = () => wrapper.find('.commit-actions');
const getCommitPipelineStatus = () => wrapper.find(CommitPipelineStatus);
- const mountComponent = propsData => {
+ const getCommitNavButtonsElement = () => wrapper.find('.commit-nav-buttons');
+ const getNextCommitNavElement = () =>
+ getCommitNavButtonsElement().find('.btn-group > *:last-child');
+ const getPrevCommitNavElement = () =>
+ getCommitNavButtonsElement().find('.btn-group > *:first-child');
+
+ const mountComponent = (propsData, featureFlags = {}) => {
wrapper = mount(Component, {
propsData: {
commit,
...propsData,
},
+ provide: {
+ glFeatures: {
+ mrCommitNeighborNav: true,
+ ...featureFlags,
+ },
+ },
stubs: {
CommitPipelineStatus: true,
},
@@ -173,4 +187,132 @@ describe('diffs/components/commit_item', () => {
expect(getCommitPipelineStatus().exists()).toBe(true);
});
});
+
+ describe('without neighbor commits', () => {
+ beforeEach(() => {
+ mountComponent({ commit: { ...commit, prev_commit_id: null, next_commit_id: null } });
+ });
+
+ it('does not render any navigation buttons', () => {
+ expect(getCommitNavButtonsElement().exists()).toEqual(false);
+ });
+ });
+
+ describe('with neighbor commits', () => {
+ let mrCommit;
+
+ beforeEach(() => {
+ mrCommit = {
+ ...commit,
+ next_commit_id: 'next',
+ prev_commit_id: 'prev',
+ };
+
+ mountComponent({ commit: mrCommit });
+ });
+
+ it('renders the commit navigation buttons', () => {
+ expect(getCommitNavButtonsElement().exists()).toEqual(true);
+
+ mountComponent({
+ commit: { ...mrCommit, next_commit_id: null },
+ });
+ expect(getCommitNavButtonsElement().exists()).toEqual(true);
+
+ mountComponent({
+ commit: { ...mrCommit, prev_commit_id: null },
+ });
+ expect(getCommitNavButtonsElement().exists()).toEqual(true);
+ });
+
+ it('does not render the commit navigation buttons if the `mrCommitNeighborNav` feature flag is disabled', () => {
+ mountComponent({ commit: mrCommit }, { mrCommitNeighborNav: false });
+
+ expect(getCommitNavButtonsElement().exists()).toEqual(false);
+ });
+
+ describe('prev commit', () => {
+ const { location } = window;
+
+ beforeAll(() => {
+ delete window.location;
+ window.location = { href: `${TEST_HOST}?commit_id=${mrCommit.id}` };
+ });
+
+ beforeEach(() => {
+ jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {});
+ });
+
+ afterAll(() => {
+ window.location = location;
+ });
+
+ it('uses the correct href', () => {
+ const link = getPrevCommitNavElement();
+
+ expect(link.element.getAttribute('href')).toEqual(PREV_COMMIT_URL);
+ });
+
+ it('triggers the correct Vuex action on click', () => {
+ const link = getPrevCommitNavElement();
+
+ link.trigger('click');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.moveToNeighboringCommit).toHaveBeenCalledWith({
+ direction: 'previous',
+ });
+ });
+ });
+
+ it('renders a disabled button when there is no prev commit', () => {
+ mountComponent({ commit: { ...mrCommit, prev_commit_id: null } });
+
+ const button = getPrevCommitNavElement();
+
+ expect(button.element.tagName).toEqual('BUTTON');
+ expect(button.element.hasAttribute('disabled')).toEqual(true);
+ });
+ });
+
+ describe('next commit', () => {
+ const { location } = window;
+
+ beforeAll(() => {
+ delete window.location;
+ window.location = { href: `${TEST_HOST}?commit_id=${mrCommit.id}` };
+ });
+
+ beforeEach(() => {
+ jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {});
+ });
+
+ afterAll(() => {
+ window.location = location;
+ });
+
+ it('uses the correct href', () => {
+ const link = getNextCommitNavElement();
+
+ expect(link.element.getAttribute('href')).toEqual(NEXT_COMMIT_URL);
+ });
+
+ it('triggers the correct Vuex action on click', () => {
+ const link = getNextCommitNavElement();
+
+ link.trigger('click');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.moveToNeighboringCommit).toHaveBeenCalledWith({ direction: 'next' });
+ });
+ });
+
+ it('renders a disabled button when there is no next commit', () => {
+ mountComponent({ commit: { ...mrCommit, next_commit_id: null } });
+
+ const button = getNextCommitNavElement();
+
+ expect(button.element.tagName).toEqual('BUTTON');
+ expect(button.element.hasAttribute('disabled')).toEqual(true);
+ });
+ });
+ });
});
diff --git a/spec/lib/gitlab/ci/config/entry/artifacts_spec.rb b/spec/lib/gitlab/ci/config/entry/artifacts_spec.rb
index 53469159110..8cfd07df777 100644
--- a/spec/lib/gitlab/ci/config/entry/artifacts_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/artifacts_spec.rb
@@ -124,5 +124,55 @@ describe Gitlab::Ci::Config::Entry::Artifacts do
end
end
end
+
+ describe 'excluded artifacts' do
+ context 'when configuration is valid and the feature is enabled' do
+ before do
+ stub_feature_flags(ci_artifacts_exclude: true)
+ end
+
+ context 'when configuration is valid' do
+ let(:config) { { untracked: true, exclude: ['some/directory/'] } }
+
+ it 'correctly parses the configuration' do
+ expect(entry).to be_valid
+ expect(entry.value).to eq config
+ end
+ end
+
+ context 'when configuration is not valid' do
+ let(:config) { { untracked: true, exclude: 1234 } }
+
+ it 'returns an error' do
+ expect(entry).not_to be_valid
+ expect(entry.errors)
+ .to include 'artifacts exclude should be an array of strings'
+ end
+ end
+ end
+
+ context 'when artifacts/exclude feature is disabled' do
+ before do
+ stub_feature_flags(ci_artifacts_exclude: false)
+ end
+
+ context 'when configuration has been provided' do
+ let(:config) { { untracked: true, exclude: ['some/directory/'] } }
+
+ it 'returns an error' do
+ expect(entry).not_to be_valid
+ expect(entry.errors).to include 'artifacts exclude feature is disabled'
+ end
+ end
+
+ context 'when configuration is not present' do
+ let(:config) { { untracked: true } }
+
+ it 'is a valid configuration' do
+ expect(entry).to be_valid
+ end
+ end
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb
index 969a674a308..c93bb901981 100644
--- a/spec/lib/gitlab/ci/yaml_processor_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb
@@ -1364,6 +1364,24 @@ module Gitlab
expect { described_class.new(config) }.to raise_error(described_class::ValidationError)
end
+
+ it 'populates a build options with complete artifacts configuration' do
+ stub_feature_flags(ci_artifacts_exclude: true)
+
+ config = <<~YAML
+ test:
+ script: echo "Hello World"
+ artifacts:
+ paths:
+ - my/test
+ exclude:
+ - my/test/something
+ YAML
+
+ attributes = Gitlab::Ci::YamlProcessor.new(config).build_attributes('test')
+
+ expect(attributes.dig(*%i[options artifacts exclude])).to eq(%w[my/test/something])
+ end
end
describe "release" do
diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb
index 73b9dcc5bf9..9ed4db673a6 100644
--- a/spec/lib/gitlab/database/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers_spec.rb
@@ -647,7 +647,7 @@ describe Gitlab::Database::MigrationHelpers do
expect(model).to receive(:update_column_in_batches)
.with(:projects, :foo, 10)
- expect(model).not_to receive(:add_not_null_constraint)
+ expect(model).not_to receive(:change_column_null)
model.add_column_with_default(:projects, :foo, :integer,
default: 10,
@@ -658,8 +658,8 @@ describe Gitlab::Database::MigrationHelpers do
expect(model).to receive(:update_column_in_batches)
.with(:projects, :foo, 10)
- expect(model).to receive(:add_not_null_constraint)
- .with(:projects, :foo)
+ expect(model).to receive(:change_column_null)
+ .with(:projects, :foo, false)
model.add_column_with_default(:projects, :foo, :integer, default: 10)
end
@@ -678,8 +678,8 @@ describe Gitlab::Database::MigrationHelpers do
end
it 'removes the added column whenever changing a column NULL constraint fails' do
- expect(model).to receive(:add_not_null_constraint)
- .with(:projects, :foo)
+ expect(model).to receive(:change_column_null)
+ .with(:projects, :foo, false)
.and_raise(ActiveRecord::ActiveRecordError)
expect(model).to receive(:remove_column)
@@ -699,7 +699,7 @@ describe Gitlab::Database::MigrationHelpers do
allow(model).to receive(:transaction).and_yield
allow(model).to receive(:column_for).with(:user_details, :foo).and_return(column)
allow(model).to receive(:update_column_in_batches).with(:user_details, :foo, 10, batch_column_name: :user_id)
- allow(model).to receive(:add_not_null_constraint).with(:user_details, :foo)
+ allow(model).to receive(:change_column_null).with(:user_details, :foo, false)
allow(model).to receive(:change_column_default).with(:user_details, :foo, 10)
expect(model).to receive(:add_column)
@@ -721,7 +721,7 @@ describe Gitlab::Database::MigrationHelpers do
allow(model).to receive(:transaction).and_yield
allow(model).to receive(:column_for).with(:projects, :foo).and_return(column)
allow(model).to receive(:update_column_in_batches).with(:projects, :foo, 10)
- allow(model).to receive(:add_not_null_constraint).with(:projects, :foo)
+ allow(model).to receive(:change_column_null).with(:projects, :foo, false)
allow(model).to receive(:change_column_default).with(:projects, :foo, 10)
expect(model).to receive(:add_column)
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index 6e1dba6945d..3c66902bb2e 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -70,6 +70,7 @@ describe Notify do
it_behaves_like 'an email starting a new thread with reply-by-email enabled' do
let(:model) { issue }
end
+
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -116,6 +117,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
+
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -155,6 +157,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
+
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'a user cannot unsubscribe through footer link'
it_behaves_like 'an email with a labels subscriptions link in its footer'
@@ -200,6 +203,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
+
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -214,6 +218,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
+
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -248,6 +253,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
+
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
@@ -300,6 +306,7 @@ describe Notify do
it_behaves_like 'an email starting a new thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -344,6 +351,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like "an unsubscribeable thread"
it_behaves_like 'appearance header and footer enabled'
@@ -409,6 +417,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'a user cannot unsubscribe through footer link'
it_behaves_like 'an email with a labels subscriptions link in its footer'
@@ -436,6 +445,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -466,6 +476,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -502,6 +513,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -533,6 +545,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -694,6 +707,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { project_snippet }
end
+
it_behaves_like 'a user cannot unsubscribe through footer link'
it 'has the correct subject' do
@@ -936,6 +950,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { commit }
end
+
it_behaves_like 'it should show Gmail Actions View Commit link'
it_behaves_like 'a user cannot unsubscribe through footer link'
it_behaves_like 'appearance header and footer enabled'
@@ -962,6 +977,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -988,6 +1004,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
+
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -1060,6 +1077,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { commit }
end
+
it_behaves_like 'it should show Gmail Actions View Commit link'
it_behaves_like 'a user cannot unsubscribe through footer link'
it_behaves_like 'appearance header and footer enabled'
@@ -1092,6 +1110,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -1124,6 +1143,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
+
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 985bb9d3194..02558ef21ee 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -4088,6 +4088,28 @@ describe Ci::Build do
it { is_expected.to include(:upload_multiple_artifacts) }
end
+
+ context 'when artifacts exclude is defined and the is feature enabled' do
+ let(:options) do
+ { artifacts: { exclude: %w[something] } }
+ end
+
+ context 'when a feature flag is enabled' do
+ before do
+ stub_feature_flags(ci_artifacts_exclude: true)
+ end
+
+ it { is_expected.to include(:artifacts_exclude) }
+ end
+
+ context 'when a feature flag is disabled' do
+ before do
+ stub_feature_flags(ci_artifacts_exclude: false)
+ end
+
+ it { is_expected.not_to include(:artifacts_exclude) }
+ end
+ end
end
describe '#supported_runner?' do
diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb
index 4900743ccb5..6dd295ca915 100644
--- a/spec/models/note_spec.rb
+++ b/spec/models/note_spec.rb
@@ -105,6 +105,38 @@ describe Note do
end
end
+ describe 'callbacks' do
+ describe '#notify_after_create' do
+ it 'calls #after_note_created on the noteable' do
+ note = build(:note)
+
+ expect(note).to receive(:notify_after_create).and_call_original
+ expect(note.noteable).to receive(:after_note_created).with(note)
+
+ note.save!
+ end
+ end
+
+ describe '#notify_after_destroy' do
+ it 'calls #after_note_destroyed on the noteable' do
+ note = create(:note)
+
+ expect(note).to receive(:notify_after_destroy).and_call_original
+ expect(note.noteable).to receive(:after_note_destroyed).with(note)
+
+ note.destroy
+ end
+
+ it 'does not error if noteable is nil' do
+ note = create(:note)
+
+ expect(note).to receive(:notify_after_destroy).and_call_original
+ expect(note).to receive(:noteable).at_least(:once).and_return(nil)
+ expect { note.destroy }.not_to raise_error
+ end
+ end
+ end
+
describe "Commit notes" do
before do
allow(Gitlab::Git::KeepAround).to receive(:execute).and_call_original
diff --git a/spec/presenters/ci/build_runner_presenter_spec.rb b/spec/presenters/ci/build_runner_presenter_spec.rb
index 6b2b031dbd0..cfa2e77e7cf 100644
--- a/spec/presenters/ci/build_runner_presenter_spec.rb
+++ b/spec/presenters/ci/build_runner_presenter_spec.rb
@@ -38,6 +38,47 @@ describe Ci::BuildRunnerPresenter do
expect(presenter.artifacts).to be_empty
end
end
+
+ context 'when artifacts exclude is defined' do
+ let(:build) do
+ create(:ci_build, options: { artifacts: { paths: %w[abc], exclude: %w[cde] } })
+ end
+
+ context 'when the feature is enabled' do
+ before do
+ stub_feature_flags(ci_artifacts_exclude: true)
+ end
+
+ it 'includes the list of excluded paths' do
+ expect(presenter.artifacts.first).to include(
+ artifact_type: :archive,
+ artifact_format: :zip,
+ paths: %w[abc],
+ exclude: %w[cde]
+ )
+ end
+ end
+
+ context 'when the feature is disabled' do
+ before do
+ stub_feature_flags(ci_artifacts_exclude: false)
+ end
+
+ it 'does not include the list of excluded paths' do
+ expect(presenter.artifacts.first).not_to have_key(:exclude)
+ end
+ end
+ end
+
+ context 'when artifacts exclude is not defined' do
+ let(:build) do
+ create(:ci_build, options: { artifacts: { paths: %w[abc] } })
+ end
+
+ it 'does not include an empty list of excluded paths' do
+ expect(presenter.artifacts.first).not_to have_key(:exclude)
+ end
+ end
end
context "with reports" do
diff --git a/spec/requests/api/metrics/user_starred_dashboards_spec.rb b/spec/requests/api/metrics/user_starred_dashboards_spec.rb
new file mode 100644
index 00000000000..4b3c6407ef7
--- /dev/null
+++ b/spec/requests/api/metrics/user_starred_dashboards_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe API::Metrics::UserStarredDashboards do
+ let_it_be(:user) { create(:user) }
+ let!(:project) { create(:project, :private, :repository, :custom_repo, namespace: user.namespace, files: { dashboard => dashboard_yml }) }
+ let(:dashboard_yml) { fixture_file('lib/gitlab/metrics/dashboard/sample_dashboard.yml') }
+ let(:dashboard) { '.gitlab/dashboards/find&seek.yml' }
+ let(:params) do
+ {
+ user: user,
+ project: project,
+ dashboard_path: CGI.escape(dashboard)
+ }
+ end
+
+ describe 'POST /projects/:id/metrics/user_starred_dashboards' do
+ let(:url) { "/projects/#{project.id}/metrics/user_starred_dashboards" }
+
+ before do
+ project.add_reporter(user)
+ end
+
+ context 'with correct permissions' do
+ context 'with valid parameters' do
+ context 'dashboard_path as url param url escaped' do
+ it 'creates a new annotation', :aggregate_failures do
+ post api(url, user), params: params
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['project_id']).to eq(project.id)
+ expect(json_response['user_id']).to eq(user.id)
+ expect(json_response['dashboard_path']).to eq(dashboard)
+ end
+ end
+
+ context 'dashboard_path in request body unescaped' do
+ let(:params) do
+ {
+ user: user,
+ project: project,
+ dashboard_path: dashboard
+ }
+ end
+
+ it 'creates a new annotation', :aggregate_failures do
+ post api(url, user), params: params
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['project_id']).to eq(project.id)
+ expect(json_response['user_id']).to eq(user.id)
+ expect(json_response['dashboard_path']).to eq(dashboard)
+ end
+ end
+ end
+
+ context 'with invalid parameters' do
+ it 'returns error message' do
+ post api(url, user), params: { dashboard_path: '' }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq('dashboard_path is empty')
+ end
+
+ context 'user is missing' do
+ it 'returns 404 not found' do
+ post api(url, nil), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'project is missing' do
+ it 'returns 404 not found' do
+ post api("/projects/#{project.id + 1}/user_starred_dashboards", user), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
+ context 'without correct permissions' do
+ it 'returns 404 not found' do
+ post api(url, create(:user)), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb
index bc2da8a2b9a..5327a3a77c5 100644
--- a/spec/requests/api/runner_spec.rb
+++ b/spec/requests/api/runner_spec.rb
@@ -998,6 +998,53 @@ describe API::Runner, :clean_gitlab_redis_shared_state do
end
end
+ describe 'a job with excluded artifacts' do
+ context 'when excluded paths are defined' do
+ let(:job) do
+ create(:ci_build, pipeline: pipeline, token: 'test-job-token', name: 'test',
+ stage: 'deploy', stage_idx: 1,
+ options: { artifacts: { paths: ['abc'], exclude: ['cde'] } })
+ end
+
+ context 'when a runner supports this feature' do
+ it 'exposes excluded paths when the feature is enabled' do
+ stub_feature_flags(ci_artifacts_exclude: true)
+
+ request_job info: { features: { artifacts_exclude: true } }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response.dig('artifacts').first).to include('exclude' => ['cde'])
+ end
+
+ it 'does not expose excluded paths when the feature is disabled' do
+ stub_feature_flags(ci_artifacts_exclude: false)
+
+ request_job info: { features: { artifacts_exclude: true } }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response.dig('artifacts').first).not_to have_key('exclude')
+ end
+ end
+
+ context 'when a runner does not support this feature' do
+ it 'does not expose the build at all' do
+ stub_feature_flags(ci_artifacts_exclude: true)
+
+ request_job
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+ end
+
+ it 'does not expose excluded paths when these are empty' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response.dig('artifacts').first).not_to have_key('exclude')
+ end
+ end
+
def request_job(token = runner.token, **params)
new_params = params.merge(token: token, last_update: last_update)
post api('/jobs/request'), params: new_params, headers: { 'User-Agent' => user_agent }
diff --git a/spec/rubocop/cop/rspec/empty_line_after_shared_example_spec.rb b/spec/rubocop/cop/rspec/empty_line_after_shared_example_spec.rb
new file mode 100644
index 00000000000..cee593fe535
--- /dev/null
+++ b/spec/rubocop/cop/rspec/empty_line_after_shared_example_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_relative '../../../../rubocop/cop/rspec/empty_line_after_shared_example'
+
+describe RuboCop::Cop::RSpec::EmptyLineAfterSharedExample do
+ subject(:cop) { described_class.new }
+
+ it 'flags a missing empty line after `it_behaves_like` block' do
+ expect_offense(<<-RUBY)
+ RSpec.describe Foo do
+ it_behaves_like 'does this' do
+ end
+ ^^^ Add an empty line after `it_behaves_like` block.
+ it_behaves_like 'does that' do
+ end
+ end
+ RUBY
+
+ expect_correction(<<-RUBY)
+ RSpec.describe Foo do
+ it_behaves_like 'does this' do
+ end
+
+ it_behaves_like 'does that' do
+ end
+ end
+ RUBY
+ end
+
+ it 'ignores one-line shared examples before shared example blocks' do
+ expect_no_offenses(<<-RUBY)
+ RSpec.describe Foo do
+ it_behaves_like 'does this'
+ it_behaves_like 'does that' do
+ end
+ end
+ RUBY
+ end
+
+ it 'flags a missing empty line after `shared_examples`' do
+ expect_offense(<<-RUBY)
+ RSpec.context 'foo' do
+ shared_examples do
+ end
+ ^^^ Add an empty line after `shared_examples` block.
+ shared_examples 'something gets done' do
+ end
+ end
+ RUBY
+
+ expect_correction(<<-RUBY)
+ RSpec.context 'foo' do
+ shared_examples do
+ end
+
+ shared_examples 'something gets done' do
+ end
+ end
+ RUBY
+ end
+
+ it 'ignores consecutive one-liners' do
+ expect_no_offenses(<<-RUBY)
+ RSpec.describe Foo do
+ it_behaves_like 'do this'
+ it_behaves_like 'do that'
+ end
+ RUBY
+ end
+
+ it 'flags mixed one-line and multi-line shared examples' do
+ expect_offense(<<-RUBY)
+ RSpec.context 'foo' do
+ it_behaves_like 'do this'
+ it_behaves_like 'do that'
+ it_behaves_like 'does this' do
+ end
+ ^^^ Add an empty line after `it_behaves_like` block.
+ it_behaves_like 'do this'
+ it_behaves_like 'do that'
+ end
+ RUBY
+ end
+end
diff --git a/spec/services/design_management/delete_designs_service_spec.rb b/spec/services/design_management/delete_designs_service_spec.rb
new file mode 100644
index 00000000000..2c0c1570cb4
--- /dev/null
+++ b/spec/services/design_management/delete_designs_service_spec.rb
@@ -0,0 +1,195 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe DesignManagement::DeleteDesignsService do
+ include DesignManagementTestHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:user) { create(:user) }
+ let(:designs) { create_designs }
+
+ subject(:service) { described_class.new(project, user, issue: issue, designs: designs) }
+
+ # Defined as a method so that the reponse is not cached. We also construct
+ # a new service executor each time to avoid the intermediate cached values
+ # it constructs during its execution.
+ def run_service(delenda = nil)
+ service = described_class.new(project, user, issue: issue, designs: delenda || designs)
+ service.execute
+ end
+
+ let(:response) { run_service }
+
+ shared_examples 'a service error' do
+ it 'returns an error', :aggregate_failures do
+ expect(response).to include(status: :error)
+ end
+ end
+
+ shared_examples 'a top-level error' do
+ let(:expected_error) { StandardError }
+ it 'raises an en expected error', :aggregate_failures do
+ expect { run_service }.to raise_error(expected_error)
+ end
+ end
+
+ shared_examples 'a success' do
+ it 'returns successfully', :aggregate_failures do
+ expect(response).to include(status: :success)
+ end
+
+ it 'saves the user as the author' do
+ version = response[:version]
+
+ expect(version.author).to eq(user)
+ end
+ end
+
+ before do
+ enable_design_management(enabled)
+ project.add_developer(user)
+ end
+
+ describe "#execute" do
+ context "when the feature is not available" do
+ let(:enabled) { false }
+
+ it_behaves_like "a service error"
+ end
+
+ context "when the feature is available" do
+ let(:enabled) { true }
+
+ it 'is able to delete designs' do
+ expect(service.send(:can_delete_designs?)).to be true
+ end
+
+ context 'no designs were passed' do
+ let(:designs) { [] }
+
+ it_behaves_like "a top-level error"
+
+ it 'does not log any events' do
+ counter = ::Gitlab::UsageDataCounters::DesignsCounter
+ expect { run_service rescue nil }.not_to change { counter.totals }
+ end
+ end
+
+ context 'one design is passed' do
+ before do
+ create_designs(2)
+ end
+
+ let!(:designs) { create_designs(1) }
+
+ it 'removes that design' do
+ expect { run_service }.to change { issue.designs.current.count }.from(3).to(2)
+ end
+
+ it 'logs a deletion event' do
+ counter = ::Gitlab::UsageDataCounters::DesignsCounter
+ expect { run_service }.to change { counter.read(:delete) }.by(1)
+ end
+
+ it 'informs the new-version-worker' do
+ expect(::DesignManagement::NewVersionWorker).to receive(:perform_async).with(Integer)
+
+ run_service
+ end
+
+ it 'creates a new version' do
+ expect { run_service }.to change { DesignManagement::Version.where(issue: issue).count }.by(1)
+ end
+
+ it 'returns the new version' do
+ version = response[:version]
+
+ expect(version).to eq(DesignManagement::Version.for_issue(issue).ordered.first)
+ end
+
+ it_behaves_like "a success"
+
+ it 'removes the design from the current design list' do
+ run_service
+
+ expect(issue.designs.current).not_to include(designs.first)
+ end
+
+ it 'marks the design as deleted' do
+ expect { run_service }
+ .to change { designs.first.deleted? }.from(false).to(true)
+ end
+ end
+
+ context 'more than one design is passed' do
+ before do
+ create_designs(1)
+ end
+
+ let!(:designs) { create_designs(2) }
+
+ it 'removes those designs' do
+ expect { run_service }
+ .to change { issue.designs.current.count }.from(3).to(1)
+ end
+
+ it 'logs the correct number of deletion events' do
+ counter = ::Gitlab::UsageDataCounters::DesignsCounter
+ expect { run_service }.to change { counter.read(:delete) }.by(2)
+ end
+
+ it_behaves_like "a success"
+
+ context 'after executing the service' do
+ let(:deleted_designs) { designs.map(&:reset) }
+
+ let!(:version) { run_service[:version] }
+
+ it 'removes the removed designs from the current design list' do
+ expect(issue.designs.current).not_to include(*deleted_designs)
+ end
+
+ it 'does not make the designs impossible to find' do
+ expect(issue.designs).to include(*deleted_designs)
+ end
+
+ it 'associates the new version with all the designs' do
+ current_versions = deleted_designs.map { |d| d.most_recent_action.version }
+ expect(current_versions).to all(eq version)
+ end
+
+ it 'marks all deleted designs as deleted' do
+ expect(deleted_designs).to all(be_deleted)
+ end
+
+ it 'marks all deleted designs with the same deletion version' do
+ expect(deleted_designs.map { |d| d.most_recent_action.version_id }.uniq)
+ .to have_attributes(size: 1)
+ end
+ end
+ end
+
+ describe 'scalability' do
+ before do
+ run_service(create_designs(1)) # ensure project, issue, etc are created
+ end
+
+ it 'makes the same number of DB requests for one design as for several' do
+ one = create_designs(1)
+ many = create_designs(5)
+
+ baseline = ActiveRecord::QueryRecorder.new { run_service(one) }
+
+ expect { run_service(many) }.not_to exceed_query_limit(baseline)
+ end
+ end
+ end
+ end
+
+ private
+
+ def create_designs(how_many = 2)
+ create_list(:design, how_many, :with_lfs_file, issue: issue)
+ end
+end
diff --git a/spec/services/design_management/design_user_notes_count_service_spec.rb b/spec/services/design_management/design_user_notes_count_service_spec.rb
index f4808542995..62211a4dd0f 100644
--- a/spec/services/design_management/design_user_notes_count_service_spec.rb
+++ b/spec/services/design_management/design_user_notes_count_service_spec.rb
@@ -23,15 +23,8 @@ describe DesignManagement::DesignUserNotesCountService, :use_clean_rails_memory_
end
end
- # TODO These tests are being temporarily skipped unless run in EE,
- # as we are in the process of moving Design Management to FOSS in 13.0
- # in steps. In the current step the services have not yet been moved.
- #
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283.
describe 'cache invalidation' do
it 'changes when a new note is created' do
- skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee?
-
new_note_attrs = attributes_for(:diff_note_on_design, noteable: design)
expect do
@@ -40,8 +33,6 @@ describe DesignManagement::DesignUserNotesCountService, :use_clean_rails_memory_
end
it 'changes when a note is destroyed' do
- skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee?
-
note = create(:diff_note_on_design, noteable: design)
expect do
diff --git a/spec/services/design_management/generate_image_versions_service_spec.rb b/spec/services/design_management/generate_image_versions_service_spec.rb
new file mode 100644
index 00000000000..cd021c8d7d3
--- /dev/null
+++ b/spec/services/design_management/generate_image_versions_service_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignManagement::GenerateImageVersionsService do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:version) { create(:design, :with_lfs_file, issue: issue).versions.first }
+ let_it_be(:action) { version.actions.first }
+
+ describe '#execute' do
+ it 'generates the image' do
+ expect { described_class.new(version).execute }
+ .to change { action.reload.image_v432x230.file }
+ .from(nil).to(CarrierWave::SanitizedFile)
+ end
+
+ it 'skips generating image versions if the mime type is not whitelisted' do
+ stub_const('DesignManagement::DesignV432x230Uploader::MIME_TYPE_WHITELIST', [])
+
+ described_class.new(version).execute
+
+ expect(action.reload.image_v432x230.file).to eq(nil)
+ end
+
+ it 'skips generating image versions if the design file size is too large' do
+ stub_const("#{described_class.name}::MAX_DESIGN_SIZE", 1.byte)
+
+ described_class.new(version).execute
+
+ expect(action.reload.image_v432x230.file).to eq(nil)
+ end
+
+ it 'returns the status' do
+ result = described_class.new(version).execute
+
+ expect(result[:status]).to eq(:success)
+ end
+
+ it 'returns the version' do
+ result = described_class.new(version).execute
+
+ expect(result[:version]).to eq(version)
+ end
+
+ it 'logs if the raw image cannot be found' do
+ version.designs.first.update(filename: 'foo.png')
+
+ expect(Gitlab::AppLogger).to receive(:error).with("No design file found for Action: #{action.id}")
+
+ described_class.new(version).execute
+ end
+
+ context 'when an error is encountered when generating the image versions' do
+ before do
+ expect_next_instance_of(DesignManagement::DesignV432x230Uploader) do |uploader|
+ expect(uploader).to receive(:cache!).and_raise(CarrierWave::DownloadError, 'foo')
+ end
+ end
+
+ it 'logs the error' do
+ expect(Gitlab::AppLogger).to receive(:error).with('foo')
+
+ described_class.new(version).execute
+ end
+
+ it 'tracks the error' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
+ instance_of(CarrierWave::DownloadError),
+ project_id: project.id, version_id: version.id, design_id: version.designs.first.id
+ )
+
+ described_class.new(version).execute
+ end
+ end
+ end
+end
diff --git a/spec/services/design_management/save_designs_service_spec.rb b/spec/services/design_management/save_designs_service_spec.rb
new file mode 100644
index 00000000000..013d5473860
--- /dev/null
+++ b/spec/services/design_management/save_designs_service_spec.rb
@@ -0,0 +1,356 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe DesignManagement::SaveDesignsService do
+ include DesignManagementTestHelpers
+ include ConcurrentHelpers
+
+ let_it_be(:developer) { create(:user) }
+ let(:project) { issue.project }
+ let(:issue) { create(:issue) }
+ let(:user) { developer }
+ let(:files) { [rails_sample] }
+ let(:design_repository) { ::Gitlab::GlRepository::DESIGN.repository_resolver.call(project) }
+ let(:rails_sample_name) { 'rails_sample.jpg' }
+ let(:rails_sample) { sample_image(rails_sample_name) }
+ let(:dk_png) { sample_image('dk.png') }
+
+ def sample_image(filename)
+ fixture_file_upload("spec/fixtures/#{filename}")
+ end
+
+ before do
+ project.add_developer(developer)
+ end
+
+ def run_service(files_to_upload = nil)
+ design_files = files_to_upload || files
+ design_files.each(&:rewind)
+
+ service = described_class.new(project, user,
+ issue: issue,
+ files: design_files)
+ service.execute
+ end
+
+ # Randomly alter the content of files.
+ # This allows the files to be updated by the service, as unmodified
+ # files are rejected.
+ def touch_files(files_to_touch = nil)
+ design_files = files_to_touch || files
+
+ design_files.each do |f|
+ f.tempfile.write(SecureRandom.random_bytes)
+ end
+ end
+
+ let(:response) { run_service }
+
+ shared_examples 'a service error' do
+ it 'returns an error', :aggregate_failures do
+ expect(response).to match(a_hash_including(status: :error))
+ end
+ end
+
+ shared_examples 'an execution error' do
+ it 'returns an error', :aggregate_failures do
+ expect { service.execute }.to raise_error(some_error)
+ end
+ end
+
+ describe '#execute' do
+ context 'when the feature is not available' do
+ before do
+ enable_design_management(false)
+ end
+
+ it_behaves_like 'a service error'
+ end
+
+ context 'when the feature is available' do
+ before do
+ enable_design_management(true)
+ end
+
+ describe 'repository existence' do
+ def repository_exists
+ # Expire the memoized value as the service creates it's own instance
+ design_repository.expire_exists_cache
+ design_repository.exists?
+ end
+
+ it 'creates a design repository when it did not exist' do
+ expect { run_service }.to change { repository_exists }.from(false).to(true)
+ end
+ end
+
+ it 'updates the creation count' do
+ counter = Gitlab::UsageDataCounters::DesignsCounter
+ expect { run_service }.to change { counter.read(:create) }.by(1)
+ end
+
+ it 'creates a commit in the repository' do
+ run_service
+
+ expect(design_repository.commit).to have_attributes(
+ author: user,
+ message: include(rails_sample_name)
+ )
+ end
+
+ it 'can run the same command in parallel' do
+ blocks = Array.new(10).map do
+ unique_files = %w(rails_sample.jpg dk.png)
+ .map { |name| RenameableUpload.unique_file(name) }
+
+ -> { run_service(unique_files) }
+ end
+
+ expect { run_parallel(blocks) }.to change(DesignManagement::Version, :count).by(10)
+ end
+
+ it 'causes diff_refs not to be nil' do
+ expect(response).to include(
+ designs: all(have_attributes(diff_refs: be_present))
+ )
+ end
+
+ it 'creates a design & a version for the filename if it did not exist' do
+ expect(issue.designs.size).to eq(0)
+
+ updated_designs = response[:designs]
+
+ expect(updated_designs.size).to eq(1)
+ expect(updated_designs.first.versions.size).to eq(1)
+ end
+
+ it 'saves the user as the author' do
+ updated_designs = response[:designs]
+
+ expect(updated_designs.first.versions.first.author).to eq(user)
+ end
+
+ describe 'saving the file to LFS' do
+ before do
+ expect_next_instance_of(Lfs::FileTransformer) do |transformer|
+ expect(transformer).to receive(:lfs_file?).and_return(true)
+ end
+ end
+
+ it 'saves the design to LFS' do
+ expect { run_service }.to change { LfsObject.count }.by(1)
+ end
+
+ it 'saves the repository_type of the LfsObjectsProject as design' do
+ expect do
+ run_service
+ end.to change { project.lfs_objects_projects.count }.from(0).to(1)
+
+ expect(project.lfs_objects_projects.first.repository_type).to eq('design')
+ end
+ end
+
+ context 'when a design is being updated' do
+ before do
+ run_service
+ touch_files
+ end
+
+ it 'creates a new version for the existing design and updates the file' do
+ expect(issue.designs.size).to eq(1)
+ expect(DesignManagement::Version.for_designs(issue.designs).size).to eq(1)
+
+ updated_designs = response[:designs]
+
+ expect(updated_designs.size).to eq(1)
+ expect(updated_designs.first.versions.size).to eq(2)
+ end
+
+ it 'increments the update counter' do
+ counter = Gitlab::UsageDataCounters::DesignsCounter
+ expect { run_service }.to change { counter.read(:update) }.by 1
+ end
+
+ context 'when uploading a new design' do
+ it 'does not link the new version to the existing design' do
+ existing_design = issue.designs.first
+
+ updated_designs = run_service([dk_png])[:designs]
+
+ expect(existing_design.versions.reload.size).to eq(1)
+ expect(updated_designs.size).to eq(1)
+ expect(updated_designs.first.versions.size).to eq(1)
+ end
+ end
+ end
+
+ context 'when a design has not changed since its previous version' do
+ before do
+ run_service
+ end
+
+ it 'does not create a new version' do
+ expect { run_service }.not_to change { issue.design_versions.count }
+ end
+
+ it 'returns the design in `skipped_designs` instead of `designs`' do
+ response = run_service
+
+ expect(response[:designs]).to be_empty
+ expect(response[:skipped_designs].size).to eq(1)
+ end
+ end
+
+ context 'when doing a mixture of updates and creations' do
+ let(:files) { [rails_sample, dk_png] }
+
+ before do
+ # Create just the first one, which we will later update.
+ run_service([files.first])
+ touch_files([files.first])
+ end
+
+ it 'counts one creation and one update' do
+ counter = Gitlab::UsageDataCounters::DesignsCounter
+ expect { run_service }
+ .to change { counter.read(:create) }.by(1)
+ .and change { counter.read(:update) }.by(1)
+ end
+
+ it 'creates a single commit' do
+ commit_count = -> do
+ design_repository.expire_all_method_caches
+ design_repository.commit_count
+ end
+
+ expect { run_service }.to change { commit_count.call }.by(1)
+ end
+
+ it 'enqueues just one new version worker' do
+ expect(::DesignManagement::NewVersionWorker)
+ .to receive(:perform_async).once.with(Integer)
+
+ run_service
+ end
+ end
+
+ context 'when uploading multiple files' do
+ let(:files) { [rails_sample, dk_png] }
+
+ it 'returns information about both designs in the response' do
+ expect(response).to include(designs: have_attributes(size: 2), status: :success)
+ end
+
+ it 'creates 2 designs with a single version' do
+ expect { run_service }.to change { issue.designs.count }.from(0).to(2)
+
+ expect(DesignManagement::Version.for_designs(issue.designs).size).to eq(1)
+ end
+
+ it 'increments the creation count by 2' do
+ counter = Gitlab::UsageDataCounters::DesignsCounter
+ expect { run_service }.to change { counter.read(:create) }.by 2
+ end
+
+ it 'enqueues a new version worker' do
+ expect(::DesignManagement::NewVersionWorker)
+ .to receive(:perform_async).once.with(Integer)
+
+ run_service
+ end
+
+ it 'creates a single commit' do
+ commit_count = -> do
+ design_repository.expire_all_method_caches
+ design_repository.commit_count
+ end
+
+ expect { run_service }.to change { commit_count.call }.by(1)
+ end
+
+ it 'only does 5 gitaly calls', :request_store, :sidekiq_might_not_need_inline do
+ allow(::DesignManagement::NewVersionWorker).to receive(:perform_async).with(Integer)
+ service = described_class.new(project, user, issue: issue, files: files)
+ # Some unrelated calls that are usually cached or happen only once
+ service.__send__(:repository).create_if_not_exists
+ service.__send__(:repository).has_visible_content?
+
+ request_count = -> { Gitlab::GitalyClient.get_request_count }
+
+ # An exists?, a check for existing blobs, default branch, an after_commit
+ # callback on LfsObjectsProject
+ expect { service.execute }.to change(&request_count).by(4)
+ end
+
+ context 'when uploading too many files' do
+ let(:files) { Array.new(DesignManagement::SaveDesignsService::MAX_FILES + 1) { dk_png } }
+
+ it 'returns the correct error' do
+ expect(response[:message]).to match(/only \d+ files are allowed simultaneously/i)
+ end
+ end
+ end
+
+ context 'when the user is not allowed to upload designs' do
+ let(:user) { create(:user) }
+
+ it_behaves_like 'a service error'
+ end
+
+ describe 'failure modes' do
+ let(:service) { described_class.new(project, user, issue: issue, files: files) }
+ let(:response) { service.execute }
+
+ before do
+ expect(service).to receive(:run_actions).and_raise(some_error)
+ end
+
+ context 'when creating the commit fails' do
+ let(:some_error) { Gitlab::Git::BaseError }
+
+ it_behaves_like 'an execution error'
+ end
+
+ context 'when creating the versions fails' do
+ let(:some_error) { ActiveRecord::RecordInvalid }
+
+ it_behaves_like 'a service error'
+ end
+ end
+
+ context "when a design already existed in the repo but we didn't know about it in the database" do
+ let(:filename) { rails_sample_name }
+
+ before do
+ path = File.join(build(:design, issue: issue, filename: filename).full_path)
+ design_repository.create_if_not_exists
+ design_repository.create_file(user, path, 'something fake',
+ branch_name: 'master',
+ message: 'Somehow created without being tracked in db')
+ end
+
+ it 'creates the design and a new version for it' do
+ first_updated_design = response[:designs].first
+
+ expect(first_updated_design.filename).to eq(filename)
+ expect(first_updated_design.versions.size).to eq(1)
+ end
+ end
+
+ describe 'scalability', skip: 'See: https://gitlab.com/gitlab-org/gitlab/-/issues/213169' do
+ before do
+ run_service([sample_image('banana_sample.gif')]) # ensure project, issue, etc are created
+ end
+
+ it 'runs the same queries for all requests, regardless of number of files' do
+ one = [dk_png]
+ two = [rails_sample, dk_png]
+
+ baseline = ActiveRecord::QueryRecorder.new { run_service(one) }
+
+ expect { run_service(two) }.not_to exceed_query_limit(baseline)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/git/wiki_push_service_spec.rb b/spec/services/git/wiki_push_service_spec.rb
index 2b2f8835d85..cdb1dc5a435 100644
--- a/spec/services/git/wiki_push_service_spec.rb
+++ b/spec/services/git/wiki_push_service_spec.rb
@@ -320,7 +320,7 @@ describe Git::WikiPushService, services: true do
file_content: 'some stuff',
branch_name: 'master'
}
- ::Wikis::CreateAttachmentService.new(project, project.owner, params).execute
+ ::Wikis::CreateAttachmentService.new(container: project, current_user: project.owner, params: params).execute
end
def update_page(title)
diff --git a/spec/services/lfs/file_transformer_spec.rb b/spec/services/lfs/file_transformer_spec.rb
index 9973d64930b..13d9c369c42 100644
--- a/spec/services/lfs/file_transformer_spec.rb
+++ b/spec/services/lfs/file_transformer_spec.rb
@@ -81,6 +81,23 @@ describe Lfs::FileTransformer do
expect(LfsObject.last.file.read).to eq file_content
end
+
+ context 'when repository is a design repository' do
+ let(:file_path) { "/#{DesignManagement.designs_directory}/test_file.lfs" }
+ let(:repository) { project.design_repository }
+
+ it "creates an LfsObject with the file's content" do
+ subject.new_file(file_path, file)
+
+ expect(LfsObject.last.file.read).to eq(file_content)
+ end
+
+ it 'saves the correct repository_type to LfsObjectsProject' do
+ subject.new_file(file_path, file)
+
+ expect(project.lfs_objects_projects.first.repository_type).to eq('design')
+ end
+ end
end
context "when doesn't use LFS" do
diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb
index c461dd700ec..2ae4fbeb900 100644
--- a/spec/services/notes/create_service_spec.rb
+++ b/spec/services/notes/create_service_spec.rb
@@ -342,6 +342,60 @@ describe Notes::CreateService do
end
end
+ context 'design note' do
+ subject(:service) { described_class.new(project, user, params) }
+
+ let_it_be(:design) { create(:design, :with_file) }
+ let_it_be(:project) { design.project }
+ let_it_be(:user) { project.owner }
+ let_it_be(:params) do
+ {
+ type: 'DiffNote',
+ noteable: design,
+ note: "A message",
+ position: {
+ old_path: design.full_path,
+ new_path: design.full_path,
+ position_type: 'image',
+ width: '100',
+ height: '100',
+ x: '50',
+ y: '50',
+ base_sha: design.diff_refs.base_sha,
+ start_sha: design.diff_refs.base_sha,
+ head_sha: design.diff_refs.head_sha
+ }
+ }
+ end
+
+ it 'can create diff notes for designs' do
+ note = service.execute
+
+ expect(note).to be_a(DiffNote)
+ expect(note).to be_persisted
+ expect(note.noteable).to eq(design)
+ end
+
+ it 'sends a notification about this note', :sidekiq_might_not_need_inline do
+ notifier = double
+ allow(::NotificationService).to receive(:new).and_return(notifier)
+
+ expect(notifier)
+ .to receive(:new_note)
+ .with have_attributes(noteable: design)
+
+ service.execute
+ end
+
+ it 'correctly builds the position of the note' do
+ note = service.execute
+
+ expect(note.position.new_path).to eq(design.full_path)
+ expect(note.position.old_path).to eq(design.full_path)
+ expect(note.position.diff_refs).to eq(design.diff_refs)
+ end
+ end
+
context 'note with emoji only' do
it 'creates regular note' do
opts = {
diff --git a/spec/services/notes/post_process_service_spec.rb b/spec/services/notes/post_process_service_spec.rb
index 99db7897664..d564cacd2d8 100644
--- a/spec/services/notes/post_process_service_spec.rb
+++ b/spec/services/notes/post_process_service_spec.rb
@@ -43,5 +43,32 @@ describe Notes::PostProcessService do
described_class.new(@note).execute
end
end
+
+ context 'when the noteable is a design' do
+ let_it_be(:noteable) { create(:design, :with_file) }
+ let_it_be(:discussion_note) { create_note }
+
+ subject { described_class.new(note).execute }
+
+ def create_note(in_reply_to: nil)
+ create(:diff_note_on_design, noteable: noteable, in_reply_to: in_reply_to)
+ end
+
+ context 'when the note is the start of a new discussion' do
+ let(:note) { discussion_note }
+
+ it 'creates a new system note' do
+ expect { subject }.to change { Note.system.count }.by(1)
+ end
+ end
+
+ context 'when the note is a reply within a discussion' do
+ let_it_be(:note) { create_note(in_reply_to: discussion_note) }
+
+ it 'does not create a new system note' do
+ expect { subject }.not_to change { Note.system.count }
+ end
+ end
+ end
end
end
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index 2134166cd6d..7d6fd9430eb 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -709,6 +709,57 @@ describe NotificationService, :mailer do
end
end
end
+
+ context 'when notified of a new design diff note' do
+ include DesignManagementTestHelpers
+
+ let_it_be(:design) { create(:design, :with_file) }
+ let_it_be(:project) { design.project }
+ let_it_be(:dev) { create(:user) }
+ let_it_be(:stranger) { create(:user) }
+ let_it_be(:note) do
+ create(:diff_note_on_design,
+ noteable: design,
+ note: "Hello #{dev.to_reference}, G'day #{stranger.to_reference}")
+ end
+ let(:mailer) { double(deliver_later: true) }
+
+ context 'design management is enabled' do
+ before do
+ enable_design_management
+ project.add_developer(dev)
+ allow(Notify).to receive(:note_design_email) { mailer }
+ end
+
+ it 'sends new note notifications' do
+ expect(subject).to receive(:send_new_note_notifications).with(note)
+
+ subject.new_note(note)
+ end
+
+ it 'sends a mail to the developer' do
+ expect(Notify)
+ .to receive(:note_design_email).with(dev.id, note.id, 'mentioned')
+
+ subject.new_note(note)
+ end
+
+ it 'does not notify non-developers' do
+ expect(Notify)
+ .not_to receive(:note_design_email).with(stranger.id, note.id)
+
+ subject.new_note(note)
+ end
+ end
+
+ context 'design management is disabled' do
+ it 'does not notify the user' do
+ expect(Notify).not_to receive(:note_design_email)
+
+ subject.new_note(note)
+ end
+ end
+ end
end
describe '#send_new_release_notifications', :deliver_mails_inline, :sidekiq_inline do
diff --git a/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb b/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb
index 71be335c11d..f1eaf8324e0 100644
--- a/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb
+++ b/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb
@@ -6,7 +6,7 @@ describe Projects::HashedStorage::MigrateRepositoryService do
include GitHelpers
let(:gitlab_shell) { Gitlab::Shell.new }
- let(:project) { create(:project, :legacy_storage, :repository, :wiki_repo) }
+ let(:project) { create(:project, :legacy_storage, :repository, :wiki_repo, :design_repo) }
let(:legacy_storage) { Storage::LegacyProject.new(project) }
let(:hashed_storage) { Storage::Hashed.new(project) }
@@ -45,11 +45,12 @@ describe Projects::HashedStorage::MigrateRepositoryService do
end
context 'when succeeds' do
- it 'renames project and wiki repositories' do
+ it 'renames project, wiki and design repositories' do
service.execute
expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.git")).to be_truthy
expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.wiki.git")).to be_truthy
+ expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.design.git")).to be_truthy
end
it 'updates project to be hashed and not read-only' do
@@ -59,9 +60,10 @@ describe Projects::HashedStorage::MigrateRepositoryService do
expect(project.repository_read_only).to be_falsey
end
- it 'move operation is called for both repositories' do
+ it 'move operation is called for all repositories' do
expect_move_repository(old_disk_path, new_disk_path)
expect_move_repository("#{old_disk_path}.wiki", "#{new_disk_path}.wiki")
+ expect_move_repository("#{old_disk_path}.design", "#{new_disk_path}.design")
service.execute
end
@@ -86,6 +88,7 @@ describe Projects::HashedStorage::MigrateRepositoryService do
expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.git")).to be_falsey
expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.wiki.git")).to be_falsey
+ expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.design.git")).to be_falsey
expect(project.repository_read_only?).to be_falsey
end
@@ -97,6 +100,7 @@ describe Projects::HashedStorage::MigrateRepositoryService do
it 'does not try to move nil repository over existing' do
expect(gitlab_shell).not_to receive(:mv_repository).with(project.repository_storage, old_disk_path, new_disk_path)
expect_move_repository("#{old_disk_path}.wiki", "#{new_disk_path}.wiki")
+ expect_move_repository("#{old_disk_path}.design", "#{new_disk_path}.design")
service.execute
end
diff --git a/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb b/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb
index 6dcd2ff4555..1c0f446d9cf 100644
--- a/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb
+++ b/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb
@@ -6,7 +6,7 @@ describe Projects::HashedStorage::RollbackRepositoryService, :clean_gitlab_redis
include GitHelpers
let(:gitlab_shell) { Gitlab::Shell.new }
- let(:project) { create(:project, :repository, :wiki_repo, storage_version: ::Project::HASHED_STORAGE_FEATURES[:repository]) }
+ let(:project) { create(:project, :repository, :wiki_repo, :design_repo, storage_version: ::Project::HASHED_STORAGE_FEATURES[:repository]) }
let(:legacy_storage) { Storage::LegacyProject.new(project) }
let(:hashed_storage) { Storage::Hashed.new(project) }
@@ -45,11 +45,12 @@ describe Projects::HashedStorage::RollbackRepositoryService, :clean_gitlab_redis
end
context 'when succeeds' do
- it 'renames project and wiki repositories' do
+ it 'renames project, wiki and design repositories' do
service.execute
expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.git")).to be_truthy
expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.wiki.git")).to be_truthy
+ expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.design.git")).to be_truthy
end
it 'updates project to be legacy and not read-only' do
@@ -62,6 +63,7 @@ describe Projects::HashedStorage::RollbackRepositoryService, :clean_gitlab_redis
it 'move operation is called for both repositories' do
expect_move_repository(old_disk_path, new_disk_path)
expect_move_repository("#{old_disk_path}.wiki", "#{new_disk_path}.wiki")
+ expect_move_repository("#{old_disk_path}.design", "#{new_disk_path}.design")
service.execute
end
@@ -86,6 +88,7 @@ describe Projects::HashedStorage::RollbackRepositoryService, :clean_gitlab_redis
expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.git")).to be_falsey
expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.wiki.git")).to be_falsey
+ expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.design.git")).to be_falsey
expect(project.repository_read_only?).to be_falsey
end
@@ -97,6 +100,7 @@ describe Projects::HashedStorage::RollbackRepositoryService, :clean_gitlab_redis
it 'does not try to move nil repository over existing' do
expect(gitlab_shell).not_to receive(:mv_repository).with(project.repository_storage, old_disk_path, new_disk_path)
expect_move_repository("#{old_disk_path}.wiki", "#{new_disk_path}.wiki")
+ expect_move_repository("#{old_disk_path}.design", "#{new_disk_path}.design")
service.execute
end
diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb
index f17ddb22d22..274af5f03bf 100644
--- a/spec/services/projects/transfer_service_spec.rb
+++ b/spec/services/projects/transfer_service_spec.rb
@@ -359,6 +359,90 @@ describe Projects::TransferService do
end
end
+ describe 'transferring a design repository' do
+ subject { described_class.new(project, user) }
+
+ before do
+ group.add_owner(user)
+ end
+
+ def design_repository
+ project.design_repository
+ end
+
+ it 'does not create a design repository' do
+ expect(subject.execute(group)).to be true
+
+ project.clear_memoization(:design_repository)
+
+ expect(design_repository.exists?).to be false
+ end
+
+ describe 'when the project has a design repository' do
+ let(:project_repo_path) { "#{project.path}#{::Gitlab::GlRepository::DESIGN.path_suffix}" }
+ let(:old_full_path) { "#{user.namespace.full_path}/#{project_repo_path}" }
+ let(:new_full_path) { "#{group.full_path}/#{project_repo_path}" }
+
+ context 'with legacy storage' do
+ let(:project) { create(:project, :repository, :legacy_storage, :design_repo, namespace: user.namespace) }
+
+ it 'moves the repository' do
+ expect(subject.execute(group)).to be true
+
+ project.clear_memoization(:design_repository)
+
+ expect(design_repository).to have_attributes(
+ disk_path: new_full_path,
+ full_path: new_full_path
+ )
+ end
+
+ it 'does not move the repository when an error occurs', :aggregate_failures do
+ allow(subject).to receive(:execute_system_hooks).and_raise('foo')
+ expect { subject.execute(group) }.to raise_error('foo')
+
+ project.clear_memoization(:design_repository)
+
+ expect(design_repository).to have_attributes(
+ disk_path: old_full_path,
+ full_path: old_full_path
+ )
+ end
+ end
+
+ context 'with hashed storage' do
+ let(:project) { create(:project, :repository, namespace: user.namespace) }
+
+ it 'does not move the repository' do
+ old_disk_path = design_repository.disk_path
+
+ expect(subject.execute(group)).to be true
+
+ project.clear_memoization(:design_repository)
+
+ expect(design_repository).to have_attributes(
+ disk_path: old_disk_path,
+ full_path: new_full_path
+ )
+ end
+
+ it 'does not move the repository when an error occurs' do
+ old_disk_path = design_repository.disk_path
+
+ allow(subject).to receive(:execute_system_hooks).and_raise('foo')
+ expect { subject.execute(group) }.to raise_error('foo')
+
+ project.clear_memoization(:design_repository)
+
+ expect(design_repository).to have_attributes(
+ disk_path: old_disk_path,
+ full_path: old_full_path
+ )
+ end
+ end
+ end
+ end
+
def rugged_config
rugged_repo(project.repository).config
end
diff --git a/spec/services/projects/update_repository_storage_service_spec.rb b/spec/services/projects/update_repository_storage_service_spec.rb
index b58dac9a6ce..4d63216d1c8 100644
--- a/spec/services/projects/update_repository_storage_service_spec.rb
+++ b/spec/services/projects/update_repository_storage_service_spec.rb
@@ -141,5 +141,18 @@ describe Projects::UpdateRepositoryStorageService do
end
end
end
+
+ context 'with design repository' do
+ include_examples 'moves repository to another storage', 'design' do
+ let(:project) { create(:project, :repository, repository_read_only: true) }
+ let(:repository) { project.design_repository }
+ let(:destination) { 'test_second_storage' }
+ let(:repository_storage_move) { create(:project_repository_storage_move, :scheduled, project: project, destination_storage_name: destination) }
+
+ before do
+ project.design_repository.create_if_not_exists
+ end
+ end
+ end
end
end
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index f3e6cb3cc52..66f9b5d092f 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -6,6 +6,7 @@ describe SystemNoteService do
include Gitlab::Routing
include RepoHelpers
include AssetsHelpers
+ include DesignManagementTestHelpers
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, group: group) }
@@ -636,4 +637,28 @@ describe SystemNoteService do
described_class.auto_resolve_prometheus_alert(noteable, project, author)
end
end
+
+ describe '.design_version_added' do
+ let(:version) { create(:design_version) }
+
+ it 'calls DesignManagementService' do
+ expect_next_instance_of(SystemNotes::DesignManagementService) do |service|
+ expect(service).to receive(:design_version_added).with(version)
+ end
+
+ described_class.design_version_added(version)
+ end
+ end
+
+ describe '.design_discussion_added' do
+ let(:discussion_note) { create(:diff_note_on_design) }
+
+ it 'calls DesignManagementService' do
+ expect_next_instance_of(SystemNotes::DesignManagementService) do |service|
+ expect(service).to receive(:design_discussion_added).with(discussion_note)
+ end
+
+ described_class.design_discussion_added(discussion_note)
+ end
+ end
end
diff --git a/spec/services/system_notes/design_management_service_spec.rb b/spec/services/system_notes/design_management_service_spec.rb
new file mode 100644
index 00000000000..08511e62341
--- /dev/null
+++ b/spec/services/system_notes/design_management_service_spec.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe SystemNotes::DesignManagementService do
+ let(:project) { create(:project) }
+ let(:issue) { create(:issue, project: project) }
+
+ let(:instance) { described_class.new(noteable: instance_noteable, project: instance_project, author: instance_author) }
+
+ describe '#design_version_added' do
+ let(:instance_noteable) { version.issue }
+ let(:instance_project) { version.issue.project }
+ let(:instance_author) { version.author }
+
+ subject { instance.design_version_added(version) }
+
+ # default (valid) parameters:
+ let(:n_designs) { 3 }
+ let(:designs) { create_list(:design, n_designs, issue: issue) }
+ let(:user) { build(:user) }
+ let(:version) do
+ create(:design_version, issue: issue, designs: designs)
+ end
+
+ before do
+ # Avoid needing to call into gitaly
+ allow(version).to receive(:author).and_return(user)
+ end
+
+ context 'with one kind of event' do
+ before do
+ DesignManagement::Action
+ .where(design: designs).update_all(event: :modification)
+ end
+
+ it 'makes just one note' do
+ expect(subject).to contain_exactly(Note)
+ end
+
+ it 'adds a new system note' do
+ expect { subject }.to change { Note.system.count }.by(1)
+ end
+ end
+
+ context 'with a mixture of events' do
+ let(:n_designs) { DesignManagement::Action.events.size }
+
+ before do
+ designs.each_with_index do |design, i|
+ design.actions.update_all(event: i)
+ end
+ end
+
+ it 'makes one note for each kind of event' do
+ expect(subject).to have_attributes(size: n_designs)
+ end
+
+ it 'adds a system note for each kind of event' do
+ expect { subject }.to change { Note.system.count }.by(n_designs)
+ end
+ end
+
+ describe 'icons' do
+ where(:action) do
+ [
+ [:creation],
+ [:modification],
+ [:deletion]
+ ]
+ end
+
+ with_them do
+ before do
+ version.actions.update_all(event: action)
+ end
+
+ subject(:metadata) do
+ instance.design_version_added(version)
+ .first.system_note_metadata
+ end
+
+ it 'has a valid action' do
+ expect(::SystemNoteHelper::ICON_NAMES_BY_ACTION)
+ .to include(metadata.action)
+ end
+ end
+ end
+
+ context 'it succeeds' do
+ where(:action, :icon, :human_description) do
+ [
+ [:creation, 'designs_added', 'added'],
+ [:modification, 'designs_modified', 'updated'],
+ [:deletion, 'designs_removed', 'removed']
+ ]
+ end
+
+ with_them do
+ before do
+ version.actions.update_all(event: action)
+ end
+
+ let(:anchor_tag) { %r{ <a[^>]*>#{link}</a>} }
+ let(:href) { instance.send(:designs_path, { version: version.id }) }
+ let(:link) { "#{n_designs} designs" }
+
+ subject(:note) { instance.design_version_added(version).first }
+
+ it 'has the correct data' do
+ expect(note)
+ .to be_system
+ .and have_attributes(
+ system_note_metadata: have_attributes(action: icon),
+ note: include(human_description)
+ .and(include link)
+ .and(include href),
+ note_html: a_string_matching(anchor_tag)
+ )
+ end
+ end
+ end
+ end
+
+ describe '#design_discussion_added' do
+ let(:instance_noteable) { design.issue }
+ let(:instance_project) { design.issue.project }
+ let(:instance_author) { discussion_note.author }
+
+ subject { instance.design_discussion_added(discussion_note) }
+
+ let(:design) { create(:design, :with_file, issue: issue) }
+ let(:author) { create(:user) }
+ let(:discussion_note) do
+ create(:diff_note_on_design, noteable: design, author: author)
+ end
+ let(:action) { 'designs_discussion_added' }
+
+ it_behaves_like 'a system note' do
+ let(:noteable) { discussion_note.noteable.issue }
+ end
+
+ it 'adds a new system note' do
+ expect { subject }.to change { Note.system.count }.by(1)
+ end
+
+ it 'has the correct note text' do
+ href = instance.send(:designs_path,
+ { vueroute: design.filename, anchor: ActionView::RecordIdentifier.dom_id(discussion_note) }
+ )
+
+ expect(subject.note).to eq("started a discussion on [#{design.filename}](#{href})")
+ end
+ end
+end
diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb
index 9b92590cb63..4894cf12372 100644
--- a/spec/services/todo_service_spec.rb
+++ b/spec/services/todo_service_spec.rb
@@ -895,6 +895,36 @@ describe TodoService do
end
end
+ describe 'Designs' do
+ include DesignManagementTestHelpers
+
+ let(:issue) { create(:issue, project: project) }
+ let(:design) { create(:design, issue: issue) }
+
+ before do
+ enable_design_management
+
+ project.add_guest(author)
+ project.add_developer(john_doe)
+ end
+
+ let(:note) do
+ build(:diff_note_on_design,
+ noteable: design,
+ author: author,
+ note: "Hey #{john_doe.to_reference}")
+ end
+
+ it 'creates a todo for mentioned user on new diff note' do
+ service.new_note(note, author)
+
+ should_create_todo(user: john_doe,
+ target: design,
+ action: Todo::MENTIONED,
+ note: note)
+ end
+ end
+
describe '#update_note' do
let(:noteable) { create(:issue, project: project) }
let(:note) { create(:note, project: project, note: mentions, noteable: noteable) }
diff --git a/spec/services/wikis/create_attachment_service_spec.rb b/spec/services/wikis/create_attachment_service_spec.rb
index 7a73a0a555f..4adfaa24874 100644
--- a/spec/services/wikis/create_attachment_service_spec.rb
+++ b/spec/services/wikis/create_attachment_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
describe Wikis::CreateAttachmentService do
- let(:project) { create(:project, :wiki_repo) }
+ let(:container) { create(:project, :wiki_repo) }
let(:user) { create(:user) }
let(:file_name) { 'filename.txt' }
let(:file_path_regex) { %r{#{described_class::ATTACHMENT_PATH}/\h{32}/#{file_name}} }
@@ -15,25 +15,21 @@ describe Wikis::CreateAttachmentService do
end
let(:opts) { file_opts }
- subject(:service) { described_class.new(project, user, opts) }
+ subject(:service) { described_class.new(container: container, current_user: user, params: opts) }
before do
- project.add_developer(user)
+ container.add_developer(user)
end
describe 'initialization' do
context 'author commit info' do
it 'does not raise error if user is nil' do
- service = described_class.new(project, nil, opts)
+ service = described_class.new(container: container, current_user: nil, params: opts)
expect(service.instance_variable_get(:@author_email)).to be_nil
expect(service.instance_variable_get(:@author_name)).to be_nil
end
- it 'fills file_path from the repository uploads folder' do
- expect(service.instance_variable_get(:@file_path)).to match(file_path_regex)
- end
-
context 'when no author info provided' do
it 'fills author_email and author_name from current_user info' do
expect(service.instance_variable_get(:@author_email)).to eq user.email
@@ -73,7 +69,7 @@ describe Wikis::CreateAttachmentService do
context 'branch name' do
context 'when no branch provided' do
it 'sets the branch from the wiki default_branch' do
- expect(service.instance_variable_get(:@branch_name)).to eq project.wiki.default_branch
+ expect(service.instance_variable_get(:@branch_name)).to eq container.wiki.default_branch
end
end
@@ -151,7 +147,7 @@ describe Wikis::CreateAttachmentService do
context 'when user' do
shared_examples 'wiki attachment user validations' do
it 'returns error' do
- result = described_class.new(project, user2, opts).execute
+ result = described_class.new(container: container, current_user: user2, params: opts).execute
expect(result[:status]).to eq :error
expect(result[:message]).to eq 'You are not allowed to push to the wiki'
@@ -172,54 +168,5 @@ describe Wikis::CreateAttachmentService do
end
end
- describe '#execute' do
- let(:wiki) { project.wiki }
-
- subject(:service_execute) { service.execute[:result] }
-
- context 'creates branch if it does not exists' do
- let(:branch_name) { 'new_branch' }
- let(:opts) { file_opts.merge(branch_name: branch_name) }
-
- it do
- expect(wiki.repository.branches).to be_empty
- expect { service.execute }.to change { wiki.repository.branches.count }.by(1)
- expect(wiki.repository.branches.first.name).to eq branch_name
- end
- end
-
- it 'adds file to the repository' do
- expect(wiki.repository.ls_files('HEAD')).to be_empty
-
- service.execute
-
- files = wiki.repository.ls_files('HEAD')
- expect(files.count).to eq 1
- expect(files.first).to match(file_path_regex)
- end
-
- context 'returns' do
- before do
- allow(SecureRandom).to receive(:hex).and_return('fixed_hex')
-
- service_execute
- end
-
- it 'returns the file name' do
- expect(service_execute[:file_name]).to eq file_name
- end
-
- it 'returns the path where file was stored' do
- expect(service_execute[:file_path]).to eq 'uploads/fixed_hex/filename.txt'
- end
-
- it 'returns the branch where the file was pushed' do
- expect(service_execute[:branch]).to eq wiki.default_branch
- end
-
- it 'returns the commit id' do
- expect(service_execute[:commit]).not_to be_empty
- end
- end
- end
+ it_behaves_like 'Wikis::CreateAttachmentService#execute', :project
end
diff --git a/spec/support/helpers/wiki_helpers.rb b/spec/support/helpers/wiki_helpers.rb
index 86eb1793707..e6818ff8f0c 100644
--- a/spec/support/helpers/wiki_helpers.rb
+++ b/spec/support/helpers/wiki_helpers.rb
@@ -14,7 +14,10 @@ module WikiHelpers
file_content: File.read(expand_fixture_path(file_name))
}
- ::Wikis::CreateAttachmentService.new(project, user, opts)
- .execute[:result][:file_path]
+ ::Wikis::CreateAttachmentService.new(
+ container: project,
+ current_user: user,
+ params: opts
+ ).execute[:result][:file_path]
end
end
diff --git a/spec/support/shared_examples/services/wikis/create_attachment_service_shared_examples.rb b/spec/support/shared_examples/services/wikis/create_attachment_service_shared_examples.rb
new file mode 100644
index 00000000000..541e332e3a1
--- /dev/null
+++ b/spec/support/shared_examples/services/wikis/create_attachment_service_shared_examples.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'Wikis::CreateAttachmentService#execute' do |container_type|
+ let(:container) { create(container_type, :wiki_repo) }
+ let(:wiki) { container.wiki }
+
+ let(:user) { create(:user) }
+ let(:file_name) { 'filename.txt' }
+ let(:file_path_regex) { %r{#{described_class::ATTACHMENT_PATH}/\h{32}/#{file_name}} }
+
+ let(:file_opts) do
+ {
+ file_name: file_name,
+ file_content: 'Content of attachment'
+ }
+ end
+ let(:opts) { file_opts }
+
+ let(:service) { Wikis::CreateAttachmentService.new(container: container, current_user: user, params: opts) }
+
+ subject(:service_execute) { service.execute[:result] }
+
+ before do
+ container.add_developer(user)
+ end
+
+ context 'creates branch if it does not exists' do
+ let(:branch_name) { 'new_branch' }
+ let(:opts) { file_opts.merge(branch_name: branch_name) }
+
+ it do
+ expect(wiki.repository.branches).to be_empty
+ expect { service.execute }.to change { wiki.repository.branches.count }.by(1)
+ expect(wiki.repository.branches.first.name).to eq branch_name
+ end
+ end
+
+ it 'adds file to the repository' do
+ expect(wiki.repository.ls_files('HEAD')).to be_empty
+
+ service.execute
+
+ files = wiki.repository.ls_files('HEAD')
+ expect(files.count).to eq 1
+ expect(files.first).to match(file_path_regex)
+ end
+
+ context 'returns' do
+ before do
+ allow(SecureRandom).to receive(:hex).and_return('fixed_hex')
+
+ service_execute
+ end
+
+ it 'returns related information', :aggregate_failures do
+ expect(service_execute[:file_name]).to eq file_name
+ expect(service_execute[:file_path]).to eq 'uploads/fixed_hex/filename.txt'
+ expect(service_execute[:branch]).to eq wiki.default_branch
+ expect(service_execute[:commit]).not_to be_empty
+ end
+ end
+end
diff --git a/spec/workers/design_management/new_version_worker_spec.rb b/spec/workers/design_management/new_version_worker_spec.rb
index 76497dde464..ef7cd8de108 100644
--- a/spec/workers/design_management/new_version_worker_spec.rb
+++ b/spec/workers/design_management/new_version_worker_spec.rb
@@ -3,14 +3,6 @@
require 'spec_helper'
describe DesignManagement::NewVersionWorker do
- # TODO a number of these tests are being temporarily skipped unless run in EE,
- # as we are in the process of moving Design Management to FOSS in 13.0
- # in steps. In the current step the services have not yet been moved, and
- # certain services are called within these tests:
- # - `SystemNoteService`
- # - `DesignManagement::GenerateImageVersionsService`
- #
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283.
describe '#perform' do
let(:worker) { described_class.new }
@@ -18,16 +10,12 @@ describe DesignManagement::NewVersionWorker do
let(:version_id) { -1 }
it 'does not create system notes' do
- skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee?
-
expect(SystemNoteService).not_to receive(:design_version_added)
worker.perform(version_id)
end
it 'does not invoke GenerateImageVersionsService' do
- skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee?
-
expect(DesignManagement::GenerateImageVersionsService).not_to receive(:new)
worker.perform(version_id)
@@ -45,14 +33,10 @@ describe DesignManagement::NewVersionWorker do
let_it_be(:version) { create(:design_version, :with_lfs_file, designs_count: 2) }
it 'creates a system note' do
- skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee?
-
expect { worker.perform(version.id) }.to change { Note.system.count }.by(1)
end
it 'invokes GenerateImageVersionsService' do
- skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee?
-
expect_next_instance_of(DesignManagement::GenerateImageVersionsService) do |service|
expect(service).to receive(:execute)
end
@@ -61,8 +45,6 @@ describe DesignManagement::NewVersionWorker do
end
it 'does not log anything' do
- skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee?
-
expect(Sidekiq.logger).not_to receive(:warn)
worker.perform(version.id)
@@ -77,14 +59,10 @@ describe DesignManagement::NewVersionWorker do
end
it 'creates two system notes' do
- skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee?
-
expect { worker.perform(version.id) }.to change { Note.system.count }.by(2)
end
it 'calls design_version_added' do
- skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee?
-
expect(SystemNoteService).to receive(:design_version_added).with(version)
worker.perform(version.id)