diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-23 03:12:37 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-23 03:12:37 +0000 |
commit | f34b26bb882947bcc1126de19fa55eb8763af32e (patch) | |
tree | 7774da06e9e981fc80bf05b6269e1ecb8b6ab5a4 | |
parent | 04dabf41f65cf1c25d80d92b1cc5568bfcca80ee (diff) | |
download | gitlab-ce-f34b26bb882947bcc1126de19fa55eb8763af32e.tar.gz |
Add latest changes from gitlab-org/gitlab@master
46 files changed, 1123 insertions, 68 deletions
@@ -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 Binary files differindex d082e297c68..3a56aeba91b 100644 --- a/doc/user/application_security/policies/img/association_diagram.png +++ b/doc/user/application_security/policies/img/association_diagram.png 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 Binary files differdeleted file mode 100644 index 8ca7547a33c..00000000000 --- a/doc/user/application_security/policies/img/policy_rule_mode_v14_9.png +++ /dev/null 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 Binary files differnew file mode 100644 index 00000000000..8cb2e82ac05 --- /dev/null +++ b/doc/user/application_security/policies/img/policy_rule_mode_v15_9.png 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 Binary files differdeleted file mode 100644 index 1d71e8684e9..00000000000 --- a/doc/user/application_security/policies/img/policy_yaml_mode_v14_9.png +++ /dev/null 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 Binary files differnew file mode 100644 index 00000000000..95b637efef3 --- /dev/null +++ b/doc/user/application_security/policies/img/policy_yaml_mode_v15_9.png 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 Binary files differdeleted file mode 100644 index 5ae7c2e065a..00000000000 --- a/doc/user/application_security/policies/img/scan_execution_policy_rule_mode_v15_5.png +++ /dev/null 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 Binary files differnew file mode 100644 index 00000000000..57e729158da --- /dev/null +++ b/doc/user/application_security/policies/img/scan_execution_policy_rule_mode_v15_9.png 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.  @@ -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. -  +  - 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. -  +  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 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 - <pre>This is a test</pre></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 = '<pre>This is a test</pre>' - - 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 |