summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.checksum2
-rw-r--r--Gemfile.lock6
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue40
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue20
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js5
-rw-r--r--app/assets/javascripts/saved_replies/components/form.vue182
-rw-r--r--app/assets/javascripts/saved_replies/pages/index.vue6
-rw-r--r--app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql10
-rw-r--r--app/controllers/concerns/registrations_tracking.rb2
-rw-r--r--app/graphql/resolvers/analytics/cycle_analytics/issue_count_resolver.rb86
-rw-r--r--app/graphql/types/analytics/cycle_analytics/flow_metrics.rb22
-rw-r--r--app/graphql/types/analytics/cycle_analytics/metric_type.rb39
-rw-r--r--app/graphql/types/project_type.rb8
-rw-r--r--app/services/system_notes/commit_service.rb56
-rw-r--r--danger/stable_branch_patch/Dangerfile2
-rw-r--r--db/post_migrate/20230220112930_replace_uniq_index_on_postgres_async_foreign_key_validations.rb19
-rw-r--r--db/schema_migrations/202302201129301
-rw-r--r--db/structure.sql4
-rw-r--r--doc/api/graphql/reference/index.md61
-rw-r--r--doc/operations/incident_management/incident_timeline_events.md6
-rw-r--r--doc/user/application_security/policies/img/association_diagram.pngbin6624 -> 19149 bytes
-rw-r--r--doc/user/application_security/policies/img/policy_rule_mode_v14_9.pngbin34025 -> 0 bytes
-rw-r--r--doc/user/application_security/policies/img/policy_rule_mode_v15_9.pngbin0 -> 37866 bytes
-rw-r--r--doc/user/application_security/policies/img/policy_yaml_mode_v14_9.pngbin27424 -> 0 bytes
-rw-r--r--doc/user/application_security/policies/img/policy_yaml_mode_v15_9.pngbin0 -> 29904 bytes
-rw-r--r--doc/user/application_security/policies/img/scan_execution_policy_rule_mode_v15_5.pngbin23688 -> 0 bytes
-rw-r--r--doc/user/application_security/policies/img/scan_execution_policy_rule_mode_v15_9.pngbin0 -> 27667 bytes
-rw-r--r--doc/user/application_security/policies/index.md8
-rw-r--r--doc/user/application_security/policies/scan-execution-policies.md2
-rw-r--r--doc/user/group/compliance_frameworks.md10
-rw-r--r--doc/user/group/import/index.md52
-rw-r--r--lib/gitlab/database/async_foreign_keys/migration_helpers.rb4
-rw-r--r--lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation.rb2
-rw-r--r--locale/gitlab.pot18
-rw-r--r--spec/features/profiles/user_creates_saved_reply_spec.rb29
-rw-r--r--spec/frontend/fixtures/saved_replies.rb28
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js80
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js28
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js3
-rw-r--r--spec/frontend/saved_replies/components/form_spec.js116
-rw-r--r--spec/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation_spec.rb2
-rw-r--r--spec/requests/api/graphql/project/flow_metrics_spec.rb19
-rw-r--r--spec/services/system_notes/commit_service_spec.rb82
-rw-r--r--spec/support/shared_examples/analytics/cycle_analytics/flow_metrics_examples.rb124
46 files changed, 1123 insertions, 68 deletions
diff --git a/Gemfile b/Gemfile
index 48d61eae910..a38c9361be7 100644
--- a/Gemfile
+++ b/Gemfile
@@ -373,7 +373,7 @@ gem 'prometheus-client-mmap', '~> 0.17', require: 'prometheus/client'
gem 'warning', '~> 1.3.0'
group :development do
- gem 'lefthook', '~> 1.2.9', require: false
+ gem 'lefthook', '~> 1.3.0', require: false
gem 'rubocop'
gem 'solargraph', '~> 0.47.2', require: false
diff --git a/Gemfile.checksum b/Gemfile.checksum
index 2dbcae42f0f..24ae815ae2e 100644
--- a/Gemfile.checksum
+++ b/Gemfile.checksum
@@ -314,7 +314,7 @@
{"name":"kramdown","version":"2.3.2","platform":"ruby","checksum":"cb4530c2e9d16481591df2c9336723683c354e5416a5dd3e447fa48215a6a71c"},
{"name":"kramdown-parser-gfm","version":"1.1.0","platform":"ruby","checksum":"fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729"},
{"name":"launchy","version":"2.5.0","platform":"ruby","checksum":"954243c4255920982ce682f89a42e76372dba94770bf09c23a523e204bdebef5"},
-{"name":"lefthook","version":"1.2.9","platform":"ruby","checksum":"1fd4a768e08fc624e756597fc628b3c7991267325974a7a5cc169595b425701d"},
+{"name":"lefthook","version":"1.3.0","platform":"ruby","checksum":"46460ceb0084d1a60c7aa2872c90fd9a97d92c32063b41ac88303e1d1a382b43"},
{"name":"letter_opener","version":"1.7.0","platform":"ruby","checksum":"095bc0d58e006e5b43ea7d219e64ecf2de8d1f7d9dafc432040a845cf59b4725"},
{"name":"letter_opener_web","version":"2.0.0","platform":"ruby","checksum":"33860ad41e1785d75456500e8ca8bba8ed71ee6eaf08a98d06bbab67c5577b6f"},
{"name":"libyajl2","version":"1.2.0","platform":"ruby","checksum":"1117cd1e48db013b626e36269bbf1cef210538ca6d2e62d3fa3db9ded005b258"},
diff --git a/Gemfile.lock b/Gemfile.lock
index a3d6dc31fb6..3b0ee82c3c3 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -845,7 +845,7 @@ GEM
kramdown (~> 2.0)
launchy (2.5.0)
addressable (~> 2.7)
- lefthook (1.2.9)
+ lefthook (1.3.0)
letter_opener (1.7.0)
launchy (~> 2.2)
letter_opener_web (2.0.0)
@@ -1738,7 +1738,7 @@ DEPENDENCIES
knapsack (~> 1.21.1)
kramdown (~> 2.3.1)
kubeclient (~> 4.9.3)!
- lefthook (~> 1.2.9)
+ lefthook (~> 1.3.0)
letter_opener_web (~> 2.0.0)
license_finder (~> 7.0)
licensee (~> 9.15)
@@ -1893,4 +1893,4 @@ DEPENDENCIES
yajl-ruby (~> 1.4.3)
BUNDLED WITH
- 2.4.6
+ 2.4.7
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
index d982df4f984..a06b2cadd6e 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
@@ -4,16 +4,22 @@ import VersionRow from '~/packages_and_registries/package_registry/components/de
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue';
+import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue';
import {
+ CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
+ DELETE_PACKAGE_VERSION_TRACKING_ACTION,
DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
+ REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
} from '~/packages_and_registries/package_registry/constants';
import Tracking from '~/tracking';
+import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
export default {
components: {
DeleteModal,
+ DeletePackageModal,
VersionRow,
PackagesListLoader,
RegistryList,
@@ -42,6 +48,7 @@ export default {
},
data() {
return {
+ itemToBeDeleted: null,
itemsToBeDeleted: [],
};
},
@@ -52,8 +59,25 @@ export default {
isListEmpty() {
return this.versions.length === 0;
},
+ tracking() {
+ const category = this.itemToBeDeleted
+ ? packageTypeToTrackCategory(this.itemToBeDeleted.packageType)
+ : undefined;
+ return {
+ category,
+ };
+ },
},
methods: {
+ deleteItemConfirmation() {
+ this.$emit('delete', [this.itemToBeDeleted]);
+ this.track(DELETE_PACKAGE_VERSION_TRACKING_ACTION);
+ this.itemToBeDeleted = null;
+ },
+ deleteItemCanceled() {
+ this.track(CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION);
+ this.itemToBeDeleted = null;
+ },
deleteItemsCanceled() {
this.track(CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
this.itemsToBeDeleted = [];
@@ -63,7 +87,16 @@ export default {
this.track(DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
this.itemsToBeDeleted = [];
},
+ setItemToBeDeleted(item) {
+ this.itemToBeDeleted = { ...item };
+ this.track(REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION);
+ },
setItemsToBeDeleted(items) {
+ if (items.length === 1) {
+ const [item] = items;
+ this.setItemToBeDeleted(item);
+ return;
+ }
this.itemsToBeDeleted = items;
this.track(REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
this.$refs.deletePackagesModal.show();
@@ -96,11 +129,18 @@ export default {
:first="canDestroy && first"
:package-entity="item"
:selected="isSelected(item)"
+ @delete="setItemToBeDeleted(item)"
@select="selectItem(item)"
/>
</template>
</registry-list>
+ <delete-package-modal
+ :item-to-be-deleted="itemToBeDeleted"
+ @ok="deleteItemConfirmation"
+ @cancel="deleteItemCanceled"
+ />
+
<delete-modal
ref="deletePackagesModal"
:items-to-be-deleted="itemsToBeDeleted"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
index 9f8f6328970..193a222853f 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
@@ -1,5 +1,7 @@
<script>
import {
+ GlDropdown,
+ GlDropdownItem,
GlFormCheckbox,
GlIcon,
GlLink,
@@ -13,6 +15,7 @@ import PublishMethod from '~/packages_and_registries/shared/components/publish_m
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import {
+ DELETE_PACKAGE_TEXT,
ERRORED_PACKAGE_TEXT,
ERROR_PUBLISHING,
PACKAGE_ERROR_STATUS,
@@ -22,6 +25,8 @@ import {
export default {
name: 'PackageVersionRow',
components: {
+ GlDropdown,
+ GlDropdownItem,
GlFormCheckbox,
GlIcon,
GlLink,
@@ -58,6 +63,7 @@ export default {
},
},
i18n: {
+ deletePackage: DELETE_PACKAGE_TEXT,
erroredPackageText: ERRORED_PACKAGE_TEXT,
errorPublishing: ERROR_PUBLISHING,
warningText: WARNING_TEXT,
@@ -121,5 +127,19 @@ export default {
</gl-sprintf>
</span>
</template>
+
+ <template v-if="packageEntity.canDestroy" #right-action>
+ <gl-dropdown
+ icon="ellipsis_v"
+ :text="$options.i18n.moreActions"
+ :text-sr-only="true"
+ category="tertiary"
+ no-caret
+ >
+ <gl-dropdown-item variant="danger" @click="$emit('delete')">{{
+ $options.i18n.deletePackage
+ }}</gl-dropdown-item>
+ </gl-dropdown>
+ </template>
</list-item>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
index 16f21bfe61d..c5354b7e7df 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
@@ -8,9 +8,10 @@ import {
GlTooltipDirective,
GlTruncate,
} from '@gitlab/ui';
-import { s__, __ } from '~/locale';
+import { __ } from '~/locale';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import {
+ DELETE_PACKAGE_TEXT,
ERRORED_PACKAGE_TEXT,
ERROR_PUBLISHING,
PACKAGE_ERROR_STATUS,
@@ -91,7 +92,7 @@ export default {
i18n: {
erroredPackageText: ERRORED_PACKAGE_TEXT,
createdAt: __('Created %{timestamp}'),
- deletePackage: s__('PackageRegistry|Delete package'),
+ deletePackage: DELETE_PACKAGE_TEXT,
errorPublishing: ERROR_PUBLISHING,
warning: WARNING_TEXT,
moreActions: __('More actions'),
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
index d979ae5c08c..b8875b5dc18 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -119,6 +119,10 @@ export const DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'delete_package_versions'
export const REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'request_delete_package_versions';
export const CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'cancel_delete_package_versions';
+export const DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'delete_package_version';
+export const REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'request_delete_package_version';
+export const CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'cancel_delete_package_version';
+
export const DELETE_PACKAGES_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting packages.',
);
@@ -127,6 +131,7 @@ export const DELETE_PACKAGES_SUCCESS_MESSAGE = s__('PackageRegistry|Packages del
export const DELETE_PACKAGES_MODAL_TITLE = s__('PackageRegistry|Delete packages');
export const DELETE_PACKAGE_MODAL_PRIMARY_ACTION = s__('PackageRegistry|Permanently delete');
+export const DELETE_PACKAGE_TEXT = s__('PackageRegistry|Delete package');
export const DELETE_PACKAGE_SUCCESS_MESSAGE = s__('PackageRegistry|Package deleted successfully');
export const DELETE_PACKAGE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package.',
diff --git a/app/assets/javascripts/saved_replies/components/form.vue b/app/assets/javascripts/saved_replies/components/form.vue
new file mode 100644
index 00000000000..932e7dcfa1f
--- /dev/null
+++ b/app/assets/javascripts/saved_replies/components/form.vue
@@ -0,0 +1,182 @@
+<script>
+import { GlButton, GlForm, GlFormGroup, GlFormInput, GlAlert } from '@gitlab/ui';
+import { produce } from 'immer';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { logError } from '~/lib/logger';
+import { __ } from '~/locale';
+import savedRepliesQuery from '../queries/saved_replies.query.graphql';
+import createSavedReplyMutation from '../queries/create_saved_reply.mutation.graphql';
+
+export default {
+ components: {
+ GlButton,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlAlert,
+ MarkdownField,
+ },
+ data() {
+ return {
+ errors: [],
+ saving: false,
+ showValidation: false,
+ updateSavedReply: {
+ name: '',
+ content: '',
+ },
+ };
+ },
+ computed: {
+ isNameValid() {
+ if (this.showValidation) return Boolean(this.updateSavedReply.name);
+
+ return true;
+ },
+ isContentValid() {
+ if (this.showValidation) return Boolean(this.updateSavedReply.content);
+
+ return true;
+ },
+ isValid() {
+ return this.isNameValid && this.isContentValid;
+ },
+ },
+ methods: {
+ onSubmit() {
+ this.showValidation = true;
+
+ if (!this.isValid) return;
+
+ this.errors = [];
+ this.saving = true;
+
+ this.$apollo
+ .mutate({
+ mutation: createSavedReplyMutation,
+ variables: {
+ name: this.updateSavedReply.name,
+ content: this.updateSavedReply.content,
+ },
+ update: (store, { data: { savedReplyMutation } }) => {
+ if (savedReplyMutation.errors.length) {
+ this.errors = savedReplyMutation.errors.map((e) => e);
+ } else {
+ const sourceData = store.readQuery({ query: savedRepliesQuery });
+ const newData = produce(sourceData, (draftState) => {
+ if (draftState) {
+ draftState.currentUser?.savedReplies?.nodes.unshift(
+ savedReplyMutation.savedReply,
+ );
+ if (draftState.currentUser?.savedReplies?.count !== null) {
+ draftState.currentUser.savedReplies.count += 1;
+ }
+ }
+ });
+
+ if (newData) {
+ store.writeQuery({
+ query: savedRepliesQuery,
+ data: newData,
+ });
+ }
+
+ this.updateSavedReply = { name: '', content: '' };
+ this.showValidation = false;
+ }
+ },
+ })
+ .catch((error) => {
+ const errors = error.graphQLErrors;
+
+ if (errors?.length) {
+ this.errors = errors.map((e) => e.message);
+ } else {
+ // Let's be sure to log the original error so it isn't just swallowed.
+ // Also, we don't want to translate console messages.
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ logError('Unexpected error while saving reply', error);
+
+ this.errors = [__('An unexpected error occurred. Please try again.')];
+ }
+ })
+ .finally(() => {
+ this.saving = false;
+ });
+ },
+ },
+ restrictedToolbarItems: ['full-screen'],
+ markdownDocsPath: helpPagePath('user/markdown'),
+};
+</script>
+
+<template>
+ <gl-form
+ class="new-note common-note-form"
+ data-testid="saved-reply-form"
+ @submit.prevent="onSubmit"
+ >
+ <gl-alert
+ v-for="error in errors"
+ :key="error"
+ variant="danger"
+ class="gl-mb-3"
+ :dismissible="false"
+ >
+ {{ error }}
+ </gl-alert>
+ <gl-form-group
+ :label="__('Name')"
+ :state="isNameValid"
+ :invalid-feedback="__('Please enter a name for the saved reply.')"
+ data-testid="saved-reply-name-form-group"
+ >
+ <gl-form-input
+ v-model="updateSavedReply.name"
+ :placeholder="__('Name')"
+ data-testid="saved-reply-name-input"
+ />
+ </gl-form-group>
+ <gl-form-group
+ :label="__('Content')"
+ :state="isContentValid"
+ :invalid-feedback="__('Please enter the saved reply content.')"
+ data-testid="saved-reply-content-form-group"
+ >
+ <markdown-field
+ :enable-preview="false"
+ :is-submitting="saving"
+ :add-spacing-classes="false"
+ :textarea-value="updateSavedReply.content"
+ :markdown-docs-path="$options.markdownDocsPath"
+ :restricted-tool-bar-items="$options.restrictedToolbarItems"
+ :force-autosize="false"
+ class="js-no-autosize gl-border-gray-400!"
+ >
+ <template #textarea>
+ <textarea
+ v-model="updateSavedReply.content"
+ dir="auto"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ data-supports-quick-actions="false"
+ :aria-label="__('Content')"
+ :placeholder="__('Write saved reply content here…')"
+ data-testid="saved-reply-content-input"
+ @keydown.meta.enter="onSubmit"
+ @keydown.ctrl.enter="onSubmit"
+ ></textarea>
+ </template>
+ </markdown-field>
+ </gl-form-group>
+ <gl-button
+ variant="confirm"
+ class="gl-mr-3 js-no-auto-disable"
+ type="submit"
+ :loading="saving"
+ data-testid="saved-reply-form-submit-btn"
+ >
+ {{ __('Save') }}
+ </gl-button>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/saved_replies/pages/index.vue b/app/assets/javascripts/saved_replies/pages/index.vue
index 38f51dbc365..f5994c50e59 100644
--- a/app/assets/javascripts/saved_replies/pages/index.vue
+++ b/app/assets/javascripts/saved_replies/pages/index.vue
@@ -1,8 +1,10 @@
<script>
+import CreateForm from '../components/form.vue';
import List from '../components/list.vue';
export default {
components: {
+ CreateForm,
List,
},
};
@@ -10,6 +12,10 @@ export default {
<template>
<div>
+ <h5 class="gl-mt-0 gl-font-lg">
+ {{ __('Add new saved reply') }}
+ </h5>
+ <create-form />
<list />
</div>
</template>
diff --git a/app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql b/app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql
new file mode 100644
index 00000000000..c4e632d0f16
--- /dev/null
+++ b/app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql
@@ -0,0 +1,10 @@
+mutation savedReplyCreate($name: String!, $content: String!) {
+ savedReplyMutation: savedReplyCreate(input: { name: $name, content: $content }) {
+ errors
+ savedReply {
+ id
+ name
+ content
+ }
+ }
+}
diff --git a/app/controllers/concerns/registrations_tracking.rb b/app/controllers/concerns/registrations_tracking.rb
index 14743349c1a..6c83c57d9dd 100644
--- a/app/controllers/concerns/registrations_tracking.rb
+++ b/app/controllers/concerns/registrations_tracking.rb
@@ -13,3 +13,5 @@ module RegistrationsTracking
params.permit(:glm_source, :glm_content)
end
end
+
+RegistrationsTracking.prepend_mod
diff --git a/app/graphql/resolvers/analytics/cycle_analytics/issue_count_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/issue_count_resolver.rb
new file mode 100644
index 00000000000..eda83ea38c2
--- /dev/null
+++ b/app/graphql/resolvers/analytics/cycle_analytics/issue_count_resolver.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Analytics
+ module CycleAnalytics
+ class IssueCountResolver < BaseResolver
+ type Types::Analytics::CycleAnalytics::MetricType, null: true
+
+ argument :assignee_usernames, [GraphQL::Types::String],
+ required: false,
+ description: 'Usernames of users assigned to the issue.'
+
+ argument :author_username, GraphQL::Types::String,
+ required: false,
+ description: 'Username of the author of the issue.'
+
+ argument :milestone_title, GraphQL::Types::String,
+ required: false,
+ description: 'Milestone applied to the issue.'
+
+ argument :label_names, [GraphQL::Types::String],
+ required: false,
+ description: 'Labels applied to the issue.'
+
+ argument :from, Types::TimeType,
+ required: true,
+ description: 'Issues created after the date.'
+
+ argument :to, Types::TimeType,
+ required: true,
+ description: 'Issues created before the date.'
+
+ def resolve(**args)
+ scope = IssuesFinder
+ .new(current_user, process_params(args))
+ .execute
+
+ scope = scope.in_projects(args[:project_ids]) if args[:project_ids]
+ value = scope.count
+
+ {
+ value: value,
+ title: n_('New Issue', 'New Issues', value),
+ identifier: 'issues',
+ links: []
+ }
+ end
+
+ private
+
+ def process_params(params)
+ params[:assignee_username] = params.delete(:assignee_usernames) if params[:assignee_usernames]
+ params[:label_name] = params.delete(:label_names) if params[:label_names]
+ params[:created_after] = params.delete(:from)
+ params[:created_before] = params.delete(:to)
+
+ params.merge(finder_params)
+ end
+
+ def finder_params
+ { project_id: object.project.id }
+ end
+
+ # :project level: no customization, returning the original resolver
+ # :group level: add the project_ids argument
+ def self.[](context = :project)
+ case context
+ when :project
+ self
+ when :group
+ Class.new(self) do
+ argument :project_ids, [GraphQL::Types::ID],
+ required: false,
+ description: 'Project IDs within the group hierarchy.'
+
+ define_method :finder_params do
+ { group_id: object.id, include_subgroups: true }
+ end
+ end
+
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/analytics/cycle_analytics/flow_metrics.rb b/app/graphql/types/analytics/cycle_analytics/flow_metrics.rb
new file mode 100644
index 00000000000..d320cd6cfc6
--- /dev/null
+++ b/app/graphql/types/analytics/cycle_analytics/flow_metrics.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ module Analytics
+ module CycleAnalytics
+ module FlowMetrics
+ def self.[](context = :project)
+ Class.new(BaseObject) do
+ graphql_name "#{context.capitalize}ValueStreamAnalyticsFlowMetrics"
+ description 'Exposes aggregated value stream flow metrics'
+
+ field :issue_count,
+ Types::Analytics::CycleAnalytics::MetricType,
+ null: true,
+ description: 'Number of issues opened in the given period.',
+ resolver: Resolvers::Analytics::CycleAnalytics::IssueCountResolver[context]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/analytics/cycle_analytics/metric_type.rb b/app/graphql/types/analytics/cycle_analytics/metric_type.rb
new file mode 100644
index 00000000000..b880f5029ea
--- /dev/null
+++ b/app/graphql/types/analytics/cycle_analytics/metric_type.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Types
+ module Analytics
+ module CycleAnalytics
+ # rubocop: disable Graphql/AuthorizeTypes
+ class MetricType < BaseObject
+ graphql_name 'ValueStreamAnalyticsMetric'
+ description ''
+
+ field :value,
+ GraphQL::Types::Float,
+ null: true,
+ description: 'Value for the metric.'
+
+ field :identifier,
+ GraphQL::Types::String,
+ null: false,
+ description: 'Identifier for the metric.'
+
+ field :unit,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Unit of measurement.'
+
+ field :title,
+ GraphQL::Types::String,
+ null: false,
+ description: 'Title for the metric.'
+
+ field :links,
+ [GraphQL::Types::String],
+ null: false,
+ description: 'Optional links for drilling down.'
+ end
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index c105ab9814c..4593f5e5925 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -581,6 +581,14 @@ module Types
description: 'Minimum access level.'
end
+ field :flow_metrics,
+ ::Types::Analytics::CycleAnalytics::FlowMetrics[:project],
+ null: true,
+ description: 'Flow metrics for value stream analytics.',
+ method: :project_namespace,
+ authorize: :read_cycle_analytics,
+ alpha: { milestone: '15.10' }
+
def timelog_categories
object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories)
end
diff --git a/app/services/system_notes/commit_service.rb b/app/services/system_notes/commit_service.rb
index 592351079aa..e4d89ecb930 100644
--- a/app/services/system_notes/commit_service.rb
+++ b/app/services/system_notes/commit_service.rb
@@ -2,6 +2,8 @@
module SystemNotes
class CommitService < ::SystemNotes::BaseService
+ NEW_COMMIT_DISPLAY_LIMIT = 10
+
# Called when commits are added to a merge request
#
# new_commits - Array of Commits added since last push
@@ -36,25 +38,73 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'tag'))
end
+ private
+
# Build an Array of lines detailing each commit added in a merge request
#
# new_commits - Array of new Commit objects
#
# Returns an Array of Strings
- def new_commit_summary(new_commits)
+ def new_commits_list(new_commits)
new_commits.collect do |commit|
content_tag('li', "#{commit.short_id} - #{commit.title}")
end
end
- private
+ # Builds an Array of lines describing each commit and truncate them based on the limit
+ # to avoid creating a note with a large number of commits.
+ #
+ # commits - Array of Commit objects
+ #
+ # Returns an Array of Strings
+ #
+ # rubocop: disable CodeReuse/ActiveRecord
+ def new_commit_summary(commits, start_rev)
+ if commits.size > NEW_COMMIT_DISPLAY_LIMIT
+ no_of_commits_to_truncate = commits.size - NEW_COMMIT_DISPLAY_LIMIT
+ commits_to_truncate = commits.take(no_of_commits_to_truncate)
+ remaining_commits = commits.drop(no_of_commits_to_truncate)
+
+ [truncated_new_commits(commits_to_truncate, start_rev)] + new_commits_list(remaining_commits)
+ else
+ new_commits_list(commits)
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # Builds a summary line that describes given truncated commits.
+ #
+ # commits - Array of Commit objects
+ # start_rev - String SHA of a Commit that will be used as the starting SHA of the range
+ #
+ # Returns a String wrapped in 'li' tag.
+ def truncated_new_commits(commits, start_rev)
+ count = commits.size
+
+ commit_ids = if count == 1
+ commits.first.short_id
+ elsif start_rev && !Gitlab::Git.blank_ref?(start_rev)
+ "#{Commit.truncate_sha(start_rev)}...#{commits.last.short_id}"
+ else
+ # This two-dots notation seems to be not functioning as expected, but we should
+ # fallback to it as start_rev can be empty.
+ #
+ # For more information, please see https://gitlab.com/gitlab-org/gitlab/-/issues/391809
+ "#{commits.first.short_id}..#{commits.last.short_id}"
+ end
+
+ commits_text = "#{count} earlier commit".pluralize(count)
+
+ content_tag('li', "#{commit_ids} - #{commits_text}")
+ end
# Builds a list of existing and new commits according to existing_commits and
# new_commits methods.
# Returns a String wrapped in `ul` and `li` tags.
def commits_list(noteable, new_commits, existing_commits, oldrev)
existing_commit_summary = existing_commit_summary(noteable, existing_commits, oldrev)
- new_commit_summary = new_commit_summary(new_commits).join
+ start_rev = existing_commits.empty? ? oldrev : existing_commits.last.id
+ new_commit_summary = new_commit_summary(new_commits, start_rev).join
content_tag('ul', "#{existing_commit_summary}#{new_commit_summary}".html_safe)
end
diff --git a/danger/stable_branch_patch/Dangerfile b/danger/stable_branch_patch/Dangerfile
index 258daa7d1fa..4fa8b05464a 100644
--- a/danger/stable_branch_patch/Dangerfile
+++ b/danger/stable_branch_patch/Dangerfile
@@ -2,7 +2,7 @@
if stable_branch.non_security_stable_branch?
markdown(<<~MARKDOWN)
- ### QA `e2e:package-and-test`
+ ## QA `e2e:package-and-test`
**@#{helper.mr_author}, the `package-and-test` job must complete before merging this merge request.***
diff --git a/db/post_migrate/20230220112930_replace_uniq_index_on_postgres_async_foreign_key_validations.rb b/db/post_migrate/20230220112930_replace_uniq_index_on_postgres_async_foreign_key_validations.rb
new file mode 100644
index 00000000000..1adc275e1e9
--- /dev/null
+++ b/db/post_migrate/20230220112930_replace_uniq_index_on_postgres_async_foreign_key_validations.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class ReplaceUniqIndexOnPostgresAsyncForeignKeyValidations < Gitlab::Database::Migration[2.1]
+ disable_ddl_transaction!
+
+ NEW_INDEX_NAME = 'unique_postgres_async_fk_validations_name_and_table_name'
+ OLD_INDEX_NAME = 'index_postgres_async_foreign_key_validations_on_name'
+ TABLE_NAME = 'postgres_async_foreign_key_validations'
+
+ def up
+ add_concurrent_index TABLE_NAME, [:name, :table_name], unique: true, name: NEW_INDEX_NAME
+ remove_concurrent_index_by_name TABLE_NAME, OLD_INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index TABLE_NAME, :name, unique: true, name: OLD_INDEX_NAME
+ remove_concurrent_index_by_name TABLE_NAME, NEW_INDEX_NAME
+ end
+end
diff --git a/db/schema_migrations/20230220112930 b/db/schema_migrations/20230220112930
new file mode 100644
index 00000000000..0852b3fe5f7
--- /dev/null
+++ b/db/schema_migrations/20230220112930
@@ -0,0 +1 @@
+b58d0cf5df91d7abc4ba7ef4a1257f03aa6e9849624d43728ca0e008c5710e7c \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index bc18ea90ba6..364f697dceb 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -31244,8 +31244,6 @@ CREATE INDEX index_pool_repositories_on_shard_id ON pool_repositories USING btre
CREATE UNIQUE INDEX index_pool_repositories_on_source_project_id_and_shard_id ON pool_repositories USING btree (source_project_id, shard_id);
-CREATE UNIQUE INDEX index_postgres_async_foreign_key_validations_on_name ON postgres_async_foreign_key_validations USING btree (name);
-
CREATE UNIQUE INDEX index_postgres_async_indexes_on_name ON postgres_async_indexes USING btree (name);
CREATE INDEX index_postgres_reindex_actions_on_index_identifier ON postgres_reindex_actions USING btree (index_identifier);
@@ -32506,6 +32504,8 @@ CREATE UNIQUE INDEX unique_index_for_project_pages_unique_domain ON project_sett
CREATE UNIQUE INDEX unique_merge_request_metrics_by_merge_request_id ON merge_request_metrics USING btree (merge_request_id);
+CREATE UNIQUE INDEX unique_postgres_async_fk_validations_name_and_table_name ON postgres_async_foreign_key_validations USING btree (name, table_name);
+
CREATE UNIQUE INDEX unique_projects_on_name_namespace_id ON projects USING btree (name, namespace_id);
CREATE UNIQUE INDEX unique_streaming_event_type_filters_destination_id ON audit_events_streaming_event_type_filters USING btree (external_audit_event_destination_id, audit_event_type);
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index a86cd4a6975..b46c111ff43 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -14047,6 +14047,7 @@ GPG signature for a signed commit.
| <a id="groupepicboards"></a>`epicBoards` | [`EpicBoardConnection`](#epicboardconnection) | Find epic boards. (see [Connections](#connections)) |
| <a id="groupepicsenabled"></a>`epicsEnabled` | [`Boolean`](#boolean) | Indicates if Epics are enabled for namespace. |
| <a id="groupexternalauditeventdestinations"></a>`externalAuditEventDestinations` | [`ExternalAuditEventDestinationConnection`](#externalauditeventdestinationconnection) | External locations that receive audit events belonging to the group. (see [Connections](#connections)) |
+| <a id="groupflowmetrics"></a>`flowMetrics` **{warning-solid}** | [`GroupValueStreamAnalyticsFlowMetrics`](#groupvaluestreamanalyticsflowmetrics) | **Introduced** in 15.10. This feature is in Alpha. It can be changed or removed at any time. Flow metrics for value stream analytics. |
| <a id="groupfullname"></a>`fullName` | [`String!`](#string) | Full name of the namespace. |
| <a id="groupfullpath"></a>`fullPath` | [`ID!`](#id) | Full path of the namespace. |
| <a id="groupid"></a>`id` | [`ID!`](#id) | ID of the namespace. |
@@ -14954,6 +14955,30 @@ Contains statistics about a group.
| ---- | ---- | ----------- |
| <a id="groupstatsreleasestats"></a>`releaseStats` | [`GroupReleaseStats`](#groupreleasestats) | Statistics related to releases within the group. |
+### `GroupValueStreamAnalyticsFlowMetrics`
+
+Exposes aggregated value stream flow metrics.
+
+#### Fields with arguments
+
+##### `GroupValueStreamAnalyticsFlowMetrics.issueCount`
+
+Number of issues opened in the given period.
+
+Returns [`ValueStreamAnalyticsMetric`](#valuestreamanalyticsmetric).
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="groupvaluestreamanalyticsflowmetricsissuecountassigneeusernames"></a>`assigneeUsernames` | [`[String!]`](#string) | Usernames of users assigned to the issue. |
+| <a id="groupvaluestreamanalyticsflowmetricsissuecountauthorusername"></a>`authorUsername` | [`String`](#string) | Username of the author of the issue. |
+| <a id="groupvaluestreamanalyticsflowmetricsissuecountfrom"></a>`from` | [`Time!`](#time) | Issues created after the date. |
+| <a id="groupvaluestreamanalyticsflowmetricsissuecountlabelnames"></a>`labelNames` | [`[String!]`](#string) | Labels applied to the issue. |
+| <a id="groupvaluestreamanalyticsflowmetricsissuecountmilestonetitle"></a>`milestoneTitle` | [`String`](#string) | Milestone applied to the issue. |
+| <a id="groupvaluestreamanalyticsflowmetricsissuecountprojectids"></a>`projectIds` | [`[ID!]`](#id) | Project IDs within the group hierarchy. |
+| <a id="groupvaluestreamanalyticsflowmetricsissuecountto"></a>`to` | [`Time!`](#time) | Issues created before the date. |
+
### `GroupWikiRepositoryRegistry`
Represents the Geo sync and verification state of a group wiki repository.
@@ -17847,6 +17872,7 @@ Represents a product analytics dashboard visualization.
| <a id="projectdescription"></a>`description` | [`String`](#string) | Short description of the project. |
| <a id="projectdescriptionhtml"></a>`descriptionHtml` | [`String`](#string) | GitLab Flavored Markdown rendering of `description`. |
| <a id="projectdora"></a>`dora` | [`Dora`](#dora) | Project's DORA metrics. |
+| <a id="projectflowmetrics"></a>`flowMetrics` **{warning-solid}** | [`ProjectValueStreamAnalyticsFlowMetrics`](#projectvaluestreamanalyticsflowmetrics) | **Introduced** in 15.10. This feature is in Alpha. It can be changed or removed at any time. Flow metrics for value stream analytics. |
| <a id="projectforkscount"></a>`forksCount` | [`Int!`](#int) | Number of times the project has been forked. |
| <a id="projectfullpath"></a>`fullPath` | [`ID!`](#id) | Full path of the project. |
| <a id="projectgrafanaintegration"></a>`grafanaIntegration` | [`GrafanaIntegration`](#grafanaintegration) | Grafana integration details for the project. |
@@ -19359,6 +19385,29 @@ Represents the source of a security policy belonging to a project.
| <a id="projectstatisticsuploadssize"></a>`uploadsSize` | [`Float`](#float) | Uploads size of the project in bytes. |
| <a id="projectstatisticswikisize"></a>`wikiSize` | [`Float`](#float) | Wiki size of the project in bytes. |
+### `ProjectValueStreamAnalyticsFlowMetrics`
+
+Exposes aggregated value stream flow metrics.
+
+#### Fields with arguments
+
+##### `ProjectValueStreamAnalyticsFlowMetrics.issueCount`
+
+Number of issues opened in the given period.
+
+Returns [`ValueStreamAnalyticsMetric`](#valuestreamanalyticsmetric).
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="projectvaluestreamanalyticsflowmetricsissuecountassigneeusernames"></a>`assigneeUsernames` | [`[String!]`](#string) | Usernames of users assigned to the issue. |
+| <a id="projectvaluestreamanalyticsflowmetricsissuecountauthorusername"></a>`authorUsername` | [`String`](#string) | Username of the author of the issue. |
+| <a id="projectvaluestreamanalyticsflowmetricsissuecountfrom"></a>`from` | [`Time!`](#time) | Issues created after the date. |
+| <a id="projectvaluestreamanalyticsflowmetricsissuecountlabelnames"></a>`labelNames` | [`[String!]`](#string) | Labels applied to the issue. |
+| <a id="projectvaluestreamanalyticsflowmetricsissuecountmilestonetitle"></a>`milestoneTitle` | [`String`](#string) | Milestone applied to the issue. |
+| <a id="projectvaluestreamanalyticsflowmetricsissuecountto"></a>`to` | [`Time!`](#time) | Issues created before the date. |
+
### `PrometheusAlert`
The alert condition for Prometheus.
@@ -21052,6 +21101,18 @@ fields relate to interactions between the two entities.
| <a id="userstatusmessage"></a>`message` | [`String`](#string) | User status message. |
| <a id="userstatusmessagehtml"></a>`messageHtml` | [`String`](#string) | HTML of the user status message. |
+### `ValueStreamAnalyticsMetric`
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="valuestreamanalyticsmetricidentifier"></a>`identifier` | [`String!`](#string) | Identifier for the metric. |
+| <a id="valuestreamanalyticsmetriclinks"></a>`links` | [`[String!]!`](#string) | Optional links for drilling down. |
+| <a id="valuestreamanalyticsmetrictitle"></a>`title` | [`String!`](#string) | Title for the metric. |
+| <a id="valuestreamanalyticsmetricunit"></a>`unit` | [`String`](#string) | Unit of measurement. |
+| <a id="valuestreamanalyticsmetricvalue"></a>`value` | [`Float`](#float) | Value for the metric. |
+
### `VulnerabilitiesCountByDay`
Represents the count of vulnerabilities by severity on a particular day. This data is retained for 365 days.
diff --git a/doc/operations/incident_management/incident_timeline_events.md b/doc/operations/incident_management/incident_timeline_events.md
index e79f36884cb..d23797c6d1d 100644
--- a/doc/operations/incident_management/incident_timeline_events.md
+++ b/doc/operations/incident_management/incident_timeline_events.md
@@ -110,12 +110,12 @@ Alternatively:
## Incident tags
-> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/8741) in GitLab 15.9 [with a flag](../../administration/feature_flags.md) named `incident_event_tags`. Disabled by default.
+> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/8741) in GitLab 15.9 [with a flag](../../administration/feature_flags.md) named `incident_event_tags`. Disabled by default.
+> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/387647) in GitLab 15.9.
FLAG:
On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to [enable the feature flag](../../administration/feature_flags.md) named `incident_event_tags`.
-On GitLab.com, this feature is not available.
-This feature is not ready for production use.
+On GitLab.com, this feature is available.
[When creating an event using the form](#using-the-form) or editing it,
you can specify incident tags to capture relevant incident timestamps.
diff --git a/doc/user/application_security/policies/img/association_diagram.png b/doc/user/application_security/policies/img/association_diagram.png
index d082e297c68..3a56aeba91b 100644
--- a/doc/user/application_security/policies/img/association_diagram.png
+++ b/doc/user/application_security/policies/img/association_diagram.png
Binary files differ
diff --git a/doc/user/application_security/policies/img/policy_rule_mode_v14_9.png b/doc/user/application_security/policies/img/policy_rule_mode_v14_9.png
deleted file mode 100644
index 8ca7547a33c..00000000000
--- a/doc/user/application_security/policies/img/policy_rule_mode_v14_9.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/application_security/policies/img/policy_rule_mode_v15_9.png b/doc/user/application_security/policies/img/policy_rule_mode_v15_9.png
new file mode 100644
index 00000000000..8cb2e82ac05
--- /dev/null
+++ b/doc/user/application_security/policies/img/policy_rule_mode_v15_9.png
Binary files differ
diff --git a/doc/user/application_security/policies/img/policy_yaml_mode_v14_9.png b/doc/user/application_security/policies/img/policy_yaml_mode_v14_9.png
deleted file mode 100644
index 1d71e8684e9..00000000000
--- a/doc/user/application_security/policies/img/policy_yaml_mode_v14_9.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/application_security/policies/img/policy_yaml_mode_v15_9.png b/doc/user/application_security/policies/img/policy_yaml_mode_v15_9.png
new file mode 100644
index 00000000000..95b637efef3
--- /dev/null
+++ b/doc/user/application_security/policies/img/policy_yaml_mode_v15_9.png
Binary files differ
diff --git a/doc/user/application_security/policies/img/scan_execution_policy_rule_mode_v15_5.png b/doc/user/application_security/policies/img/scan_execution_policy_rule_mode_v15_5.png
deleted file mode 100644
index 5ae7c2e065a..00000000000
--- a/doc/user/application_security/policies/img/scan_execution_policy_rule_mode_v15_5.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/application_security/policies/img/scan_execution_policy_rule_mode_v15_9.png b/doc/user/application_security/policies/img/scan_execution_policy_rule_mode_v15_9.png
new file mode 100644
index 00000000000..57e729158da
--- /dev/null
+++ b/doc/user/application_security/policies/img/scan_execution_policy_rule_mode_v15_9.png
Binary files differ
diff --git a/doc/user/application_security/policies/index.md b/doc/user/application_security/policies/index.md
index f1aafc3d38f..be10be8e707 100644
--- a/doc/user/application_security/policies/index.md
+++ b/doc/user/application_security/policies/index.md
@@ -23,8 +23,8 @@ GitLab supports the following security policies:
## Security policy project
All security policies are stored as YAML in a separate security policy project that gets linked to
-the development project. This association can be a one-to-many relationship, allowing one security
-policy project to apply to multiple development projects. Linked projects are not required to be in
+the development project, group, or sub-group. This association can be a one-to-many relationship, allowing one security
+policy project to apply to multiple development projects, groups, or sub-groups. Linked projects are not required to be in
the same group as the development projects to which they are linked.
![Security Policy Project Linking Diagram](img/association_diagram.png)
@@ -104,13 +104,13 @@ The policy editor has two modes:
- The visual _Rule_ mode allows you to construct and preview policy
rules using rule blocks and related controls.
- ![Policy Editor Rule Mode](img/policy_rule_mode_v14_9.png)
+ ![Policy Editor Rule Mode](img/policy_rule_mode_v15_9.png)
- YAML mode allows you to enter a policy definition in `.yaml` format
and is aimed at expert users and cases that the Rule mode doesn't
support.
- ![Policy Editor YAML Mode](img/policy_yaml_mode_v14_9.png)
+ ![Policy Editor YAML Mode](img/policy_yaml_mode_v15_9.png)
You can use both modes interchangeably and switch between them at any
time. If a YAML resource is incorrect or contains data not supported
diff --git a/doc/user/application_security/policies/scan-execution-policies.md b/doc/user/application_security/policies/scan-execution-policies.md
index 26c7e1d9c77..539589f03d7 100644
--- a/doc/user/application_security/policies/scan-execution-policies.md
+++ b/doc/user/application_security/policies/scan-execution-policies.md
@@ -44,7 +44,7 @@ Most policy changes take effect as soon as the merge request is merged. Any chan
do not go through a merge request and are committed directly to the default branch may require up to 10 minutes
before the policy changes take effect.
-![Scan Execution Policy Editor Rule Mode](img/scan_execution_policy_rule_mode_v15_5.png)
+![Scan Execution Policy Editor Rule Mode](img/scan_execution_policy_rule_mode_v15_9.png)
## Scan execution policies schema
diff --git a/doc/user/group/compliance_frameworks.md b/doc/user/group/compliance_frameworks.md
index 84cca5800c2..2baa6aa54bb 100644
--- a/doc/user/group/compliance_frameworks.md
+++ b/doc/user/group/compliance_frameworks.md
@@ -208,11 +208,11 @@ audit trail:
- "# No after scripts."
include: # Execute individual project's configuration (if project contains .gitlab-ci.yml)
- project: '$CI_PROJECT_PATH'
- file: '$CI_CONFIG_PATH'
- ref: '$CI_COMMIT_SHA' # Must be defined or MR pipelines always use the use default branch
- rules:
- - if: $CI_PROJECT_PATH != "my-group/project-1" # Must be the hardcoded path to the project that hosts this configuration.
+ - project: '$CI_PROJECT_PATH'
+ file: '$CI_CONFIG_PATH'
+ ref: '$CI_COMMIT_SHA' # Must be defined or MR pipelines always use the use default branch
+ rules:
+ - if: $CI_PROJECT_PATH != "my-group/project-1" # Must be the hardcoded path to the project that hosts this configuration.
```
The `rules` configuration in the `include` definition avoids circular inclusion in case the compliance pipeline must be able to run in the host project itself.
diff --git a/doc/user/group/import/index.md b/doc/user/group/import/index.md
index 1647c7d38ee..4ec06173e95 100644
--- a/doc/user/group/import/index.md
+++ b/doc/user/group/import/index.md
@@ -63,11 +63,18 @@ groups are in the same GitLab instance. Transferring groups is a faster and more
See [epic 6629](https://gitlab.com/groups/gitlab-org/-/epics/6629) for a list of known issues for migrating by direct
transfer.
-### Rate limit
-
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/386452) in GitLab 15.9.
-
-Each user can perform up to six migrations per minute.
+### Limits
+
+| Limit | Description |
+|:------------|:-------------------------------------------------------------------------------------------------------------------------------------|
+| 6 | Maximum number of migrations per minute per user. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/386452) in GitLab 15.9. |
+| 5 GB | Maximum relation size that can be downloaded from the source instance. |
+| 10 GB | Maximum size of a decompressed archive. |
+| 210 seconds | Maximum number of seconds to wait for decompressing an archive file. |
+| 50 MB | Maximum length an NDJSON row can have. |
+| 5 minutes | Maximum number of seconds until an empty export status on source instance is raised. |
+| 8 hours | Time until migration times out. |
+| 90 minutes | Time the destination is waiting for export to complete. |
### Visibility rules
@@ -105,16 +112,15 @@ To migrate groups by direct transfer:
To ensure GitLab maps users and their contributions correctly:
-1. Create the required users on the destination GitLab instance. When migrating to GitLab.com, you must create users
- manually unless [SCIM](../../group/saml_sso/scim_setup.md) is used. Creating users with the API is only available to
- self-managed instances because it requires administrator access.
-1. Check that users have a public email on the source GitLab instance that matches their primary email on the
- destination GitLab instance.
-1. Ensure that users confirm their primary email addresses on the destination GitLab instance. Most users receive an
- email asking them to confirm their email address.
-1. If using an OmniAuth provider like SAML, link GitLab and SAML accounts of users on GitLab. All users on the
- destination GitLab instance must sign in and verify their account on the destination GitLab instance. If using
- [SAML SSO for GitLab.com groups](../../group/saml_sso/index.md), users must
+1. Create the required users on the destination GitLab instance. You can create users with the API only on self-managed instances because it requires
+ administrator access. When migrating to GitLab.com or a self-managed GitLab instance you can:
+ - Create users manually.
+ - Set up or use your existing [SAML SSO provider](../saml_sso/index.md) and leverage user synchronization of SAML SSO groups supported through
+ [SCIM](../../group/saml_sso/scim_setup.md). You can
+ [bypass the GitLab user account verification with verified email domains](../saml_sso/index.md#bypass-user-email-confirmation-with-verified-domains).
+ 1. Ensure that users have a public email on the source GitLab instance that matches any confirmed email address on the destination GitLab instance. Most
+ users receive an email asking them to confirm their email address.
+ 1. If users already exist on the destination instance and you use [SAML SSO for GitLab.com groups](../../group/saml_sso/index.md), all users must
[link their SAML identity to their GitLab.com account](../../group/saml_sso/index.md#linking-saml-to-your-existing-gitlabcom-account).
### Connect the source GitLab instance
@@ -175,11 +181,13 @@ for your version of GitLab to see the list of items relevant to you. For example
Group items that are migrated to the destination GitLab instance include:
- Badges ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/292431) in 13.11)
-- Board Lists
-- Boards
+- Boards ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/18938) in GitLab 13.7)
+- Board Lists ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/24863) in GitLab 13.7)
- Epics ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/250281) in 13.7)
- Epic resource state events ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/291983) in GitLab 15.4)
-- Finisher
+ - Label associations ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/62074) in GitLab 13.12)
+ - State and State ID ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28203) in GitLab 13.7)
+ - System Note Metadata ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63551) in GitLab 14.0)
- Group Labels ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/292429) in 13.9)
- Iterations ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/292428) in 13.10)
- Iterations cadences ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/96570) in 15.4)
@@ -189,11 +197,11 @@ Group items that are migrated to the destination GitLab instance include:
- The user has a public email in the source GitLab instance that matches a
confirmed email in the destination GitLab instance
- Milestones ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/292427) in 13.10)
-- Namespace Settings
+- Namespace Settings ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85128) in GitLab 14.10)
- Releases
- Milestones ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/339422) in GitLab 15.0).
-- Subgroups
-- Uploads
+- Subgroups ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/18938) in GitLab 13.7)
+- Uploads ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/18938) in GitLab 13.7)
Any other items are **not** migrated.
@@ -409,7 +417,7 @@ Items that are **not** exported include:
- To preserve the member list and their respective permissions on imported groups, review the users in these groups. Make
sure these users exist before importing the desired groups.
-- Users must set a public email in the source GitLab instance that matches one of their verified emails in the target GitLab instance.
+- Users must set a public email in the source GitLab instance that matches their confirmed primary email in the destination GitLab instance. Most users receive an email asking them to confirm their email address.
### Enable export for a group
diff --git a/lib/gitlab/database/async_foreign_keys/migration_helpers.rb b/lib/gitlab/database/async_foreign_keys/migration_helpers.rb
index b8b9fc6d156..eb33b9dc1f6 100644
--- a/lib/gitlab/database/async_foreign_keys/migration_helpers.rb
+++ b/lib/gitlab/database/async_foreign_keys/migration_helpers.rb
@@ -38,7 +38,9 @@ module Gitlab
fk_name = name || concurrent_foreign_key_name(table_name, column_name)
- PostgresAsyncForeignKeyValidation.find_by(name: fk_name).try(&:destroy)
+ PostgresAsyncForeignKeyValidation
+ .find_by(name: fk_name, table_name: table_name)
+ .try(&:destroy!)
end
def prepare_partitioned_async_foreign_key_validation(table_name, column_name = nil, name: nil)
diff --git a/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation.rb b/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation.rb
index de69a3d496f..fb01c1e2025 100644
--- a/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation.rb
+++ b/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation.rb
@@ -11,7 +11,7 @@ module Gitlab
MAX_IDENTIFIER_LENGTH = Gitlab::Database::MigrationHelpers::MAX_IDENTIFIER_NAME_LENGTH
MAX_LAST_ERROR_LENGTH = 10_000
- validates :name, presence: true, uniqueness: true, length: { maximum: MAX_IDENTIFIER_LENGTH }
+ validates :name, presence: true, uniqueness: { scope: :table_name }, length: { maximum: MAX_IDENTIFIER_LENGTH }
validates :table_name, presence: true, length: { maximum: MAX_IDENTIFIER_LENGTH }
scope :ordered, -> { order(attempts: :asc, id: :asc) }
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index d67ea731230..e087cd913d9 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -2385,6 +2385,9 @@ msgstr ""
msgid "Add new directory"
msgstr ""
+msgid "Add new saved reply"
+msgstr ""
+
msgid "Add or remove a user."
msgstr ""
@@ -4708,6 +4711,9 @@ msgstr ""
msgid "An unexpected error occurred while stopping the Web Terminal."
msgstr ""
+msgid "An unexpected error occurred. Please try again."
+msgstr ""
+
msgid "An unknown error occurred while loading this graph."
msgstr ""
@@ -11215,6 +11221,9 @@ msgstr ""
msgid "ContainerRegistry|You can add an image to this registry with the following commands:"
msgstr ""
+msgid "Content"
+msgstr ""
+
msgid "Content parsed with %{link}."
msgstr ""
@@ -31966,6 +31975,9 @@ msgstr ""
msgid "Please enable and migrate to hashed storage to avoid security issues and ensure data integrity. %{migrate_link}"
msgstr ""
+msgid "Please enter a name for the saved reply."
+msgstr ""
+
msgid "Please enter a non-negative number"
msgstr ""
@@ -31987,6 +31999,9 @@ msgstr ""
msgid "Please enter a value of 90 days or more"
msgstr ""
+msgid "Please enter the saved reply content."
+msgstr ""
+
msgid "Please enter your current password."
msgstr ""
@@ -48849,6 +48864,9 @@ msgstr ""
msgid "Write milestone description..."
msgstr ""
+msgid "Write saved reply content here…"
+msgstr ""
+
msgid "Write your release notes or drag your files here…"
msgstr ""
diff --git a/spec/features/profiles/user_creates_saved_reply_spec.rb b/spec/features/profiles/user_creates_saved_reply_spec.rb
new file mode 100644
index 00000000000..1d851b5cea0
--- /dev/null
+++ b/spec/features/profiles/user_creates_saved_reply_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Profile > Saved replies > User creates saved reply', :js,
+ feature_category: :user_profile do
+ let_it_be(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+
+ visit profile_saved_replies_path
+
+ wait_for_requests
+ end
+
+ it 'shows the user a list of their saved replies' do
+ find('[data-testid="saved-reply-name-input"]').set('test')
+ find('[data-testid="saved-reply-content-input"]').set('Test content')
+
+ click_button 'Save'
+
+ wait_for_requests
+
+ expect(page).to have_content('My saved replies (1)')
+ expect(page).to have_content('test')
+ expect(page).to have_content('Test content')
+ end
+end
diff --git a/spec/frontend/fixtures/saved_replies.rb b/spec/frontend/fixtures/saved_replies.rb
index c80ba06bca1..613e4a1b447 100644
--- a/spec/frontend/fixtures/saved_replies.rb
+++ b/spec/frontend/fixtures/saved_replies.rb
@@ -43,4 +43,32 @@ RSpec.describe GraphQL::Query, type: :request, feature_category: :user_profile d
expect_graphql_errors_to_be_empty
end
end
+
+ context 'when user creates saved reply' do
+ base_input_path = 'saved_replies/queries/'
+ base_output_path = 'graphql/saved_replies/'
+ query_name = 'create_saved_reply.mutation.graphql'
+
+ it "#{base_output_path}#{query_name}.json" do
+ query = get_graphql_query_as_string("#{base_input_path}#{query_name}")
+
+ post_graphql(query, current_user: current_user, variables: { name: "Test", content: "Test content" })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+
+ context 'when user creates saved reply and it errors' do
+ base_input_path = 'saved_replies/queries/'
+ base_output_path = 'graphql/saved_replies/'
+ query_name = 'create_saved_reply.mutation.graphql'
+
+ it "#{base_output_path}create_saved_reply_with_errors.mutation.graphql.json" do
+ query = get_graphql_query_as_string("#{base_input_path}#{query_name}")
+
+ post_graphql(query, current_user: current_user, variables: { name: nil, content: nil })
+
+ expect(flattened_errors).not_to be_empty
+ end
+ end
end
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js
index 27c0ab96cfc..fc7f5c80d45 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js
@@ -1,14 +1,18 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue';
+import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue';
import PackageVersionsList from '~/packages_and_registries/package_registry/components/details/package_versions_list.vue';
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue';
import Tracking from '~/tracking';
import {
+ CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
+ DELETE_PACKAGE_VERSION_TRACKING_ACTION,
DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
+ REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
} from '~/packages_and_registries/package_registry/constants';
import { packageData } from '../../mock_data';
@@ -22,7 +26,7 @@ describe('PackageVersionsList', () => {
name: 'version 1',
}),
packageData({
- id: `gid://gitlab/Packages::Package/112`,
+ id: 'gid://gitlab/Packages::Package/112',
name: 'version 2',
}),
];
@@ -31,8 +35,10 @@ describe('PackageVersionsList', () => {
findLoader: () => wrapper.findComponent(PackagesListLoader),
findRegistryList: () => wrapper.findComponent(RegistryList),
findEmptySlot: () => wrapper.findComponent(EmptySlotStub),
- findListRow: () => wrapper.findAllComponents(VersionRow),
+ findListRow: () => wrapper.findComponent(VersionRow),
+ findAllListRow: () => wrapper.findAllComponents(VersionRow),
findDeletePackagesModal: () => wrapper.findComponent(DeleteModal),
+ findPackageListDeleteModal: () => wrapper.findComponent(DeletePackageModal),
};
const mountComponent = (props) => {
wrapper = shallowMountExtended(PackageVersionsList, {
@@ -118,16 +124,16 @@ describe('PackageVersionsList', () => {
});
it('displays package version rows', () => {
- expect(uiElements.findListRow().exists()).toEqual(true);
- expect(uiElements.findListRow()).toHaveLength(packageList.length);
+ expect(uiElements.findAllListRow().exists()).toEqual(true);
+ expect(uiElements.findAllListRow()).toHaveLength(packageList.length);
});
it('binds the correct props', () => {
- expect(uiElements.findListRow().at(0).props()).toMatchObject({
+ expect(uiElements.findAllListRow().at(0).props()).toMatchObject({
packageEntity: expect.objectContaining(packageList[0]),
});
- expect(uiElements.findListRow().at(1).props()).toMatchObject({
+ expect(uiElements.findAllListRow().at(1).props()).toMatchObject({
packageEntity: expect.objectContaining(packageList[1]),
});
});
@@ -159,6 +165,68 @@ describe('PackageVersionsList', () => {
});
});
+ describe.each`
+ description | finderFunction | deletePayload
+ ${'when the user can destroy the package'} | ${uiElements.findListRow} | ${packageList[0]}
+ ${'when the user can bulk destroy packages and deletes only one package'} | ${uiElements.findRegistryList} | ${[packageList[0]]}
+ `('$description', ({ finderFunction, deletePayload }) => {
+ let eventSpy;
+ const category = 'UI::NpmPackages';
+ const { findPackageListDeleteModal } = uiElements;
+
+ beforeEach(() => {
+ eventSpy = jest.spyOn(Tracking, 'event');
+ mountComponent({ canDestroy: true });
+ finderFunction().vm.$emit('delete', deletePayload);
+ });
+
+ it('passes itemToBeDeleted to the modal', () => {
+ expect(findPackageListDeleteModal().props('itemToBeDeleted')).toStrictEqual(packageList[0]);
+ });
+
+ it('requesting delete tracks the right action', () => {
+ expect(eventSpy).toHaveBeenCalledWith(
+ category,
+ REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
+ expect.any(Object),
+ );
+ });
+
+ describe('when modal confirms', () => {
+ beforeEach(() => {
+ findPackageListDeleteModal().vm.$emit('ok');
+ });
+
+ it('emits delete when modal confirms', () => {
+ expect(wrapper.emitted('delete')[0][0]).toEqual([packageList[0]]);
+ });
+
+ it('tracks the right action', () => {
+ expect(eventSpy).toHaveBeenCalledWith(
+ category,
+ DELETE_PACKAGE_VERSION_TRACKING_ACTION,
+ expect.any(Object),
+ );
+ });
+ });
+
+ it.each(['ok', 'cancel'])('resets itemToBeDeleted when modal emits %s', async (event) => {
+ await findPackageListDeleteModal().vm.$emit(event);
+
+ expect(findPackageListDeleteModal().props('itemToBeDeleted')).toBeNull();
+ });
+
+ it('canceling delete tracks the right action', () => {
+ findPackageListDeleteModal().vm.$emit('cancel');
+
+ expect(eventSpy).toHaveBeenCalledWith(
+ category,
+ CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
+ expect.any(Object),
+ );
+ });
+ });
+
describe('when the user can bulk destroy versions', () => {
let eventSpy;
const { findDeletePackagesModal, findRegistryList } = uiElements;
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js
index 8cb51aaf738..9f3dcc18fb6 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js
@@ -1,4 +1,4 @@
-import { GlFormCheckbox, GlIcon, GlLink, GlSprintf, GlTruncate } from '@gitlab/ui';
+import { GlDropdownItem, GlFormCheckbox, GlIcon, GlLink, GlSprintf, GlTruncate } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
@@ -24,6 +24,7 @@ describe('VersionRow', () => {
const findPackageName = () => wrapper.findComponent(GlTruncate);
const findWarningIcon = () => wrapper.findComponent(GlIcon);
const findBulkDeleteAction = () => wrapper.findComponent(GlFormCheckbox);
+ const findDeleteDropdownItem = () => wrapper.findComponent(GlDropdownItem);
function createComponent({ packageEntity = packageVersion, selected = false } = {}) {
wrapper = shallowMountExtended(VersionRow, {
@@ -112,6 +113,31 @@ describe('VersionRow', () => {
});
});
+ describe('delete button', () => {
+ it('does not exist when package cannot be destroyed', () => {
+ createComponent({ packageEntity: { ...packageVersion, canDestroy: false } });
+
+ expect(findDeleteDropdownItem().exists()).toBe(false);
+ });
+
+ it('exists and has the correct props', () => {
+ createComponent();
+
+ expect(findDeleteDropdownItem().exists()).toBe(true);
+ expect(findDeleteDropdownItem().attributes()).toMatchObject({
+ variant: 'danger',
+ });
+ });
+
+ it('emits the delete event when the delete button is clicked', () => {
+ createComponent();
+
+ findDeleteDropdownItem().vm.$emit('click');
+
+ expect(wrapper.emitted('delete')).toHaveLength(1);
+ });
+ });
+
describe(`when the package is in ${PACKAGE_ERROR_STATUS} status`, () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
index 19d56fe8cc6..0d40cb4fde0 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
@@ -1,5 +1,5 @@
import { GlFormCheckbox, GlSprintf, GlTruncate } from '@gitlab/ui';
-import Vue, { nextTick } from 'vue';
+import Vue from 'vue';
import VueRouter from 'vue-router';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
@@ -141,7 +141,6 @@ describe('packages_list_row', () => {
findDeleteDropdown().vm.$emit('click');
- await nextTick();
expect(wrapper.emitted('delete')).toHaveLength(1);
});
});
diff --git a/spec/frontend/saved_replies/components/form_spec.js b/spec/frontend/saved_replies/components/form_spec.js
new file mode 100644
index 00000000000..693703ca572
--- /dev/null
+++ b/spec/frontend/saved_replies/components/form_spec.js
@@ -0,0 +1,116 @@
+import Vue, { nextTick } from 'vue';
+import { mount } from '@vue/test-utils';
+import { GlAlert } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
+import createdSavedReplyResponse from 'test_fixtures/graphql/saved_replies/create_saved_reply.mutation.graphql.json';
+import createdSavedReplyErrorResponse from 'test_fixtures/graphql/saved_replies/create_saved_reply_with_errors.mutation.graphql.json';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import Form from '~/saved_replies/components/form.vue';
+import createSavedReplyMutation from '~/saved_replies/queries/create_saved_reply.mutation.graphql';
+
+let wrapper;
+let createSavedReplyResponseSpy;
+
+function createMockApolloProvider(response) {
+ Vue.use(VueApollo);
+
+ createSavedReplyResponseSpy = jest.fn().mockResolvedValue(response);
+
+ const requestHandlers = [[createSavedReplyMutation, createSavedReplyResponseSpy]];
+
+ return createMockApollo(requestHandlers);
+}
+
+function createComponent(response = createdSavedReplyResponse) {
+ const mockApollo = createMockApolloProvider(response);
+
+ return mount(Form, {
+ apolloProvider: mockApollo,
+ });
+}
+
+const findSavedReplyNameInput = () => wrapper.find('[data-testid="saved-reply-name-input"]');
+const findSavedReplyNameFormGroup = () =>
+ wrapper.find('[data-testid="saved-reply-name-form-group"]');
+const findSavedReplyContentInput = () => wrapper.find('[data-testid="saved-reply-content-input"]');
+const findSavedReplyContentFormGroup = () =>
+ wrapper.find('[data-testid="saved-reply-content-form-group"]');
+const findSavedReplyFrom = () => wrapper.find('[data-testid="saved-reply-form"]');
+const findAlerts = () => wrapper.findAllComponents(GlAlert);
+const findSubmitBtn = () => wrapper.find('[data-testid="saved-reply-form-submit-btn"]');
+
+describe('Saved replies form component', () => {
+ describe('create saved reply', () => {
+ it('calls apollo mutation', async () => {
+ wrapper = createComponent();
+
+ findSavedReplyNameInput().setValue('Test');
+ findSavedReplyContentInput().setValue('Test content');
+ findSavedReplyFrom().trigger('submit');
+
+ await waitForPromises();
+
+ expect(createSavedReplyResponseSpy).toHaveBeenCalledWith({
+ content: 'Test content',
+ name: 'Test',
+ });
+ });
+
+ it('does not submit when form validation fails', async () => {
+ wrapper = createComponent();
+
+ findSavedReplyFrom().trigger('submit');
+
+ await waitForPromises();
+
+ expect(createSavedReplyResponseSpy).not.toHaveBeenCalled();
+ });
+
+ it.each`
+ findFormGroup | findInput | fieldName
+ ${findSavedReplyNameFormGroup} | ${findSavedReplyContentInput} | ${'name'}
+ ${findSavedReplyContentFormGroup} | ${findSavedReplyNameInput} | ${'content'}
+ `('shows errors for empty $fieldName input', async ({ findFormGroup, findInput }) => {
+ wrapper = createComponent(createdSavedReplyErrorResponse);
+
+ findInput().setValue('Test');
+ findSavedReplyFrom().trigger('submit');
+
+ await waitForPromises();
+
+ expect(findFormGroup().classes('is-invalid')).toBe(true);
+ });
+
+ it('displays errors when mutation fails', async () => {
+ wrapper = createComponent(createdSavedReplyErrorResponse);
+
+ findSavedReplyNameInput().setValue('Test');
+ findSavedReplyContentInput().setValue('Test content');
+ findSavedReplyFrom().trigger('submit');
+
+ await waitForPromises();
+
+ const { errors } = createdSavedReplyErrorResponse;
+ const alertMessages = findAlerts().wrappers.map((x) => x.text());
+
+ expect(alertMessages).toEqual(errors.map((x) => x.message));
+ });
+
+ it('shows loading state when saving', async () => {
+ wrapper = createComponent();
+
+ findSavedReplyNameInput().setValue('Test');
+ findSavedReplyContentInput().setValue('Test content');
+ findSavedReplyFrom().trigger('submit');
+
+ await nextTick();
+
+ expect(findSubmitBtn().props('loading')).toBe(true);
+
+ await waitForPromises();
+
+ expect(findSubmitBtn().props('loading')).toBe(false);
+ });
+ });
+});
diff --git a/spec/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation_spec.rb b/spec/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation_spec.rb
index ba201d93f52..40ab9cb2dd2 100644
--- a/spec/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation_spec.rb
+++ b/spec/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe Gitlab::Database::AsyncForeignKeys::PostgresAsyncForeignKeyValida
subject { fk_validation }
it { is_expected.to validate_presence_of(:name) }
- it { is_expected.to validate_uniqueness_of(:name) }
+ it { is_expected.to validate_uniqueness_of(:name).scoped_to(:table_name) }
it { is_expected.to validate_length_of(:name).is_at_most(identifier_limit) }
it { is_expected.to validate_presence_of(:table_name) }
it { is_expected.to validate_length_of(:table_name).is_at_most(identifier_limit) }
diff --git a/spec/requests/api/graphql/project/flow_metrics_spec.rb b/spec/requests/api/graphql/project/flow_metrics_spec.rb
new file mode 100644
index 00000000000..0bdf7bad8db
--- /dev/null
+++ b/spec/requests/api/graphql/project/flow_metrics_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'getting project flow metrics', feature_category: :value_stream_management do
+ include GraphqlHelpers
+
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project1) { create(:project, group: group) }
+ # This is done so we can use the same count expectations in the shared examples and
+ # reuse the shared example for the group-level test.
+ let_it_be(:project2) { project1 }
+ let_it_be(:current_user) { create(:user, maintainer_projects: [project1]) }
+
+ it_behaves_like 'value stream analytics flow metrics issueCount examples' do
+ let(:full_path) { project1.full_path }
+ let(:context) { :project }
+ end
+end
diff --git a/spec/services/system_notes/commit_service_spec.rb b/spec/services/system_notes/commit_service_spec.rb
index 0399603980d..8dfb83f63fe 100644
--- a/spec/services/system_notes/commit_service_spec.rb
+++ b/spec/services/system_notes/commit_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe SystemNotes::CommitService do
+RSpec.describe SystemNotes::CommitService, feature_category: :code_review_workflow do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, group: group) }
let_it_be(:author) { create(:user) }
@@ -13,7 +13,7 @@ RSpec.describe SystemNotes::CommitService do
subject { commit_service.add_commits(new_commits, old_commits, oldrev) }
let(:noteable) { create(:merge_request, source_project: project, target_project: project) }
- let(:new_commits) { noteable.commits }
+ let(:new_commits) { create_commits(10) }
let(:old_commits) { [] }
let(:oldrev) { nil }
@@ -43,6 +43,48 @@ RSpec.describe SystemNotes::CommitService do
expect(decoded_note_content).to include("<li>#{commit.short_id} - #{commit.title}</li>")
end
end
+
+ context 'with HTML content' do
+ let(:new_commits) { [double(title: '<pre>This is a test</pre>', short_id: '12345678')] }
+
+ it 'escapes HTML titles' do
+ expect(note_lines[1]).to eq("<ul><li>12345678 - &lt;pre&gt;This is a test&lt;/pre&gt;</li></ul>")
+ end
+ end
+
+ context 'with one commit exceeding the NEW_COMMIT_DISPLAY_LIMIT' do
+ let(:new_commits) { create_commits(11) }
+ let(:earlier_commit_summary_line) { note_lines[1] }
+
+ it 'includes the truncated new commits summary' do
+ expect(earlier_commit_summary_line).to start_with("<ul><li>#{new_commits[0].short_id} - 1 earlier commit")
+ end
+
+ context 'with oldrev' do
+ let(:oldrev) { '12345678abcd' }
+
+ it 'includes the truncated new commits summary with the oldrev' do
+ expect(earlier_commit_summary_line).to start_with("<ul><li>#{new_commits[0].short_id} - 1 earlier commit")
+ end
+ end
+ end
+
+ context 'with multiple commits exceeding the NEW_COMMIT_DISPLAY_LIMIT' do
+ let(:new_commits) { create_commits(13) }
+ let(:earlier_commit_summary_line) { note_lines[1] }
+
+ it 'includes the truncated new commits summary' do
+ expect(earlier_commit_summary_line).to start_with("<ul><li>#{new_commits[0].short_id}..#{new_commits[2].short_id} - 3 earlier commits")
+ end
+
+ context 'with oldrev' do
+ let(:oldrev) { '12345678abcd' }
+
+ it 'includes the truncated new commits summary with the oldrev' do
+ expect(earlier_commit_summary_line).to start_with("<ul><li>12345678...#{new_commits[2].short_id} - 3 earlier commits")
+ end
+ end
+ end
end
describe 'summary line for existing commits' do
@@ -54,6 +96,15 @@ RSpec.describe SystemNotes::CommitService do
it 'includes the existing commit' do
expect(summary_line).to start_with("<ul><li>#{old_commits.first.short_id} - 1 commit from branch <code>feature</code>")
end
+
+ context 'with new commits exceeding the display limit' do
+ let(:summary_line) { note_lines[1] }
+ let(:new_commits) { create_commits(13) }
+
+ it 'includes the existing commit as well as the truncated new commit summary' do
+ expect(summary_line).to start_with("<ul><li>#{old_commits.first.short_id} - 1 commit from branch <code>feature</code></li><li>#{old_commits.last.short_id}...#{new_commits[2].short_id} - 3 earlier commits")
+ end
+ end
end
context 'with multiple existing commits' do
@@ -66,6 +117,15 @@ RSpec.describe SystemNotes::CommitService do
expect(summary_line)
.to start_with("<ul><li>#{Commit.truncate_sha(oldrev)}...#{old_commits.last.short_id} - 26 commits from branch <code>feature</code>")
end
+
+ context 'with new commits exceeding the display limit' do
+ let(:new_commits) { create_commits(13) }
+
+ it 'includes the existing commit as well as the truncated new commit summary' do
+ expect(summary_line)
+ .to start_with("<ul><li>#{Commit.truncate_sha(oldrev)}...#{old_commits.last.short_id} - 26 commits from branch <code>feature</code></li><li>#{old_commits.last.short_id}...#{new_commits[2].short_id} - 3 earlier commits")
+ end
+ end
end
context 'without oldrev' do
@@ -73,6 +133,15 @@ RSpec.describe SystemNotes::CommitService do
expect(summary_line)
.to start_with("<ul><li>#{old_commits[0].short_id}..#{old_commits[-1].short_id} - 26 commits from branch <code>feature</code>")
end
+
+ context 'with new commits exceeding the display limit' do
+ let(:new_commits) { create_commits(13) }
+
+ it 'includes the existing commit as well as the truncated new commit summary' do
+ expect(summary_line)
+ .to start_with("<ul><li>#{old_commits.first.short_id}..#{old_commits.last.short_id} - 26 commits from branch <code>feature</code></li><li>#{old_commits.last.short_id}...#{new_commits[2].short_id} - 3 earlier commits")
+ end
+ end
end
context 'on a fork' do
@@ -106,12 +175,9 @@ RSpec.describe SystemNotes::CommitService do
end
end
- describe '#new_commit_summary' do
- it 'escapes HTML titles' do
- commit = double(title: '<pre>This is a test</pre>', short_id: '12345678')
- escaped = '&lt;pre&gt;This is a test&lt;/pre&gt;'
-
- expect(described_class.new.new_commit_summary([commit])).to all(match(/- #{escaped}/))
+ def create_commits(count)
+ Array.new(count) do |i|
+ double(title: "Test commit #{i}", short_id: "abcd00#{i}")
end
end
end
diff --git a/spec/support/shared_examples/analytics/cycle_analytics/flow_metrics_examples.rb b/spec/support/shared_examples/analytics/cycle_analytics/flow_metrics_examples.rb
new file mode 100644
index 00000000000..046036c40ba
--- /dev/null
+++ b/spec/support/shared_examples/analytics/cycle_analytics/flow_metrics_examples.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'value stream analytics flow metrics issueCount examples' do
+ let_it_be(:milestone) { create(:milestone, group: group) }
+ let_it_be(:label) { create(:group_label, group: group) }
+
+ let_it_be(:author) { create(:user) }
+ let_it_be(:assignee) { create(:user) }
+
+ let_it_be(:issue1) { create(:issue, project: project1, author: author, created_at: 12.days.ago) }
+ let_it_be(:issue2) { create(:issue, project: project2, author: author, created_at: 13.days.ago) }
+
+ let_it_be(:issue3) do
+ create(:labeled_issue,
+ project: project1,
+ labels: [label],
+ author: author,
+ milestone: milestone,
+ assignees: [assignee],
+ created_at: 14.days.ago)
+ end
+
+ let_it_be(:issue4) do
+ create(:labeled_issue,
+ project: project2,
+ labels: [label],
+ assignees: [assignee],
+ created_at: 15.days.ago)
+ end
+
+ let_it_be(:issue_outside_of_the_range) { create(:issue, project: project2, author: author, created_at: 50.days.ago) }
+
+ let(:query) do
+ <<~QUERY
+ query($path: ID!, $assigneeUsernames: [String!], $authorUsername: String, $milestoneTitle: String, $labelNames: [String!], $from: Time!, $to: Time!) {
+ #{context}(fullPath: $path) {
+ flowMetrics {
+ issueCount(assigneeUsernames: $assigneeUsernames, authorUsername: $authorUsername, milestoneTitle: $milestoneTitle, labelNames: $labelNames, from: $from, to: $to) {
+ value
+ unit
+ identifier
+ title
+ }
+ }
+ }
+ }
+ QUERY
+ end
+
+ let(:variables) do
+ {
+ path: full_path,
+ from: 20.days.ago.iso8601,
+ to: 10.days.ago.iso8601
+ }
+ end
+
+ subject(:result) do
+ post_graphql(query, current_user: current_user, variables: variables)
+
+ graphql_data.dig(context.to_s, 'flowMetrics', 'issueCount')
+ end
+
+ it 'returns the correct count' do
+ expect(result).to eq({
+ 'identifier' => 'issues',
+ 'unit' => nil,
+ 'value' => 4,
+ 'title' => n_('New Issue', 'New Issues', 4)
+ })
+ end
+
+ context 'with partial filters' do
+ let(:variables) do
+ {
+ path: full_path,
+ assigneeUsernames: [assignee.username],
+ labelNames: [label.title],
+ from: 20.days.ago.iso8601,
+ to: 10.days.ago.iso8601
+ }
+ end
+
+ it 'returns filtered count' do
+ expect(result).to eq({
+ 'identifier' => 'issues',
+ 'unit' => nil,
+ 'value' => 2,
+ 'title' => n_('New Issue', 'New Issues', 2)
+ })
+ end
+ end
+
+ context 'with all filters' do
+ let(:variables) do
+ {
+ path: full_path,
+ assigneeUsernames: [assignee.username],
+ labelNames: [label.title],
+ authorUsername: author.username,
+ milestoneTitle: milestone.title,
+ from: 20.days.ago.iso8601,
+ to: 10.days.ago.iso8601
+ }
+ end
+
+ it 'returns filtered count' do
+ expect(result).to eq({
+ 'identifier' => 'issues',
+ 'unit' => nil,
+ 'value' => 1,
+ 'title' => n_('New Issue', 'New Issues', 1)
+ })
+ end
+ end
+
+ context 'when the user is not authorized' do
+ let(:current_user) { create(:user) }
+
+ it 'returns nil' do
+ expect(result).to eq(nil)
+ end
+ end
+end