diff options
102 files changed, 1313 insertions, 386 deletions
diff --git a/app/assets/javascripts/pages/profiles/saved_replies/index.js b/app/assets/javascripts/pages/profiles/saved_replies/index.js new file mode 100644 index 00000000000..ef227b82172 --- /dev/null +++ b/app/assets/javascripts/pages/profiles/saved_replies/index.js @@ -0,0 +1,3 @@ +import { initSavedReplies } from '~/saved_replies'; + +initSavedReplies(); diff --git a/app/assets/javascripts/saved_replies/components/app.vue b/app/assets/javascripts/saved_replies/components/app.vue new file mode 100644 index 00000000000..db8476c44f3 --- /dev/null +++ b/app/assets/javascripts/saved_replies/components/app.vue @@ -0,0 +1,23 @@ +<script> +export default {}; +</script> + +<template> + <div class="row gl-mt-5"> + <div class="col-lg-4"> + <h4 class="gl-mt-0"> + {{ __('Saved Replies') }} + </h4> + <p> + {{ + __( + 'Saved replies can be used when creating comments inside issues, merge requests, and epics.', + ) + }} + </p> + </div> + <div class="col-lg-8"> + <router-view /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/saved_replies/components/list.vue b/app/assets/javascripts/saved_replies/components/list.vue new file mode 100644 index 00000000000..30089cfa53f --- /dev/null +++ b/app/assets/javascripts/saved_replies/components/list.vue @@ -0,0 +1,57 @@ +<script> +import { GlKeysetPagination, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import savedRepliesQuery from '../queries/saved_replies.query.graphql'; +import ListItem from './list_item.vue'; + +export default { + apollo: { + savedReplies: { + query: savedRepliesQuery, + update: (r) => r.currentUser?.savedReplies?.nodes, + result({ data }) { + const pageInfo = data.currentUser?.savedReplies?.pageInfo; + + this.count = data.currentUser?.savedReplies?.count; + + if (pageInfo) { + this.pageInfo = pageInfo; + } + }, + }, + }, + components: { + GlLoadingIcon, + GlKeysetPagination, + GlSprintf, + ListItem, + }, + data() { + return { + savedReplies: [], + count: 0, + pageInfo: {}, + }; + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="$apollo.queries.savedReplies.loading" size="lg" /> + <template v-else> + <h5 class="gl-font-lg" data-testid="title"> + <gl-sprintf :message="__('My saved replies (%{count})')"> + <template #count>{{ count }}</template> + </gl-sprintf> + </h5> + <ul class="gl-list-style-none gl-p-0 gl-m-0"> + <list-item v-for="reply in savedReplies" :key="reply.id" :reply="reply" /> + </ul> + <gl-keyset-pagination + v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage" + v-bind="pageInfo" + class="gl-mt-4" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/saved_replies/components/list_item.vue b/app/assets/javascripts/saved_replies/components/list_item.vue new file mode 100644 index 00000000000..dfa9a405dee --- /dev/null +++ b/app/assets/javascripts/saved_replies/components/list_item.vue @@ -0,0 +1,19 @@ +<script> +export default { + props: { + reply: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <li class="gl-mb-5"> + <div class="gl-display-flex gl-align-items-center"> + <strong>{{ reply.name }}</strong> + </div> + <div class="gl-mt-3 gl-font-monospace">{{ reply.content }}</div> + </li> +</template> diff --git a/app/assets/javascripts/saved_replies/index.js b/app/assets/javascripts/saved_replies/index.js new file mode 100644 index 00000000000..5022ff62b10 --- /dev/null +++ b/app/assets/javascripts/saved_replies/index.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import routes from './routes'; +import App from './components/app.vue'; + +export const initSavedReplies = () => { + Vue.use(VueApollo); + Vue.use(VueRouter); + + const el = document.getElementById('js-saved-replies-root'); + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + const router = new VueRouter({ + base: el.dataset.basePath, + mode: 'history', + routes, + }); + + // eslint-disable-next-line no-new + new Vue({ + el, + router, + apolloProvider, + render(h) { + return h(App); + }, + }); +}; diff --git a/app/assets/javascripts/saved_replies/pages/index.vue b/app/assets/javascripts/saved_replies/pages/index.vue new file mode 100644 index 00000000000..38f51dbc365 --- /dev/null +++ b/app/assets/javascripts/saved_replies/pages/index.vue @@ -0,0 +1,15 @@ +<script> +import List from '../components/list.vue'; + +export default { + components: { + List, + }, +}; +</script> + +<template> + <div> + <list /> + </div> +</template> diff --git a/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql b/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql new file mode 100644 index 00000000000..af1f12f3ceb --- /dev/null +++ b/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql @@ -0,0 +1,19 @@ +query savedReplies { + currentUser { + id + savedReplies { + nodes { + id + name + content + } + count + pageInfo { + hasNextPage + hasPreviousPage + endCursor + startCursor + } + } + } +} diff --git a/app/assets/javascripts/saved_replies/routes.js b/app/assets/javascripts/saved_replies/routes.js new file mode 100644 index 00000000000..bd582a5ed86 --- /dev/null +++ b/app/assets/javascripts/saved_replies/routes.js @@ -0,0 +1,8 @@ +import IndexComponent from './pages/index.vue'; + +export default [ + { + path: '/', + component: IndexComponent, + }, +]; diff --git a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue index 70253f88795..c6261c99e81 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue @@ -140,6 +140,7 @@ export default { :note="note" :discussion-id="discussionId" @startReplying="showReplyForm" + @deleteNote="$emit('deleteNote', note)" /> <discussion-notes-replies-wrapper> <toggle-replies-widget @@ -155,6 +156,7 @@ export default { discussion-id="discussionId" :note="reply" @startReplying="showReplyForm" + @deleteNote="$emit('deleteNote', reply)" /> </template> <work-item-note-replying v-if="isReplying" :body="replyingText" /> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue index 6985e8b3695..a4ffde81a3c 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue @@ -1,5 +1,6 @@ <script> -import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import { GlAvatarLink, GlAvatar, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import NoteBody from '~/work_items/components/notes/work_item_note_body.vue'; import NoteHeader from '~/notes/components/note_header.vue'; @@ -8,6 +9,10 @@ import { renderGFM } from '~/behaviors/markdown/render_gfm'; export default { name: 'WorkItemNoteThread', + i18n: { + moreActionsText: __('More actions'), + deleteNoteText: __('Delete comment'), + }, components: { TimelineEntryItem, NoteBody, @@ -15,6 +20,11 @@ export default { NoteActions, GlAvatar, GlAvatarLink, + GlDropdown, + GlDropdownItem, + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { note: { @@ -68,6 +78,26 @@ export default { <div class="note-header"> <note-header :author="author" :created-at="note.createdAt" :note-id="note.id" /> <note-actions :show-reply="showReply" @startReplying="showReplyForm" /> + <!-- v-if condition should be moved to "delete" dropdown item as soon as we implement copying the link --> + <gl-dropdown + v-if="note.userPermissions.adminNote" + v-gl-tooltip + icon="ellipsis_v" + text-sr-only + right + :text="$options.i18n.moreActionsText" + :title="$options.i18n.moreActionsText" + category="tertiary" + no-caret + > + <gl-dropdown-item + variant="danger" + data-testid="delete-note-action" + @click="$emit('deleteNote')" + > + {{ $options.i18n.deleteNoteText }} + </gl-dropdown-item> + </gl-dropdown> </div> <div class="timeline-discussion-body"> <note-body ref="noteBody" :note="note" /> diff --git a/app/assets/javascripts/work_items/components/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/work_item_comment_form.vue index 53e704f3da6..3f5a907b88e 100644 --- a/app/assets/javascripts/work_items/components/work_item_comment_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_comment_form.vue @@ -11,7 +11,7 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { updateCommentState } from '~/work_items/graphql/cache_utils'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import { getWorkItemQuery } from '../utils'; -import createNoteMutation from '../graphql/create_work_item_note.mutation.graphql'; +import createNoteMutation from '../graphql/notes/create_work_item_note.mutation.graphql'; import { TRACKING_CATEGORY_SHOW, i18n } from '../constants'; import WorkItemNoteSignedOut from './work_item_note_signed_out.vue'; import WorkItemCommentLocked from './work_item_comment_locked.vue'; diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue index f4df9012c1d..c42c9be4479 100644 --- a/app/assets/javascripts/work_items/components/work_item_notes.vue +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -1,12 +1,15 @@ <script> -import { GlSkeletonLoader } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { GlSkeletonLoader, GlModal } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { s__, __ } from '~/locale'; +import { TYPENAME_DISCUSSION, TYPENAME_NOTE } from '~/graphql_shared/constants'; import SystemNote from '~/work_items/components/notes/system_note.vue'; import ActivityFilter from '~/work_items/components/notes/activity_filter.vue'; import { i18n, DEFAULT_PAGE_SIZE_NOTES } from '~/work_items/constants'; import { ASC, DESC } from '~/notes/constants'; import { getWorkItemNotesQuery } from '~/work_items/utils'; import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue'; +import deleteNoteMutation from '../graphql/notes/delete_work_item_notes.mutation.graphql'; import WorkItemCommentForm from './work_item_comment_form.vue'; export default { @@ -20,6 +23,7 @@ export default { }, components: { GlSkeletonLoader, + GlModal, ActivityFilter, SystemNote, WorkItemCommentForm, @@ -53,6 +57,7 @@ export default { isLoadingMore: false, perPage: DEFAULT_PAGE_SIZE_NOTES, sortOrder: ASC, + noteToDelete: null, }; }, computed: { @@ -173,6 +178,45 @@ export default { .catch((error) => this.$emit('error', error.message)); this.isLoadingMore = false; }, + showDeleteNoteModal(note, discussion) { + const isLastNote = discussion.notes.nodes.length === 1; + this.$refs.deleteNoteModal.show(); + this.noteToDelete = { ...note, isLastNote }; + }, + cancelDeletingNote() { + this.noteToDelete = null; + }, + async deleteNote() { + try { + const { id, isLastNote, discussion } = this.noteToDelete; + await this.$apollo.mutate({ + mutation: deleteNoteMutation, + variables: { + input: { + id, + }, + }, + update(cache) { + const deletedObject = isLastNote + ? { __typename: TYPENAME_DISCUSSION, id: discussion.id } + : { __typename: TYPENAME_NOTE, id }; + cache.modify({ + id: cache.identify(deletedObject), + fields: (_, { DELETE }) => DELETE, + }); + }, + optimisticResponse: { + destroyNote: { + note: null, + __typename: 'DestroyNotePayload', + }, + }, + }); + } catch (error) { + this.$emit('error', __('Something went wrong when deleting a comment. Please try again')); + Sentry.captureException(error); + } + }, }, }; </script> @@ -226,6 +270,7 @@ export default { :work-item-id="workItemId" :fetch-by-iid="fetchByIid" :work-item-type="workItemType" + @deleteNote="showDeleteNoteModal($event, discussion)" /> </template> </template> @@ -251,5 +296,17 @@ export default { </gl-skeleton-loader> </template> </div> + <gl-modal + ref="deleteNoteModal" + modal-id="delete-note-modal" + :title="__('Delete comment?')" + :ok-title="__('Delete comment')" + ok-variant="danger" + size="sm" + @primary="deleteNote" + @canceled="cancelDeletingNote" + > + {{ __('Are you sure you want to delete this comment?') }} + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql index 0d7e1aeb089..446ab8e221a 100644 --- a/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql @@ -1,4 +1,4 @@ -#import "~/work_items/graphql/work_item_note.fragment.graphql" +#import "./work_item_note.fragment.graphql" mutation createWorkItemNote($input: CreateNoteInput!) { createNote(input: $input) { diff --git a/app/assets/javascripts/work_items/graphql/notes/delete_work_item_notes.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/delete_work_item_notes.mutation.graphql new file mode 100644 index 00000000000..592b5c2a991 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/delete_work_item_notes.mutation.graphql @@ -0,0 +1,7 @@ +mutation deleteWorkItemNote($input: DestroyNoteInput!) { + destroyNote(input: $input) { + note { + id + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_discussion_note.fragment.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql index e5c45ff9751..58561e33e53 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_discussion_note.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" -#import "~/work_items/graphql/work_item_note.fragment.graphql" +#import "./work_item_note.fragment.graphql" fragment WorkItemDiscussionNote on Note { id diff --git a/app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql index d1ab4642869..d1ab4642869 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql diff --git a/app/assets/javascripts/work_items/graphql/work_item_note_created.subscription.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_created.subscription.graphql index 5591f11823c..739f2101b5e 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_note_created.subscription.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_created.subscription.graphql @@ -1,4 +1,4 @@ -#import "~/work_items/graphql/work_item_discussion_note.fragment.graphql" +#import "./work_item_discussion_note.fragment.graphql" subscription workItemNoteCreated($noteableId: NoteableID) { workItemNoteCreated(noteableId: $noteableId) { diff --git a/app/assets/javascripts/work_items/graphql/work_item_note_deleted.subscription.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_deleted.subscription.graphql index 6a59becdb99..6a59becdb99 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_note_deleted.subscription.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_deleted.subscription.graphql diff --git a/app/assets/javascripts/work_items/graphql/work_item_note_updated.subscription.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql index 4a04b311dde..c68d5f491cf 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_note_updated.subscription.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql @@ -1,4 +1,4 @@ -#import "~/work_items/graphql/work_item_note.fragment.graphql" +#import "./work_item_note.fragment.graphql" subscription workItemNoteUpdated($noteableId: NoteableID) { workItemNoteUpdated(noteableId: $noteableId) { diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_notes.query.graphql index 9ea9cecc81a..56dc175109f 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_notes.query.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/page_info.fragment.graphql" -#import "~/work_items/graphql/work_item_note.fragment.graphql" +#import "./work_item_note.fragment.graphql" query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) { workItem(id: $id) { diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql index f401aa5595e..6b37c68cb43 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/page_info.fragment.graphql" -#import "~/work_items/graphql/work_item_note.fragment.graphql" +#import "./work_item_note.fragment.graphql" query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) { workspace: project(fullPath: $fullPath) { diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js index 3274dab37f3..f2af87d476c 100644 --- a/app/assets/javascripts/work_items/utils.js +++ b/app/assets/javascripts/work_items/utils.js @@ -1,8 +1,8 @@ import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants'; import workItemQuery from './graphql/work_item.query.graphql'; import workItemByIidQuery from './graphql/work_item_by_iid.query.graphql'; -import workItemNotesIdQuery from './graphql/work_item_notes.query.graphql'; -import workItemNotesByIidQuery from './graphql/work_item_notes_by_iid.query.graphql'; +import workItemNotesIdQuery from './graphql/notes/work_item_notes.query.graphql'; +import workItemNotesByIidQuery from './graphql/notes/work_item_notes_by_iid.query.graphql'; export function getWorkItemQuery(isFetchedByIid) { return isFetchedByIid ? workItemByIidQuery : workItemQuery; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 567f24be336..c6fa1d50997 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -637,6 +637,11 @@ $system-note-svg-size: 1rem; &.new { border-right-width: 0; } + + .note-header { + flex-wrap: wrap; + align-items: center; + } } .notes { diff --git a/app/controllers/profiles/saved_replies_controller.rb b/app/controllers/profiles/saved_replies_controller.rb new file mode 100644 index 00000000000..5ac5d645efb --- /dev/null +++ b/app/controllers/profiles/saved_replies_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Profiles + class SavedRepliesController < Profiles::ApplicationController + feature_category :user_profile + + before_action do + render_404 unless Feature.enabled?(:saved_replies, current_user) + + @hide_search_settings = true + end + end +end diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 9d3506d49b0..054e8c302c9 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -12,7 +12,8 @@ class Projects::NotesController < Projects::ApplicationController before_action :authorize_resolve_note!, only: [:resolve, :unresolve] feature_category :team_planning - urgency :low + urgency :medium, [:index] + urgency :low, [:create, :update, :destroy, :resolve, :unresolve, :toggle_award_emoji, :outdated_line_change] def delete_attachment note.remove_attachment! diff --git a/app/graphql/mutations/ci/job_token_scope/remove_project.rb b/app/graphql/mutations/ci/job_token_scope/remove_project.rb index e5f40bbf87e..20e991f5388 100644 --- a/app/graphql/mutations/ci/job_token_scope/remove_project.rb +++ b/app/graphql/mutations/ci/job_token_scope/remove_project.rb @@ -24,9 +24,9 @@ module Mutations description: 'Direction of access, which defaults to outbound.' field :ci_job_token_scope, - Types::Ci::JobTokenScopeType, - null: true, - description: "CI job token's scope of access." + Types::Ci::JobTokenScopeType, + null: true, + description: "CI job token's scope of access." def resolve(project_path:, target_project_path:, direction: :outbound) project = authorized_find!(project_path) @@ -34,7 +34,7 @@ module Mutations result = ::Ci::JobTokenScope::RemoveProjectService .new(project, current_user) - .execute(target_project, direction: direction) + .execute(target_project, direction) if result.success? { diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 6fa3fc4f04a..62b9eb2b506 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -300,6 +300,10 @@ module UsersHelper other: s_('User|Other') }.with_indifferent_access.freeze end + + def saved_replies_enabled? + Feature.enabled?(:saved_replies, current_user) + end end UsersHelper.prepend_mod_with('UsersHelper') diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb index 05addcf83d2..8297221d5be 100644 --- a/app/models/concerns/taskable.rb +++ b/app/models/concerns/taskable.rb @@ -24,10 +24,28 @@ module Taskable (\s.+) # followed by whitespace and some text. }x.freeze + # ignore tasks in code or html comment blocks. HTML blocks + # are ok as we allow tasks inside <detail> blocks + REGEX = %r{ + #{::Gitlab::Regex.markdown_code_or_html_comment_blocks} + | + (?<task_item> + #{ITEM_PATTERN} + ) + }mx.freeze + def self.get_tasks(content) - content.to_s.scan(ITEM_PATTERN).map do |prefix, checkbox, label| - TaskList::Item.new("#{prefix} #{checkbox}", label.strip) + items = [] + + content.to_s.scan(REGEX) do + next unless $~[:task_item] + + $~[:task_item].scan(ITEM_PATTERN) do |prefix, checkbox, label| + items << TaskList::Item.new("#{prefix.strip} #{checkbox}", label.strip) + end end + + items end def self.get_updated_tasks(old_content:, new_content:) @@ -67,10 +85,10 @@ module Taskable checklist_item_noun = n_('checklist item', 'checklist items', sum.item_count) if short format(s_('Tasks|%{complete_count}/%{total_count} %{checklist_item_noun}'), -checklist_item_noun: checklist_item_noun, complete_count: sum.complete_count, total_count: sum.item_count) + checklist_item_noun: checklist_item_noun, complete_count: sum.complete_count, total_count: sum.item_count) else format(s_('Tasks|%{complete_count} of %{total_count} %{checklist_item_noun} completed'), -checklist_item_noun: checklist_item_noun, complete_count: sum.complete_count, total_count: sum.item_count) + checklist_item_noun: checklist_item_noun, complete_count: sum.complete_count, total_count: sum.item_count) end end diff --git a/app/models/namespace/detail.rb b/app/models/namespace/detail.rb index a5643ab9f79..2660d11171e 100644 --- a/app/models/namespace/detail.rb +++ b/app/models/namespace/detail.rb @@ -11,3 +11,5 @@ class Namespace::Detail < ApplicationRecord self.primary_key = :namespace_id end + +Namespace::Detail.prepend_mod diff --git a/app/presenters/projects/import_export/project_export_presenter.rb b/app/presenters/projects/import_export/project_export_presenter.rb index 53c547cde9e..76cc8242da8 100644 --- a/app/presenters/projects/import_export/project_export_presenter.rb +++ b/app/presenters/projects/import_export/project_export_presenter.rb @@ -17,7 +17,7 @@ module Projects delegator_override :project_members def project_members - super + converted_group_members + super.preload(:user) + converted_group_members # rubocop:disable CodeReuse/ActiveRecord end delegator_override :description @@ -46,7 +46,7 @@ module Projects # invitee, it would make the following query return 0 rows since a NULL # user_id would be present in the subquery non_null_user_ids = project.project_members.connected_to_user.select(:user_id) - GroupMembersFinder.new(project.group).execute.where.not(user_id: non_null_user_ids) + GroupMembersFinder.new(project.group).execute.where.not(user_id: non_null_user_ids).preload(:user) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/serializers/codequality_degradation_entity.rb b/app/serializers/codequality_degradation_entity.rb index 52945a753dc..15a26739c51 100644 --- a/app/serializers/codequality_degradation_entity.rb +++ b/app/serializers/codequality_degradation_entity.rb @@ -15,4 +15,6 @@ class CodequalityDegradationEntity < Grape::Entity end expose :web_url + + expose :engine_name end diff --git a/app/services/ci/job_token_scope/remove_project_service.rb b/app/services/ci/job_token_scope/remove_project_service.rb index d21eff2b619..864f9318c68 100644 --- a/app/services/ci/job_token_scope/remove_project_service.rb +++ b/app/services/ci/job_token_scope/remove_project_service.rb @@ -5,7 +5,7 @@ module Ci class RemoveProjectService < ::BaseService include EditScopeValidations - def execute(target_project, direction: :outbound) + def execute(target_project, direction) validate_edit!(project, target_project, current_user) if project == target_project diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml index e1978009114..087eca3ba35 100644 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ b/app/views/layouts/nav/sidebar/_profile.html.haml @@ -130,6 +130,18 @@ = link_to profile_preferences_path do %strong.fly-out-top-item-name = _('Preferences') + - if saved_replies_enabled? + = nav_link(controller: :saved_replies) do + = link_to profile_saved_replies_path do + .nav-icon-container + = sprite_icon('symlink') + %span.nav-item-name + = _('Saved Replies') + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(controller: :saved_replies, html_options: { class: "fly-out-top-item" }) do + = link_to profile_saved_replies_path do + %strong.fly-out-top-item-name + = _('Saved Replies') = nav_link(controller: :active_sessions) do = link_to profile_active_sessions_path do .nav-icon-container diff --git a/app/views/profiles/saved_replies/index.html.haml b/app/views/profiles/saved_replies/index.html.haml new file mode 100644 index 00000000000..2ae7a092249 --- /dev/null +++ b/app/views/profiles/saved_replies/index.html.haml @@ -0,0 +1,10 @@ +- page_title _('Saved Replies') + +#js-saved-replies-root.row.gl-mt-5{ data: { base_path: profile_saved_replies_path } } + .col-lg-4 + %h4.gl-mt-0 + = page_title + %p + = _('Saved replies can be used when creating comments inside issues, merge requests, and epics.') + .col-lg-8 + = gl_loading_icon(size: 'lg') diff --git a/config/feature_flags/development/free_user_cap_over_user_limit_mails.yml b/config/feature_flags/development/free_user_cap_over_user_limit_mails.yml new file mode 100644 index 00000000000..88b3f59050d --- /dev/null +++ b/config/feature_flags/development/free_user_cap_over_user_limit_mails.yml @@ -0,0 +1,8 @@ +--- +name: free_user_cap_over_user_limit_mails +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/98438 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/378616 +milestone: '15.9' +type: development +group: group::acquisition +default_enabled: false diff --git a/config/feature_flags/development/limited_capacity_seat_refresh_worker_high.yml b/config/feature_flags/development/limited_capacity_seat_refresh_worker_high.yml index def6101ee2f..28f1c8a988e 100644 --- a/config/feature_flags/development/limited_capacity_seat_refresh_worker_high.yml +++ b/config/feature_flags/development/limited_capacity_seat_refresh_worker_high.yml @@ -1,7 +1,7 @@ --- name: limited_capacity_seat_refresh_worker_high -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/104099/ -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/382725" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/104099 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/382725 milestone: '15.9' type: development group: group::utilization diff --git a/config/feature_flags/development/limited_capacity_seat_refresh_worker_low.yml b/config/feature_flags/development/limited_capacity_seat_refresh_worker_low.yml index ac89bf2cd87..a0b306ac792 100644 --- a/config/feature_flags/development/limited_capacity_seat_refresh_worker_low.yml +++ b/config/feature_flags/development/limited_capacity_seat_refresh_worker_low.yml @@ -1,7 +1,7 @@ --- name: limited_capacity_seat_refresh_worker_low -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/104099/ -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/382725" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/104099 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/382725 milestone: '15.9' type: development group: group::utilization diff --git a/config/feature_flags/development/limited_capacity_seat_refresh_worker_medium.yml b/config/feature_flags/development/limited_capacity_seat_refresh_worker_medium.yml index 1169d3fdc2d..1df482e0624 100644 --- a/config/feature_flags/development/limited_capacity_seat_refresh_worker_medium.yml +++ b/config/feature_flags/development/limited_capacity_seat_refresh_worker_medium.yml @@ -1,7 +1,7 @@ --- name: limited_capacity_seat_refresh_worker_medium -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/104099/ -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/382725" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/104099 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/382725 milestone: '15.9' type: development group: group::utilization diff --git a/config/feature_flags/development/use_primary_and_secondary_stores_for_repository_cache.yml b/config/feature_flags/development/use_primary_and_secondary_stores_for_repository_cache.yml deleted file mode 100644 index 07fbc77c960..00000000000 --- a/config/feature_flags/development/use_primary_and_secondary_stores_for_repository_cache.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: use_primary_and_secondary_stores_for_repository_cache -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/107232#note_1216317991 -rollout_issue_url: -milestone: '15.7' -type: development -group: group::scalability -default_enabled: false diff --git a/config/feature_flags/development/use_primary_store_as_default_for_repository_cache.yml b/config/feature_flags/development/use_primary_store_as_default_for_repository_cache.yml deleted file mode 100644 index adf48021597..00000000000 --- a/config/feature_flags/development/use_primary_store_as_default_for_repository_cache.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: use_primary_store_as_default_for_repository_cache -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/107232#note_1216317991 -rollout_issue_url: -milestone: '15.7' -type: development -group: group::scalability -default_enabled: false diff --git a/config/routes/profile.rb b/config/routes/profile.rb index 46a078ce3b1..bee1a0f108e 100644 --- a/config/routes/profile.rb +++ b/config/routes/profile.rb @@ -39,6 +39,8 @@ resource :profile, only: [:show, :update] do end resource :preferences, only: [:show, :update] + resources :saved_replies, only: [:index], action: :index + resources :keys, only: [:index, :show, :create, :destroy] do member do delete :revoke diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 2c8169e423e..cad453fe1de 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -337,6 +337,8 @@ - 1 - - migrate_external_diffs - 1 +- - namespaces_free_user_cap_over_limit_notification + - 1 - - namespaces_process_sync_events - 1 - - namespaces_sync_namespace_name diff --git a/db/migrate/20221219112632_add_next_over_limit_check_at_to_namespace_details.rb b/db/migrate/20221219112632_add_next_over_limit_check_at_to_namespace_details.rb new file mode 100644 index 00000000000..dd2acbfd0bb --- /dev/null +++ b/db/migrate/20221219112632_add_next_over_limit_check_at_to_namespace_details.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddNextOverLimitCheckAtToNamespaceDetails < Gitlab::Database::Migration[2.1] + disable_ddl_transaction! + + TABLE_NAME = :namespace_details + COLUMN = :next_over_limit_check_at + + def up + with_lock_retries do + add_column TABLE_NAME, COLUMN, :datetime_with_timezone, null: true + end + end + + def down + with_lock_retries do + remove_column TABLE_NAME, COLUMN + end + end +end diff --git a/db/schema_migrations/20221219112632 b/db/schema_migrations/20221219112632 new file mode 100644 index 00000000000..0bba0080af7 --- /dev/null +++ b/db/schema_migrations/20221219112632 @@ -0,0 +1 @@ +400cab0a2d3130dd7406024cf982c7312918019197ae06af06696435f6bb5aaa
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 005df75da1f..067e8646819 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -18310,7 +18310,8 @@ CREATE TABLE namespace_details ( free_user_cap_over_limt_notified_at timestamp with time zone, free_user_cap_over_limit_notified_at timestamp with time zone, dashboard_notification_at timestamp with time zone, - dashboard_enforcement_at timestamp with time zone + dashboard_enforcement_at timestamp with time zone, + next_over_limit_check_at timestamp with time zone ); CREATE TABLE namespace_limits ( diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 9bf303234f4..6a0f731f6ad 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -11666,6 +11666,7 @@ Represents a code quality degradation on the pipeline. | Name | Type | Description | | ---- | ---- | ----------- | | <a id="codequalitydegradationdescription"></a>`description` | [`String!`](#string) | Description of the code quality degradation. | +| <a id="codequalitydegradationenginename"></a>`engineName` | [`String!`](#string) | Code Quality plugin that reported the finding. | | <a id="codequalitydegradationfingerprint"></a>`fingerprint` | [`String!`](#string) | Unique fingerprint to identify the code quality degradation. For example, an MD5 hash. | | <a id="codequalitydegradationline"></a>`line` | [`Int!`](#int) | Line on which the code quality degradation occurred. | | <a id="codequalitydegradationpath"></a>`path` | [`String!`](#string) | Relative path to the file containing the code quality degradation. | diff --git a/doc/api/group_epic_boards.md b/doc/api/group_epic_boards.md index bb3d2a8acf4..e85147a2868 100644 --- a/doc/api/group_epic_boards.md +++ b/doc/api/group_epic_boards.md @@ -178,6 +178,8 @@ Example response: ## List group epic board lists +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/385904) in GitLab 15.9. + Gets a list of the epic board's lists. Does not include `open` and `closed` lists. @@ -236,6 +238,8 @@ Example response: ## Single group epic board list +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/385904) in GitLab 15.9. + Gets a single board list. ```plaintext diff --git a/doc/development/contributing/community_roles.md b/doc/development/contributing/community_roles.md index 8aa219d72a1..3c9362138c2 100644 --- a/doc/development/contributing/community_roles.md +++ b/doc/development/contributing/community_roles.md @@ -1,18 +1,11 @@ --- -stage: none -group: Development -info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments +redirect_to: 'index.md' +remove_date: '2023-05-08' --- -# Community members & roles +This document was moved to [another location](index.md). -GitLab community members and their privileges/responsibilities. - -| Roles | Responsibilities | Requirements | -|-------|------------------|--------------| -| Maintainer | Accepts merge requests on several GitLab projects | Added to the [team page](https://about.gitlab.com/company/team/). An expert on code reviews and knows the product/codebase | -| Reviewer | Performs code reviews on MRs | Added to the [team page](https://about.gitlab.com/company/team/) | -| Developer | Has access to GitLab internal infrastructure & issues (for example, HR-related) | GitLab employee or a Core Team member (with an NDA) | -| Contributor | Can make contributions to all GitLab public projects | Have a GitLab.com account | - -[List of current reviewers/maintainers](https://about.gitlab.com/handbook/engineering/projects/#gitlab). +<!-- This redirect file can be deleted after <2023-05-08>. --> +<!-- Redirects that point to other docs in the same project expire in three months. --> +<!-- Redirects that point to docs in a different project or site (for example, link is not relative and starts with `https:`) expire in one year. --> +<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/redirects.html --> diff --git a/doc/development/contributing/index.md b/doc/development/contributing/index.md index 0a5160b6569..5feabf2bd18 100644 --- a/doc/development/contributing/index.md +++ b/doc/development/contributing/index.md @@ -23,9 +23,6 @@ GitLab comes in two flavors: Throughout this guide you will see references to CE and EE for abbreviation. -To get an overview of GitLab community membership, including those that would review or merge -your contributions, visit [the community roles page](community_roles.md). - ## Code of conduct We want to create a welcoming environment for everyone who is interested in contributing. diff --git a/doc/integration/advanced_search/elasticsearch.md b/doc/integration/advanced_search/elasticsearch.md index 09c7873922d..f1c2ad8ea6b 100644 --- a/doc/integration/advanced_search/elasticsearch.md +++ b/doc/integration/advanced_search/elasticsearch.md @@ -588,7 +588,7 @@ The following are some available Rake tasks: | Task | Description | |:--------------------------------------------------------------------------------------------------------------------------------------------------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | [`sudo gitlab-rake gitlab:elastic:info`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Outputs debugging information for the Advanced Search integration. | -| [`sudo gitlab-rake gitlab:elastic:index`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Enables Elasticsearch indexing and run `gitlab:elastic:create_empty_index`, `gitlab:elastic:clear_index_status`, `gitlab:elastic:index_projects`, and `gitlab:elastic:index_snippets`. | +| [`sudo gitlab-rake gitlab:elastic:index`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Enables Elasticsearch indexing and run `gitlab:elastic:create_empty_index`, `gitlab:elastic:clear_index_status`, `gitlab:elastic:index_projects`, `gitlab:elastic:index_snippets`, and `gitlab:elastic:index_users`. | | [`sudo gitlab-rake gitlab:elastic:pause_indexing`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Pauses Elasticsearch indexing. Changes are still tracked. Useful for cluster/index migrations. | | [`sudo gitlab-rake gitlab:elastic:resume_indexing`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Resumes Elasticsearch indexing. | | [`sudo gitlab-rake gitlab:elastic:index_projects`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Iterates over all projects, and queues Sidekiq jobs to index them in the background. It can only be used after the index is created. | @@ -598,6 +598,7 @@ The following are some available Rake tasks: | [`sudo gitlab-rake gitlab:elastic:delete_index`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Removes the GitLab indices and aliases (if they exist) on the Elasticsearch instance. | | [`sudo gitlab-rake gitlab:elastic:recreate_index`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Wrapper task for `gitlab:elastic:delete_index` and `gitlab:elastic:create_empty_index`. | | [`sudo gitlab-rake gitlab:elastic:index_snippets`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Performs an Elasticsearch import that indexes the snippets data. | +| [`sudo gitlab-rake gitlab:elastic:index_users`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Imports all users into Elasticsearch. | | [`sudo gitlab-rake gitlab:elastic:projects_not_indexed`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Displays which projects are not indexed. | | [`sudo gitlab-rake gitlab:elastic:reindex_cluster`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Schedules a zero-downtime cluster reindexing task. This feature should be used with an index that was created after GitLab 13.0. | | [`sudo gitlab-rake gitlab:elastic:mark_reindex_failed`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Mark the most recent re-index job as failed. | @@ -647,6 +648,7 @@ When performing a search, the GitLab index uses the following scopes: | `notes` | Note data | | `snippets` | Snippet data | | `wiki_blobs` | Wiki contents | +| `users` | Users | ## Tuning diff --git a/doc/integration/jira/development_panel.md b/doc/integration/jira/development_panel.md index f72b714550c..f36b9f2c4c1 100644 --- a/doc/integration/jira/development_panel.md +++ b/doc/integration/jira/development_panel.md @@ -69,8 +69,8 @@ To simplify administration, we recommend that a GitLab group maintainer or group | Jira usage | GitLab.com customers need | GitLab self-managed customers need | |------------|---------------------------|------------------------------------| -| [Atlassian cloud](https://www.atlassian.com/migration/assess/why-cloud) | The [GitLab for Jira Cloud app](https://marketplace.atlassian.com/apps/1221011/gitlab-com-for-jira-cloud?hosting=cloud&tab=overview) from the [Atlassian Marketplace](https://marketplace.atlassian.com). This method offers real-time sync between GitLab.com and Jira. For more information, see [GitLab for Jira Cloud app](connect-app.md). | The GitLab for Jira Cloud app [installed manually](connect-app.md#install-the-gitlab-for-jira-cloud-app-manually). By default, you can install the app from the [Atlassian Marketplace](https://marketplace.atlassian.com/). For more information, see [Connect the GitLab for Jira Cloud app for self-managed instances](connect-app.md#connect-the-gitlab-for-jira-cloud-app-for-self-managed-instances). | -| Your own server | The [Jira DVCS (distributed version control system) connector](dvcs/index.md). This syncs data hourly. | The [Jira DVCS (distributed version control system) connector](dvcs/index.md). This syncs data hourly. | +| [Atlassian cloud](https://www.atlassian.com/migration/assess/why-cloud) | The [GitLab for Jira Cloud app](https://marketplace.atlassian.com/apps/1221011/gitlab-com-for-jira-cloud?hosting=cloud&tab=overview) from the [Atlassian Marketplace](https://marketplace.atlassian.com). This method offers real-time sync between GitLab.com and Jira. The method requires inbound connections for the setup and then pushes data to Jira through outbound connections. For more information, see [GitLab for Jira Cloud app](connect-app.md). | The GitLab for Jira Cloud app [installed manually](connect-app.md#install-the-gitlab-for-jira-cloud-app-manually). By default, you can install the app from the [Atlassian Marketplace](https://marketplace.atlassian.com/). The method requires inbound connections for the setup and then pushes data to Jira through outbound connections. For more information, see [Connect the GitLab for Jira Cloud app for self-managed instances](connect-app.md#connect-the-gitlab-for-jira-cloud-app-for-self-managed-instances). | +| Your own server | The [Jira DVCS connector](dvcs/index.md). This method syncs data every hour and works only with inbound connections. The method tries to set up webhooks in GitLab to implement real-time data sync, which does not work without outbound connections. | The [Jira DVCS connector](dvcs/index.md). This method syncs data every hour and works only with inbound connections. The method tries to set up webhooks in GitLab to implement real-time data sync, which does not work without outbound connections. | Each GitLab project can be configured to connect to an entire Jira instance. That means after configuration, one GitLab project can interact with all Jira projects in that instance. For: diff --git a/lib/api/entities/issue.rb b/lib/api/entities/issue.rb index 7630fd1e94e..56e942a0383 100644 --- a/lib/api/entities/issue.rb +++ b/lib/api/entities/issue.rb @@ -6,11 +6,11 @@ module API include ::API::Helpers::RelatedResourcesHelpers expose(:has_tasks) do |issue, _| - !issue.task_list_items.empty? + !issue.tasks? end expose :task_status, if: -> (issue, _) do - !issue.task_list_items.empty? + !issue.tasks? end expose :_links do diff --git a/lib/feature.rb b/lib/feature.rb index 892d32c9b73..17c26796ea1 100644 --- a/lib/feature.rb +++ b/lib/feature.rb @@ -424,4 +424,5 @@ module Feature end end +Feature.prepend_mod Feature::ActiveSupportCacheStoreAdapter.prepend_mod_with('Feature::ActiveSupportCacheStoreAdapter') diff --git a/lib/gitlab/database/schema_validation/database.rb b/lib/gitlab/database/schema_validation/database.rb new file mode 100644 index 00000000000..dfc845f0b44 --- /dev/null +++ b/lib/gitlab/database/schema_validation/database.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Gitlab + module Database + module SchemaValidation + class Database + def initialize(connection) + @connection = connection + end + + def fetch_index_by_name(index_name) + index_map[index_name] + end + + def indexes + index_map.values + end + + private + + def index_map + @index_map ||= + fetch_indexes.transform_values! do |index_stmt| + Index.new(PgQuery.parse(index_stmt).tree.stmts.first.stmt.index_stmt) + end + end + + attr_reader :connection + + def fetch_indexes + sql = <<~SQL + SELECT indexname, indexdef + FROM pg_indexes + WHERE indexname NOT LIKE '%_pkey' AND schemaname IN ('public', 'gitlab_partitions_static'); + SQL + + @fetch_indexes ||= connection.exec_query(sql).rows.to_h + end + end + end + end +end diff --git a/lib/gitlab/database/schema_validation/index.rb b/lib/gitlab/database/schema_validation/index.rb new file mode 100644 index 00000000000..af0d5f31f4e --- /dev/null +++ b/lib/gitlab/database/schema_validation/index.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module Database + module SchemaValidation + class Index + def initialize(parsed_stmt) + @parsed_stmt = parsed_stmt + end + + def name + parsed_stmt.idxname + end + + def statement + @statement ||= PgQuery.deparse_stmt(parsed_stmt) + end + + private + + attr_reader :parsed_stmt + end + end + end +end diff --git a/lib/gitlab/database/schema_validation/indexes.rb b/lib/gitlab/database/schema_validation/indexes.rb index 82a9b39790d..b7c3705bde9 100644 --- a/lib/gitlab/database/schema_validation/indexes.rb +++ b/lib/gitlab/database/schema_validation/indexes.rb @@ -4,68 +4,33 @@ module Gitlab module Database module SchemaValidation class Indexes - def initialize(structure_file_path, database_name) - @parsed_structure_file = PgQuery.parse(File.read(structure_file_path)) - @database_name = database_name + def initialize(structure_sql, database) + @structure_sql = structure_sql + @database = database end def missing_indexes - structure_file_indexes.keys - database_indexes.keys + structure_sql.indexes.map(&:name) - database.indexes.map(&:name) end def extra_indexes - database_indexes.keys - structure_file_indexes.keys + database.indexes.map(&:name) - structure_sql.indexes.map(&:name) end def wrong_indexes - structure_file_indexes.filter_map do |index_name, index_stmt| - database_index = database_indexes[index_name] + structure_sql.indexes.filter_map do |structure_sql_index| + database_index = database.fetch_index_by_name(structure_sql_index.name) next if database_index.nil? + next if database_index.statement == structure_sql_index.statement - begin - database_index = PgQuery.deparse_stmt(PgQuery.parse(database_index).tree.stmts.first.stmt.index_stmt) - - index_stmt.relation.schemaname = "public" if index_stmt.relation.schemaname == '' - - structure_sql_index = PgQuery.deparse_stmt(index_stmt) - - index_name unless database_index == structure_sql_index - rescue PgQuery::ParseError - index_name - end + structure_sql_index.name end end private - attr_reader :parsed_structure_file, :database_name - - def structure_file_indexes - @structure_file_indexes ||= index_parsed_structure_file.each_with_object({}) do |tree, dic| - index_stmt = tree.stmt.index_stmt - - dic[index_stmt.idxname] = index_stmt - end - end - - def index_parsed_structure_file - @index_parsed_structure_file ||= parsed_structure_file.tree.stmts.reject { |s| s.stmt.index_stmt.nil? } - end - - def database_indexes - sql = <<~SQL - SELECT indexname, indexdef - FROM pg_indexes - WHERE indexname NOT LIKE '%_pkey' AND schemaname IN ('public', 'gitlab_partitions_static'); - SQL - - @database_indexes ||= connection.exec_query(sql).rows.to_h - end - - def connection - @connection ||= Gitlab::Database.database_base_models[database_name].connection - end + attr_reader :structure_sql, :database end end end diff --git a/lib/gitlab/database/schema_validation/structure_sql.rb b/lib/gitlab/database/schema_validation/structure_sql.rb new file mode 100644 index 00000000000..32c69a0e5e7 --- /dev/null +++ b/lib/gitlab/database/schema_validation/structure_sql.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Gitlab + module Database + module SchemaValidation + class StructureSql + def initialize(structure_file_path) + @structure_file_path = structure_file_path + end + + def indexes + @indexes ||= index_statements.map do |index_statement| + index_statement.relation.schemaname = "public" if index_statement.relation.schemaname == '' + + Index.new(index_statement) + end + end + + private + + attr_reader :structure_file_path + + def index_statements + parsed_structure_file.tree.stmts.filter_map { |s| s.stmt.index_stmt } + end + + def parsed_structure_file + PgQuery.parse(File.read(structure_file_path)) + end + end + end + end +end diff --git a/lib/gitlab/import_export/group/import_export.yml b/lib/gitlab/import_export/group/import_export.yml index 7f3254be3e8..e30414265be 100644 --- a/lib/gitlab/import_export/group/import_export.yml +++ b/lib/gitlab/import_export/group/import_export.yml @@ -98,6 +98,9 @@ methods: epics: - :state +# Add in this list the nested associations that are used to export the parent +# association, but are not present in the tree list. In other words, the associations +# that needs to be preloaded but do not need to be exported. preloads: export_reorders: diff --git a/lib/gitlab/import_export/project/import_export.yml b/lib/gitlab/import_export/project/import_export.yml index 7c24bf95695..d97ffee8698 100644 --- a/lib/gitlab/import_export/project/import_export.yml +++ b/lib/gitlab/import_export/project/import_export.yml @@ -1105,6 +1105,9 @@ methods: issues: - :state +# Add in this list the nested associations that are used to export the parent +# association, but are not present in the tree list. In other words, the associations +# that needs to be preloaded but do not need to be exported. preloads: issues: project: :route @@ -1113,8 +1116,8 @@ preloads: # tags: # needed by tag_list project: # deprecated: needed by coverage_regex of Ci::Build merge_requests: - source_project: # needed by source_branch_sha and diff_head_sha - target_project: # needed by target_branch_sha + source_project: :route # needed by source_branch_sha and diff_head_sha + target_project: :route # needed by target_branch_sha assignees: # needed by assigne_id that is implemented by DeprecatedAssignee # Specify a custom export reordering for a given relationship diff --git a/lib/gitlab/redis/cache.rb b/lib/gitlab/redis/cache.rb index 043f14630d5..ba3af3e7a6f 100644 --- a/lib/gitlab/redis/cache.rb +++ b/lib/gitlab/redis/cache.rb @@ -12,9 +12,13 @@ module Gitlab redis: pool, compress: Gitlab::Utils.to_boolean(ENV.fetch('ENABLE_REDIS_CACHE_COMPRESSION', '1')), namespace: CACHE_NAMESPACE, - expires_in: ENV.fetch('GITLAB_RAILS_CACHE_DEFAULT_TTL_SECONDS', 8.hours).to_i # Cache should not grow forever + expires_in: default_ttl_seconds } end + + def self.default_ttl_seconds + ENV.fetch('GITLAB_RAILS_CACHE_DEFAULT_TTL_SECONDS', 8.hours).to_i + end end end end diff --git a/lib/gitlab/redis/repository_cache.rb b/lib/gitlab/redis/repository_cache.rb index 8bfbfcfea60..d8d434ae062 100644 --- a/lib/gitlab/redis/repository_cache.rb +++ b/lib/gitlab/redis/repository_cache.rb @@ -14,19 +14,9 @@ module Gitlab redis: pool, compress: Gitlab::Utils.to_boolean(ENV.fetch('ENABLE_REDIS_CACHE_COMPRESSION', '1')), namespace: Cache::CACHE_NAMESPACE, - # Cache should not grow forever - expires_in: ENV.fetch('GITLAB_RAILS_CACHE_DEFAULT_TTL_SECONDS', 8.hours).to_i + expires_in: Cache.default_ttl_seconds ) end - - private - - def redis - primary_store = ::Redis.new(params) - secondary_store = ::Redis.new(config_fallback.params) - - MultiStore.new(primary_store, secondary_store, store_name) - end end end end diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index 717bd22d81d..93f663d7030 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -435,30 +435,59 @@ module Gitlab }x.freeze end + MARKDOWN_CODE_BLOCK_REGEX = %r{ + (?<code> + # Code blocks: + # ``` + # Anything, including `>>>` blocks which are ignored by this filter + # ``` + + ^``` + .+? + \n```\ *$ + ) + }mx.freeze + + MARKDOWN_HTML_BLOCK_REGEX = %r{ + (?<html> + # HTML block: + # <tag> + # Anything, including `>>>` blocks which are ignored by this filter + # </tag> + + ^<[^>]+?>\ *\n + .+? + \n<\/[^>]+?>\ *$ + ) + }mx.freeze + + MARKDOWN_HTML_COMMENT_BLOCK_REGEX = %r{ + (?<html_block_comment> + # HTML block comment: + # <!-- some comment text + # more comment + # and more comment --> + + ^<!--.*?\ *\n + .+? + \n.*?-->\ *$ + ) + }mx.freeze + def markdown_code_or_html_blocks @markdown_code_or_html_blocks ||= %r{ - (?<code> - # Code blocks: - # ``` - # Anything, including `>>>` blocks which are ignored by this filter - # ``` - - ^``` - .+? - \n```\ *$ - ) + #{MARKDOWN_CODE_BLOCK_REGEX} | - (?<html> - # HTML block: - # <tag> - # Anything, including `>>>` blocks which are ignored by this filter - # </tag> - - ^<[^>]+?>\ *\n - .+? - \n<\/[^>]+?>\ *$ - ) - }mx + #{MARKDOWN_HTML_BLOCK_REGEX} + }mx.freeze + end + + def markdown_code_or_html_comment_blocks + @markdown_code_or_html_comment_blocks ||= %r{ + #{MARKDOWN_CODE_BLOCK_REGEX} + | + #{MARKDOWN_HTML_COMMENT_BLOCK_REGEX} + }mx.freeze end # Based on Jira's project key format diff --git a/lib/gitlab/repository_cache.rb b/lib/gitlab/repository_cache.rb index 8de2c2fe772..498eaf92381 100644 --- a/lib/gitlab/repository_cache.rb +++ b/lib/gitlab/repository_cache.rb @@ -50,12 +50,7 @@ module Gitlab end def self.store - if Feature.enabled?(:use_primary_and_secondary_stores_for_repository_cache) || - Feature.enabled?(:use_primary_store_as_default_for_repository_cache) - Gitlab::Redis::RepositoryCache.cache_store - else - Rails.cache - end + Gitlab::Redis::RepositoryCache.cache_store end end end diff --git a/lib/gitlab/repository_hash_cache.rb b/lib/gitlab/repository_hash_cache.rb index ea90a341b1e..1f3c084e194 100644 --- a/lib/gitlab/repository_hash_cache.rb +++ b/lib/gitlab/repository_hash_cache.rb @@ -140,12 +140,7 @@ module Gitlab private def cache - if Feature.enabled?(:use_primary_and_secondary_stores_for_repository_cache) || - Feature.enabled?(:use_primary_store_as_default_for_repository_cache) - Gitlab::Redis::RepositoryCache - else - Gitlab::Redis::Cache - end + Gitlab::Redis::RepositoryCache end def with(&blk) diff --git a/lib/gitlab/repository_set_cache.rb b/lib/gitlab/repository_set_cache.rb index c67ca92af40..838f44c0f9e 100644 --- a/lib/gitlab/repository_set_cache.rb +++ b/lib/gitlab/repository_set_cache.rb @@ -68,12 +68,7 @@ module Gitlab private def cache - if Feature.enabled?(:use_primary_and_secondary_stores_for_repository_cache) || - Feature.enabled?(:use_primary_store_as_default_for_repository_cache) - Gitlab::Redis::RepositoryCache - else - Gitlab::Redis::Cache - end + Gitlab::Redis::RepositoryCache end def with(&blk) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 84c3d7ce4c2..f4f1fd3fb24 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -6525,6 +6525,9 @@ msgstr "" msgid "BillingPlans|Ultimate" msgstr "" +msgid "BillingPlans|Upgrade" +msgstr "" + msgid "BillingPlans|Upgrade to Premium" msgstr "" @@ -13377,6 +13380,9 @@ msgstr "" msgid "Delete comment" msgstr "" +msgid "Delete comment?" +msgstr "" + msgid "Delete corpus" msgstr "" @@ -17970,7 +17976,7 @@ msgstr "" msgid "FreeUserCap|Explore paid plans:" msgstr "" -msgid "FreeUserCap|Looks like you've reached your limit of %{free_user_limit} members for \"%{namespace_name}\". You can't add any more, but you can manage your existing members, for example, by removing inactive members and replacing them with new members." +msgid "FreeUserCap|It looks like you've reached your limit of %{free_user_limit} members for \"%{namespace_name}\", according to the check we ran on %{date_time}. You can't add any more, but you can manage your existing members, for example, by removing inactive members and replacing them with new members." msgstr "" msgid "FreeUserCap|Manage members" @@ -27564,6 +27570,9 @@ msgstr "" msgid "My company or team" msgstr "" +msgid "My saved replies (%{count})" +msgstr "" + msgid "My topic" msgstr "" @@ -37339,6 +37348,12 @@ msgstr "" msgid "Save pipeline schedule" msgstr "" +msgid "Saved Replies" +msgstr "" + +msgid "Saved replies can be used when creating comments inside issues, merge requests, and epics." +msgstr "" + msgid "Saving" msgstr "" @@ -40161,6 +40176,9 @@ msgstr "" msgid "Something went wrong trying to load issue contacts." msgstr "" +msgid "Something went wrong when deleting a comment. Please try again" +msgstr "" + msgid "Something went wrong when reordering designs. Please try again" msgstr "" diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index 0afd2e10ea2..23b0b58158f 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Projects::NotesController do +RSpec.describe Projects::NotesController, type: :controller, feature_category: :team_planning do include ProjectForksHelper let(:user) { create(:user) } diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index 00866ca118f..4e0c098ad81 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -68,14 +68,7 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte check_pipeline_response(returned: 2, all: 6) end - context 'when performing gitaly calls', :request_store do - before do - # To prevent double writes / fallback read due to MultiStore which is failing the `Gitlab::GitalyClient - # .get_request_count` expectation. - stub_feature_flags(use_primary_store_as_default_for_repository_cache: false) - stub_feature_flags(use_primary_and_secondary_stores_for_repository_cache: false) - end - + context 'when performing gitaly calls', :request_store, :use_null_store_as_repository_cache do it 'limits the Gitaly requests' do # Isolate from test preparation (Repository#exists? is also cached in RequestStore) RequestStore.end! diff --git a/spec/features/broadcast_messages_spec.rb b/spec/features/broadcast_messages_spec.rb index 8300cfce539..b2b41d653b6 100644 --- a/spec/features/broadcast_messages_spec.rb +++ b/spec/features/broadcast_messages_spec.rb @@ -23,7 +23,8 @@ RSpec.describe 'Broadcast Messages', feature_category: :onboarding do end shared_examples 'a dismissable Broadcast Messages' do - it 'hides broadcast message after dismiss', :js do + it 'hides broadcast message after dismiss', :js, + quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/390900' do visit root_path find('.js-dismiss-current-broadcast-notification').click diff --git a/spec/features/issues/user_creates_issue_spec.rb b/spec/features/issues/user_creates_issue_spec.rb index df039493cec..c5d0791dc57 100644 --- a/spec/features/issues/user_creates_issue_spec.rb +++ b/spec/features/issues/user_creates_issue_spec.rb @@ -157,15 +157,10 @@ RSpec.describe "User creates issue", feature_category: :team_planning do end end - context 'form filled by URL parameters' do + context 'form filled by URL parameters', :use_null_store_as_repository_cache do let(:project) { create(:project, :public, :repository) } before do - # With multistore feature flags enabled (using an actual Redis store instead of NullStore), - # it somehow writes an invalid content to Redis and the specs would fail. - stub_feature_flags(use_primary_and_secondary_stores_for_repository_cache: false) - stub_feature_flags(use_primary_store_as_default_for_repository_cache: false) - project.repository.create_file( user, '.gitlab/issue_templates/bug.md', diff --git a/spec/features/profiles/keys_spec.rb b/spec/features/profiles/keys_spec.rb index 8d4666dcb50..c2f67f36850 100644 --- a/spec/features/profiles/keys_spec.rb +++ b/spec/features/profiles/keys_spec.rb @@ -122,7 +122,8 @@ RSpec.describe 'Profile > SSH Keys', feature_category: :user_profile do project.add_developer(user) end - it 'revoking the SSH key marks commits as unverified' do + it 'revoking the SSH key marks commits as unverified', + quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/390905' do visit project_commit_path(project, commit) find('a.gpg-status-box', text: 'Verified').click diff --git a/spec/features/profiles/list_users_saved_replies_spec.rb b/spec/features/profiles/list_users_saved_replies_spec.rb new file mode 100644 index 00000000000..4f3678f8051 --- /dev/null +++ b/spec/features/profiles/list_users_saved_replies_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Profile > Notifications > List users saved replies', :js, + feature_category: :user_profile do + let_it_be(:user) { create(:user) } + let_it_be(:saved_reply) { create(:saved_reply, user: user) } + + before do + sign_in(user) + end + + it 'shows the user a list of their saved replies' do + visit profile_saved_replies_path + + expect(page).to have_content('My saved replies (1)') + expect(page).to have_content(saved_reply.name) + expect(page).to have_content(saved_reply.content) + end +end diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb index d35726fe125..95741d6cdf0 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -333,6 +333,41 @@ RSpec.describe 'Task Lists', :js, feature_category: :team_planning do expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 1) expect(page).to have_selector('ul input[checked]', count: 1) + expect(page).to have_content('1 of 1 checklist item completed') + end + end + + describe 'tasks in code blocks' do + let(:code_tasks_markdown) do + <<-EOT.strip_heredoc + ``` + - [ ] a + ``` + + - [ ] b + EOT + end + + let!(:issue) { create(:issue, description: code_tasks_markdown, author: user, project: project) } + + it 'renders' do + visit_issue(project, issue) + wait_for_requests + + expect(page).to have_selector('ul.task-list', count: 1) + expect(page).to have_selector('li.task-list-item', count: 1) + expect(page).to have_selector('ul input[checked]', count: 0) + + find('.task-list-item-checkbox').click + wait_for_requests + + visit_issue(project, issue) + wait_for_requests + + expect(page).to have_selector('ul.task-list', count: 1) + expect(page).to have_selector('li.task-list-item', count: 1) + expect(page).to have_selector('ul input[checked]', count: 1) + expect(page).to have_content('1 of 1 checklist item completed') end end @@ -370,6 +405,43 @@ RSpec.describe 'Task Lists', :js, feature_category: :team_planning do end end + describe 'summary properly formatted' do + let(:summary_markdown) do + <<-EOT.strip_heredoc + <details open> + <summary>Valid detail/summary with tasklist</summary> + + - [ ] People Ops: do such and such + + </details> + + * [x] Task 1 + EOT + end + + let!(:issue) { create(:issue, description: summary_markdown, author: user, project: project) } + + it 'renders' do + visit_issue(project, issue) + wait_for_requests + + expect(page).to have_selector('ul.task-list', count: 2) + expect(page).to have_selector('li.task-list-item', count: 2) + expect(page).to have_selector('ul input[checked]', count: 1) + + first('.task-list-item-checkbox').click + wait_for_requests + + visit_issue(project, issue) + wait_for_requests + + expect(page).to have_selector('ul.task-list', count: 2) + expect(page).to have_selector('li.task-list-item', count: 2) + expect(page).to have_selector('ul input[checked]', count: 2) + expect(page).to have_content('2 of 2 checklist items completed') + end + end + describe 'markdown starting with new line character' do let(:markdown_starting_with_new_line) do <<-EOT.strip_heredoc diff --git a/spec/fixtures/api/schemas/entities/codequality_degradation.json b/spec/fixtures/api/schemas/entities/codequality_degradation.json index 863b9f0c77e..ac772873daf 100644 --- a/spec/fixtures/api/schemas/entities/codequality_degradation.json +++ b/spec/fixtures/api/schemas/entities/codequality_degradation.json @@ -21,7 +21,10 @@ }, "web_url": { "type": "string" + }, + "engine_name": { + "type": "string" } }, "additionalProperties": false -}
\ No newline at end of file +} diff --git a/spec/frontend/fixtures/saved_replies.rb b/spec/frontend/fixtures/saved_replies.rb new file mode 100644 index 00000000000..c80ba06bca1 --- /dev/null +++ b/spec/frontend/fixtures/saved_replies.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GraphQL::Query, type: :request, feature_category: :user_profile do + include JavaScriptFixturesHelpers + include ApiHelpers + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + + before do + sign_in(current_user) + end + + context 'when user has no saved replies' do + base_input_path = 'saved_replies/queries/' + base_output_path = 'graphql/saved_replies/' + query_name = 'saved_replies.query.graphql' + + it "#{base_output_path}saved_replies_empty.query.graphql.json" do + query = get_graphql_query_as_string("#{base_input_path}#{query_name}") + + post_graphql(query, current_user: current_user) + + expect_graphql_errors_to_be_empty + end + end + + context 'when user has saved replies' do + base_input_path = 'saved_replies/queries/' + base_output_path = 'graphql/saved_replies/' + query_name = 'saved_replies.query.graphql' + + it "#{base_output_path}saved_replies.query.graphql.json" do + create(:saved_reply, user: current_user) + create(:saved_reply, user: current_user) + + query = get_graphql_query_as_string("#{base_input_path}#{query_name}") + + post_graphql(query, current_user: current_user) + + expect_graphql_errors_to_be_empty + end + end +end diff --git a/spec/frontend/saved_replies/components/__snapshots__/list_item_spec.js.snap b/spec/frontend/saved_replies/components/__snapshots__/list_item_spec.js.snap new file mode 100644 index 00000000000..3abdfcdaf20 --- /dev/null +++ b/spec/frontend/saved_replies/components/__snapshots__/list_item_spec.js.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Saved replies list item component renders list item 1`] = ` +<li + class="gl-mb-5" +> + <div + class="gl-display-flex gl-align-items-center" + > + <strong> + test + </strong> + </div> + + <div + class="gl-mt-3 gl-font-monospace" + > + /assign_reviewer + </div> +</li> +`; diff --git a/spec/frontend/saved_replies/components/list_item_spec.js b/spec/frontend/saved_replies/components/list_item_spec.js new file mode 100644 index 00000000000..cad1000473b --- /dev/null +++ b/spec/frontend/saved_replies/components/list_item_spec.js @@ -0,0 +1,22 @@ +import { shallowMount } from '@vue/test-utils'; +import ListItem from '~/saved_replies/components/list_item.vue'; + +let wrapper; + +function createComponent(propsData = {}) { + return shallowMount(ListItem, { + propsData, + }); +} + +describe('Saved replies list item component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('renders list item', async () => { + wrapper = createComponent({ reply: { name: 'test', content: '/assign_reviewer' } }); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/saved_replies/components/list_spec.js b/spec/frontend/saved_replies/components/list_spec.js new file mode 100644 index 00000000000..66e9ddfe148 --- /dev/null +++ b/spec/frontend/saved_replies/components/list_spec.js @@ -0,0 +1,68 @@ +import Vue from 'vue'; +import { mount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import noSavedRepliesResponse from 'test_fixtures/graphql/saved_replies/saved_replies_empty.query.graphql.json'; +import savedRepliesResponse from 'test_fixtures/graphql/saved_replies/saved_replies.query.graphql.json'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import List from '~/saved_replies/components/list.vue'; +import ListItem from '~/saved_replies/components/list_item.vue'; +import savedRepliesQuery from '~/saved_replies/queries/saved_replies.query.graphql'; + +let wrapper; + +function createMockApolloProvider(response) { + Vue.use(VueApollo); + + const requestHandlers = [[savedRepliesQuery, jest.fn().mockResolvedValue(response)]]; + + return createMockApollo(requestHandlers); +} + +function createComponent(options = {}) { + const { mockApollo } = options; + + return mount(List, { + apolloProvider: mockApollo, + }); +} + +describe('Saved replies list component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('does not render any list items when response is empty', async () => { + const mockApollo = createMockApolloProvider(noSavedRepliesResponse); + wrapper = createComponent({ mockApollo }); + + await waitForPromises(); + + expect(wrapper.findAllComponents(ListItem).length).toBe(0); + }); + + it('render saved replies count', async () => { + const mockApollo = createMockApolloProvider(savedRepliesResponse); + wrapper = createComponent({ mockApollo }); + + await waitForPromises(); + + expect(wrapper.find('[data-testid="title"]').text()).toEqual('My saved replies (2)'); + }); + + it('renders list of saved replies', async () => { + const mockApollo = createMockApolloProvider(savedRepliesResponse); + const savedReplies = savedRepliesResponse.data.currentUser.savedReplies.nodes; + wrapper = createComponent({ mockApollo }); + + await waitForPromises(); + + expect(wrapper.findAllComponents(ListItem).length).toBe(2); + expect(wrapper.findAllComponents(ListItem).at(0).props('reply')).toEqual( + expect.objectContaining(savedReplies[0]), + ); + expect(wrapper.findAllComponents(ListItem).at(1).props('reply')).toEqual( + expect.objectContaining(savedReplies[1]), + ); + }); +}); diff --git a/spec/frontend/work_items/components/notes/work_item_discussion_spec.js b/spec/frontend/work_items/components/notes/work_item_discussion_spec.js index fe9b984f028..1e2ec7e8dc2 100644 --- a/spec/frontend/work_items/components/notes/work_item_discussion_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_discussion_spec.js @@ -130,4 +130,11 @@ describe('Work Item Discussion', () => { expect(findToggleRepliesWidget().props('collapsed')).toBe(false); }); }); + + it('emits `deleteNote` event with correct parameter when child note component emits `deleteNote` event', () => { + createComponent(); + findThreadAtIndex(0).vm.$emit('deleteNote'); + + expect(wrapper.emitted('deleteNote')).toEqual([[mockWorkItemCommentNote]]); + }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_note_spec.js b/spec/frontend/work_items/components/notes/work_item_note_spec.js index d42d82c4e90..8f7d27def15 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_spec.js @@ -1,4 +1,4 @@ -import { GlAvatarLink } from '@gitlab/ui'; +import { GlAvatarLink, GlDropdown } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import WorkItemNote from '~/work_items/components/notes/work_item_note.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; @@ -15,6 +15,8 @@ describe('Work Item Note', () => { const findNoteHeader = () => wrapper.findComponent(NoteHeader); const findNoteBody = () => wrapper.findComponent(NoteBody); const findNoteActions = () => wrapper.findComponent(NoteActions); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-action"]'); const createComponent = ({ note = mockWorkItemCommentNote, isFirstNote = false } = {}) => { wrapper = shallowMount(WorkItemNote, { @@ -66,4 +68,34 @@ describe('Work Item Note', () => { expect(findNoteActions().props('showReply')).toBe(false); }); }); + + it('should display a dropdown if user has a permission to delete note', () => { + createComponent({ + note: { + ...mockWorkItemCommentNote, + userPermissions: { ...mockWorkItemCommentNote.userPermissions, adminNote: true }, + }, + }); + + expect(findDropdown().exists()).toBe(true); + }); + + it('should not display a dropdown if user has no permission to delete note', () => { + createComponent(); + + expect(findDropdown().exists()).toBe(false); + }); + + it('should emit `deleteNote` event when delete note action is clicked', () => { + createComponent({ + note: { + ...mockWorkItemCommentNote, + userPermissions: { ...mockWorkItemCommentNote.userPermissions, adminNote: true }, + }, + }); + + findDeleteNoteButton().vm.$emit('click'); + + expect(wrapper.emitted('deleteNote')).toEqual([[]]); + }); }); diff --git a/spec/frontend/work_items/components/work_item_comment_form_spec.js b/spec/frontend/work_items/components/work_item_comment_form_spec.js index e62eb32fad0..bef7efa2536 100644 --- a/spec/frontend/work_items/components/work_item_comment_form_spec.js +++ b/spec/frontend/work_items/components/work_item_comment_form_spec.js @@ -10,7 +10,7 @@ import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import WorkItemCommentForm from '~/work_items/components/work_item_comment_form.vue'; import WorkItemCommentLocked from '~/work_items/components/work_item_comment_locked.vue'; -import createNoteMutation from '~/work_items/graphql/create_work_item_note.mutation.graphql'; +import createNoteMutation from '~/work_items/graphql/notes/create_work_item_note.mutation.graphql'; import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; diff --git a/spec/frontend/work_items/components/work_item_notes_spec.js b/spec/frontend/work_items/components/work_item_notes_spec.js index df9a141d330..e5b4bee68a8 100644 --- a/spec/frontend/work_items/components/work_item_notes_spec.js +++ b/spec/frontend/work_items/components/work_item_notes_spec.js @@ -1,16 +1,18 @@ -import { GlSkeletonLoader } from '@gitlab/ui'; +import { GlSkeletonLoader, GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import SystemNote from '~/work_items/components/notes/system_note.vue'; import WorkItemNotes from '~/work_items/components/work_item_notes.vue'; import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue'; import WorkItemCommentForm from '~/work_items/components/work_item_comment_form.vue'; import ActivityFilter from '~/work_items/components/notes/activity_filter.vue'; -import workItemNotesQuery from '~/work_items/graphql/work_item_notes.query.graphql'; -import workItemNotesByIidQuery from '~/work_items/graphql/work_item_notes_by_iid.query.graphql'; +import workItemNotesQuery from '~/work_items/graphql/notes/work_item_notes.query.graphql'; +import workItemNotesByIidQuery from '~/work_items/graphql/notes/work_item_notes_by_iid.query.graphql'; +import deleteWorkItemNoteMutation from '~/work_items/graphql/notes/delete_work_item_notes.mutation.graphql'; import { DEFAULT_PAGE_SIZE_NOTES, WIDGET_TYPE_NOTES } from '~/work_items/constants'; import { ASC, DESC } from '~/notes/constants'; import { @@ -47,6 +49,8 @@ describe('WorkItemNotes component', () => { Vue.use(VueApollo); + const showModal = jest.fn(); + const findAllSystemNotes = () => wrapper.findAllComponents(SystemNote); const findAllListItems = () => wrapper.findAll('ul.timeline > *'); const findActivityLabel = () => wrapper.find('label'); @@ -56,6 +60,8 @@ describe('WorkItemNotes component', () => { const findSystemNoteAtIndex = (index) => findAllSystemNotes().at(index); const findAllWorkItemCommentNotes = () => wrapper.findAllComponents(WorkItemDiscussion); const findWorkItemCommentNoteAtIndex = (index) => findAllWorkItemCommentNotes().at(index); + const findDeleteNoteModal = () => wrapper.findComponent(GlModal); + const workItemNotesQueryHandler = jest.fn().mockResolvedValue(mockWorkItemNotesResponse); const workItemNotesByIidQueryHandler = jest .fn() @@ -64,16 +70,22 @@ describe('WorkItemNotes component', () => { const workItemNotesWithCommentsQueryHandler = jest .fn() .mockResolvedValue(mockWorkItemNotesResponseWithComments); + const deleteWorkItemNoteMutationSuccessHandler = jest.fn().mockResolvedValue({ + data: { destroyNote: { note: null, __typename: 'DestroyNote' } }, + }); + const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); const createComponent = ({ workItemId = mockWorkItemId, fetchByIid = false, defaultWorkItemNotesQueryHandler = workItemNotesQueryHandler, + deleteWINoteMutationHandler = deleteWorkItemNoteMutationSuccessHandler, } = {}) => { wrapper = shallowMount(WorkItemNotes, { apolloProvider: createMockApollo([ [workItemNotesQuery, defaultWorkItemNotesQueryHandler], [workItemNotesByIidQuery, workItemNotesByIidQueryHandler], + [deleteWorkItemNoteMutation, deleteWINoteMutationHandler], ]), propsData: { workItemId, @@ -89,6 +101,9 @@ describe('WorkItemNotes component', () => { useIidInWorkItemsPath: fetchByIid, }, }, + stubs: { + GlModal: stubComponent(GlModal, { methods: { show: showModal } }), + }, }); }; @@ -240,4 +255,83 @@ describe('WorkItemNotes component', () => { ); }); }); + + it('should open delete modal confirmation when child discussion emits `deleteNote` event', async () => { + createComponent({ + defaultWorkItemNotesQueryHandler: workItemNotesWithCommentsQueryHandler, + }); + await waitForPromises(); + + findWorkItemCommentNoteAtIndex(0).vm.$emit('deleteNote', { id: '1', isLastNote: false }); + expect(showModal).toHaveBeenCalled(); + }); + + describe('when modal is open', () => { + beforeEach(() => { + createComponent({ + defaultWorkItemNotesQueryHandler: workItemNotesWithCommentsQueryHandler, + }); + return waitForPromises(); + }); + + it('sends the mutation with correct variables', () => { + const noteId = 'some-test-id'; + + findWorkItemCommentNoteAtIndex(0).vm.$emit('deleteNote', { id: noteId }); + findDeleteNoteModal().vm.$emit('primary'); + + expect(deleteWorkItemNoteMutationSuccessHandler).toHaveBeenCalledWith({ + input: { + id: noteId, + }, + }); + }); + + it('successfully removes the note from the discussion', async () => { + expect(findWorkItemCommentNoteAtIndex(0).props('discussion')).toHaveLength(2); + + findWorkItemCommentNoteAtIndex(0).vm.$emit('deleteNote', { + id: mockDiscussions[0].notes.nodes[0].id, + }); + findDeleteNoteModal().vm.$emit('primary'); + + await waitForPromises(); + expect(findWorkItemCommentNoteAtIndex(0).props('discussion')).toHaveLength(1); + }); + + it('successfully removes the discussion from work item if discussion only had one note', async () => { + const secondDiscussion = findWorkItemCommentNoteAtIndex(1); + + expect(findAllWorkItemCommentNotes()).toHaveLength(2); + expect(secondDiscussion.props('discussion')).toHaveLength(1); + + secondDiscussion.vm.$emit('deleteNote', { + id: mockDiscussions[1].notes.nodes[0].id, + discussion: { id: mockDiscussions[1].id }, + }); + findDeleteNoteModal().vm.$emit('primary'); + + await waitForPromises(); + expect(findAllWorkItemCommentNotes()).toHaveLength(1); + }); + }); + + it('emits `error` event if delete note mutation is rejected', async () => { + createComponent({ + defaultWorkItemNotesQueryHandler: workItemNotesWithCommentsQueryHandler, + deleteWINoteMutationHandler: errorHandler, + }); + await waitForPromises(); + + findWorkItemCommentNoteAtIndex(0).vm.$emit('deleteNote', { + id: mockDiscussions[0].notes.nodes[0].id, + }); + findDeleteNoteModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([ + ['Something went wrong when deleting a comment. Please try again'], + ]); + }); }); diff --git a/spec/graphql/mutations/ci/job_token_scope/remove_project_spec.rb b/spec/graphql/mutations/ci/job_token_scope/remove_project_spec.rb index 7fb45e93474..a5294e96d71 100644 --- a/spec/graphql/mutations/ci/job_token_scope/remove_project_spec.rb +++ b/spec/graphql/mutations/ci/job_token_scope/remove_project_spec.rb @@ -66,7 +66,7 @@ RSpec.describe Mutations::Ci::JobTokenScope::RemoveProject, feature_category: :c it 'executes project removal for the correct direction' do expect(::Ci::JobTokenScope::RemoveProjectService) .to receive(:new).with(project, current_user).and_return(service) - expect(service).to receive(:execute).with(target_project, direction: 'inbound') + expect(service).to receive(:execute).with(target_project, 'inbound') .and_return(instance_double('ServiceResponse', "success?": true)) subject @@ -78,7 +78,7 @@ RSpec.describe Mutations::Ci::JobTokenScope::RemoveProject, feature_category: :c it 'returns an error response' do expect(::Ci::JobTokenScope::RemoveProjectService).to receive(:new).with(project, current_user).and_return(service) - expect(service).to receive(:execute).with(target_project, direction: :outbound).and_return(ServiceResponse.error(message: 'The error message')) + expect(service).to receive(:execute).with(target_project, :outbound).and_return(ServiceResponse.error(message: 'The error message')) expect(subject.fetch(:ci_job_token_scope)).to be_nil expect(subject.fetch(:errors)).to include("The error message") diff --git a/spec/lib/gitlab/database/schema_validation/database_spec.rb b/spec/lib/gitlab/database/schema_validation/database_spec.rb new file mode 100644 index 00000000000..c0026f91b46 --- /dev/null +++ b/spec/lib/gitlab/database/schema_validation/database_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::SchemaValidation::Database, feature_category: :database do + let(:database_name) { 'main' } + let(:database_indexes) do + [['index', 'CREATE UNIQUE INDEX "index" ON public.achievements USING btree (namespace_id, lower(name))']] + end + + let(:query_result) { instance_double('ActiveRecord::Result', rows: database_indexes) } + let(:database_model) { Gitlab::Database.database_base_models[database_name] } + let(:connection) { database_model.connection } + + subject(:database) { described_class.new(connection) } + + before do + allow(connection).to receive(:exec_query).and_return(query_result) + end + + describe '#fetch_index_by_name' do + context 'when index does not exist' do + it 'returns nil' do + index = database.fetch_index_by_name('non_existing_index') + + expect(index).to be_nil + end + end + + it 'returns index by name' do + index = database.fetch_index_by_name('index') + + expect(index.name).to eq('index') + end + end + + describe '#indexes' do + it 'returns indexes' do + indexes = database.indexes + + expect(indexes).to all(be_a(Gitlab::Database::SchemaValidation::Index)) + expect(indexes.map(&:name)).to eq(['index']) + end + end +end diff --git a/spec/lib/gitlab/database/schema_validation/index_spec.rb b/spec/lib/gitlab/database/schema_validation/index_spec.rb new file mode 100644 index 00000000000..297211d79ed --- /dev/null +++ b/spec/lib/gitlab/database/schema_validation/index_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Gitlab::Database::SchemaValidation::Index, feature_category: :database do + let(:index_statement) { 'CREATE INDEX index_name ON public.achievements USING btree (namespace_id)' } + + let(:stmt) { PgQuery.parse(index_statement).tree.stmts.first.stmt.index_stmt } + + let(:index) { described_class.new(stmt) } + + describe '#name' do + it 'returns index name' do + expect(index.name).to eq('index_name') + end + end + + describe '#statement' do + it 'returns index statement' do + expect(index.statement).to eq(index_statement) + end + end +end diff --git a/spec/lib/gitlab/database/schema_validation/indexes_spec.rb b/spec/lib/gitlab/database/schema_validation/indexes_spec.rb index 337597a49b0..4351031a4b4 100644 --- a/spec/lib/gitlab/database/schema_validation/indexes_spec.rb +++ b/spec/lib/gitlab/database/schema_validation/indexes_spec.rb @@ -6,8 +6,8 @@ RSpec.describe Gitlab::Database::SchemaValidation::Indexes, feature_category: :d let(:structure_file_path) { Rails.root.join('spec/fixtures/structure.sql') } let(:database_indexes) do [ - ['wrong_index', 'CREATE UNIQUE INDEX public.wrong_index ON table_name (column_name)'], - ['extra_index', 'CREATE INDEX public.extra_index ON table_name (column_name)'], + ['wrong_index', 'CREATE UNIQUE INDEX wrong_index ON public.table_name (column_name)'], + ['extra_index', 'CREATE INDEX extra_index ON public.table_name (column_name)'], ['index', 'CREATE UNIQUE INDEX "index" ON public.achievements USING btree (namespace_id, lower(name))'] ] end @@ -20,7 +20,10 @@ RSpec.describe Gitlab::Database::SchemaValidation::Indexes, feature_category: :d let(:query_result) { instance_double('ActiveRecord::Result', rows: database_indexes) } - subject(:schema_validation) { described_class.new(structure_file_path, database_name) } + let(:database) { Gitlab::Database::SchemaValidation::Database.new(connection) } + let(:structure_file) { Gitlab::Database::SchemaValidation::StructureSql.new(structure_file_path) } + + subject(:schema_validation) { described_class.new(structure_file, database) } before do allow(connection).to receive(:exec_query).and_return(query_result) diff --git a/spec/lib/gitlab/etag_caching/middleware_spec.rb b/spec/lib/gitlab/etag_caching/middleware_spec.rb index 81751511270..fa0b3d1c6dd 100644 --- a/spec/lib/gitlab/etag_caching/middleware_spec.rb +++ b/spec/lib/gitlab/etag_caching/middleware_spec.rb @@ -124,8 +124,8 @@ RSpec.describe Gitlab::EtagCaching::Middleware, :clean_gitlab_redis_shared_state method: 'GET', path: enabled_path, status: status_code, - request_urgency: :low, - target_duration_s: 5, + request_urgency: :medium, + target_duration_s: 0.5, metadata: a_hash_including( { 'meta.caller_id' => 'Projects::NotesController#index', diff --git a/spec/lib/gitlab/instrumentation_helper_spec.rb b/spec/lib/gitlab/instrumentation_helper_spec.rb index ce67d1d0297..8a88328e0c1 100644 --- a/spec/lib/gitlab/instrumentation_helper_spec.rb +++ b/spec/lib/gitlab/instrumentation_helper_spec.rb @@ -5,7 +5,7 @@ require 'rspec-parameterized' require 'support/helpers/rails_helpers' RSpec.describe Gitlab::InstrumentationHelper, :clean_gitlab_redis_repository_cache, :clean_gitlab_redis_cache, - feature_category: :scalability do + :use_null_store_as_repository_cache, feature_category: :scalability do using RSpec::Parameterized::TableSyntax describe '.add_instrumentation_data', :request_store do @@ -23,42 +23,19 @@ RSpec.describe Gitlab::InstrumentationHelper, :clean_gitlab_redis_repository_cac expect(payload).to include(db_count: 0, db_cached_count: 0, db_write_count: 0) end - shared_examples 'make Gitaly calls' do - context 'when Gitaly calls are made' do - it 'adds Gitaly and Redis data' do - project = create(:project) - RequestStore.clear! - project.repository.exists? + context 'when Gitaly calls are made' do + it 'adds Gitaly and Redis data' do + project = create(:project) + RequestStore.clear! + project.repository.exists? - subject - - expect(payload[:gitaly_calls]).to eq(1) - expect(payload[:gitaly_duration_s]).to be >= 0 - # With MultiStore, the number of `redis_calls` depends on whether primary_store - # (Gitlab::Redis::Repositorycache) and secondary_store (Gitlab::Redis::Cache) are of the same instance. - # In GitLab.com CI, primary and secondary are the same instance, thus only 1 call being made. If primary - # and secondary are different instances, an additional fallback read to secondary_store will be made because - # the first `get` call is a cache miss. Then, the following expect will fail. - expect(payload[:redis_calls]).to eq(1) - expect(payload[:redis_duration_ms]).to be_nil - end - end - end - - context 'when multistore ff use_primary_and_secondary_stores_for_repository_cache is enabled' do - before do - stub_feature_flags(use_primary_and_secondary_stores_for_repository_cache: true) - end - - it_behaves_like 'make Gitaly calls' - end + subject - context 'when multistore ff use_primary_and_secondary_stores_for_repository_cache is disabled' do - before do - stub_feature_flags(use_primary_and_secondary_stores_for_repository_cache: false) + expect(payload[:gitaly_calls]).to eq(1) + expect(payload[:gitaly_duration_s]).to be >= 0 + expect(payload[:redis_calls]).to eq(nil) + expect(payload[:redis_duration_ms]).to be_nil end - - it_behaves_like 'make Gitaly calls' end context 'when Redis calls are made' do diff --git a/spec/lib/gitlab/redis/repository_cache_spec.rb b/spec/lib/gitlab/redis/repository_cache_spec.rb index 56f77782778..8cdc4580f9e 100644 --- a/spec/lib/gitlab/redis/repository_cache_spec.rb +++ b/spec/lib/gitlab/redis/repository_cache_spec.rb @@ -4,43 +4,6 @@ require 'spec_helper' RSpec.describe Gitlab::Redis::RepositoryCache, feature_category: :scalability do include_examples "redis_new_instance_shared_examples", 'repository_cache', Gitlab::Redis::Cache - include_examples "redis_shared_examples" - - describe '#pool' do - let(:config_new_format_host) { "spec/fixtures/config/redis_new_format_host.yml" } - let(:config_new_format_socket) { "spec/fixtures/config/redis_new_format_socket.yml" } - - subject { described_class.pool } - - before do - allow(described_class).to receive(:config_file_name).and_return(config_new_format_host) - - # Override rails root to avoid having our fixtures overwritten by `redis.yml` if it exists - allow(Gitlab::Redis::Cache).to receive(:rails_root).and_return(mktmpdir) - allow(Gitlab::Redis::Cache).to receive(:config_file_name).and_return(config_new_format_socket) - end - - around do |example| - clear_pool - example.run - ensure - clear_pool - end - - it 'instantiates an instance of MultiStore' do - subject.with do |redis_instance| - expect(redis_instance).to be_instance_of(::Gitlab::Redis::MultiStore) - - expect(redis_instance.primary_store.connection[:id]).to eq("redis://test-host:6379/99") - expect(redis_instance.secondary_store.connection[:id]).to eq("unix:///path/to/redis.sock/0") - - expect(redis_instance.instance_name).to eq('RepositoryCache') - end - end - - it_behaves_like 'multi store feature flags', :use_primary_and_secondary_stores_for_repository_cache, - :use_primary_store_as_default_for_repository_cache - end describe '#raw_config_hash' do it 'has a legacy default URL' do @@ -49,4 +12,10 @@ RSpec.describe Gitlab::Redis::RepositoryCache, feature_category: :scalability do expect(subject.send(:raw_config_hash)).to eq(url: 'redis://localhost:6380') end end + + describe '.cache_store' do + it 'has a default ttl of 8 hours' do + expect(described_class.cache_store.options[:expires_in]).to eq(8.hours) + end + end end diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb index ce5d0cfa632..406de3403f3 100644 --- a/spec/lib/gitlab/regex_spec.rb +++ b/spec/lib/gitlab/regex_spec.rb @@ -1089,4 +1089,73 @@ RSpec.describe Gitlab::Regex, feature_category: :tooling do it { is_expected.not_to match('random string') } it { is_expected.not_to match('12321342545356434523412341245452345623453542345234523453245') } end + + describe 'code, html blocks, or html comment blocks regex' do + context 'code blocks' do + subject { described_class::MARKDOWN_CODE_BLOCK_REGEX } + + let(:expected) { %(```code\nsome code\n\n>>>\nthat includes a multiline-blockquote\n>>>\n```) } + let(:markdown) do + <<~MARKDOWN + Regular text + + ```code + some code + + >>> + that includes a multiline-blockquote + >>> + ``` + MARKDOWN + end + + it { is_expected.to match(%(```ruby\nsomething\n```)) } + it { is_expected.not_to match(%(must start in first column ```ruby\nsomething\n```)) } + it { is_expected.not_to match(%(```ruby must be multi-line ```)) } + it { expect(subject.match(markdown)[:code]).to eq expected } + end + + context 'HTML blocks' do + subject { described_class::MARKDOWN_HTML_BLOCK_REGEX } + + let(:expected) { %(<section>\n<p>paragraph</p>\n\n>>>\nthat includes a multiline-blockquote\n>>>\n</section>) } + let(:markdown) do + <<~MARKDOWN + Regular text + + <section> + <p>paragraph</p> + + >>> + that includes a multiline-blockquote + >>> + </section> + MARKDOWN + end + + it { is_expected.to match(%(<section>\nsomething\n</section>)) } + it { is_expected.not_to match(%(must start in first column <section>\nsomething\n</section>)) } + it { is_expected.not_to match(%(<section>must be multi-line</section>)) } + it { expect(subject.match(markdown)[:html]).to eq expected } + end + + context 'HTML comment blocks' do + subject { described_class::MARKDOWN_HTML_COMMENT_BLOCK_REGEX } + + let(:expected) { %(<!-- the start of an HTML comment\n- [ ] list item commented out\n-->) } + let(:markdown) do + <<~MARKDOWN + Regular text + + <!-- the start of an HTML comment + - [ ] list item commented out + --> + MARKDOWN + end + + it { is_expected.to match(%(<!--\ncomment\n-->)) } + it { is_expected.not_to match(%(must start in first column <!--\ncomment\n-->)) } + it { expect(subject.match(markdown)[:html_block_comment]).to eq expected } + end + end end diff --git a/spec/lib/gitlab/repository_cache/preloader_spec.rb b/spec/lib/gitlab/repository_cache/preloader_spec.rb index 21628481fed..e6fb0da6412 100644 --- a/spec/lib/gitlab/repository_cache/preloader_spec.rb +++ b/spec/lib/gitlab/repository_cache/preloader_spec.rb @@ -6,76 +6,51 @@ RSpec.describe Gitlab::RepositoryCache::Preloader, :use_clean_rails_redis_cachin feature_category: :source_code_management do let(:projects) { create_list(:project, 2, :repository) } let(:repositories) { projects.map(&:repository) } + let(:cache) { Gitlab::RepositoryCache.store } - before do - stub_feature_flags(use_primary_store_as_default_for_repository_cache: false) - end - - shared_examples 'preload' do - describe '#preload' do - context 'when the values are already cached' do - before do - # Warm the cache but use a different model so they are not memoized - repos = Project.id_in(projects).order(:id).map(&:repository) - - allow(repos[0].head_tree).to receive(:readme_path).and_return('README.txt') - allow(repos[1].head_tree).to receive(:readme_path).and_return('README.md') - - repos.map(&:exists?) - repos.map(&:readme_path) - end - - it 'prevents individual cache reads for cached methods' do - expect(cache).to receive(:read_multi).once.and_call_original - - described_class.new(repositories).preload( - %i[exists? readme_path] - ) - - expect(cache).not_to receive(:read) - expect(cache).not_to receive(:write) + describe '#preload' do + context 'when the values are already cached' do + before do + # Warm the cache but use a different model so they are not memoized + repos = Project.id_in(projects).order(:id).map(&:repository) - expect(repositories[0].exists?).to eq(true) - expect(repositories[0].readme_path).to eq('README.txt') + allow(repos[0].head_tree).to receive(:readme_path).and_return('README.txt') + allow(repos[1].head_tree).to receive(:readme_path).and_return('README.md') - expect(repositories[1].exists?).to eq(true) - expect(repositories[1].readme_path).to eq('README.md') - end + repos.map(&:exists?) + repos.map(&:readme_path) end - context 'when values are not cached' do - it 'reads and writes from cache individually' do - described_class.new(repositories).preload( - %i[exists? has_visible_content?] - ) + it 'prevents individual cache reads for cached methods' do + expect(cache).to receive(:read_multi).once.and_call_original - expect(cache).to receive(:read).exactly(4).times - expect(cache).to receive(:write).exactly(4).times + described_class.new(repositories).preload( + %i[exists? readme_path] + ) - repositories.each(&:exists?) - repositories.each(&:has_visible_content?) - end - end - end - end + expect(cache).not_to receive(:read) + expect(cache).not_to receive(:write) - context 'when use_primary_and_secondary_stores_for_repository_cache feature flag is enabled' do - let(:cache) { Gitlab::RepositoryCache.store } + expect(repositories[0].exists?).to eq(true) + expect(repositories[0].readme_path).to eq('README.txt') - before do - stub_feature_flags(use_primary_and_secondary_stores_for_repository_cache: true) + expect(repositories[1].exists?).to eq(true) + expect(repositories[1].readme_path).to eq('README.md') + end end - it_behaves_like 'preload' - end + context 'when values are not cached' do + it 'reads and writes from cache individually' do + described_class.new(repositories).preload( + %i[exists? has_visible_content?] + ) - context 'when use_primary_and_secondary_stores_for_repository_cache feature flag is disabled' do - let(:cache) { Rails.cache } + expect(cache).to receive(:read).exactly(4).times + expect(cache).to receive(:write).exactly(4).times - before do - stub_feature_flags(use_primary_and_secondary_stores_for_repository_cache: false) + repositories.each(&:exists?) + repositories.each(&:has_visible_content?) + end end - - it_behaves_like 'preload' end end diff --git a/spec/lib/gitlab/repository_hash_cache_spec.rb b/spec/lib/gitlab/repository_hash_cache_spec.rb index d41bf45f72e..6b52c315a70 100644 --- a/spec/lib/gitlab/repository_hash_cache_spec.rb +++ b/spec/lib/gitlab/repository_hash_cache_spec.rb @@ -69,35 +69,20 @@ RSpec.describe Gitlab::RepositoryHashCache, :clean_gitlab_redis_cache do end end - shared_examples "key?" do - describe "#key?" do - subject { cache.key?(:example, "test") } + describe "#key?" do + subject { cache.key?(:example, "test") } - context "key exists" do - before do - cache.write(:example, test_hash) - end - - it { is_expected.to be(true) } + context "key exists" do + before do + cache.write(:example, test_hash) end - context "key doesn't exist" do - it { is_expected.to be(false) } - end + it { is_expected.to be(true) } end - end - - context "when both multistore FF is enabled" do - it_behaves_like "key?" - end - context "when both multistore FF is disabled" do - before do - stub_feature_flags(use_primary_and_secondary_stores_for_repository_cache: false) - stub_feature_flags(use_primary_store_as_default_for_repository_cache: false) + context "key doesn't exist" do + it { is_expected.to be(false) } end - - it_behaves_like "key?" end describe "#read_members" do diff --git a/spec/models/concerns/taskable_spec.rb b/spec/models/concerns/taskable_spec.rb index 140f6cda51c..0ad29454ff3 100644 --- a/spec/models/concerns/taskable_spec.rb +++ b/spec/models/concerns/taskable_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Taskable do +RSpec.describe Taskable, feature_category: :team_planning do using RSpec::Parameterized::TableSyntax describe '.get_tasks' do @@ -13,8 +13,18 @@ RSpec.describe Taskable do - [x] Second item * [x] First item * [ ] Second item + + <!-- a comment + - [ ] Item in comment, ignore + rest of comment --> + + [ ] No-break space (U+00A0) + [ ] Figure space (U+2007) + + ``` + - [ ] Item in code, ignore + ``` + + [ ] Narrow no-break space (U+202F) + [ ] Thin space (U+2009) MARKDOWN diff --git a/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_project_spec.rb b/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_project_spec.rb index 805f300072d..f1296c054f9 100644 --- a/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_project_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_project_spec.rb @@ -76,6 +76,15 @@ RSpec.describe 'CiJobTokenScopeRemoveProject', feature_category: :continuous_int end.to change { Ci::JobToken::ProjectScopeLink.outbound.count }.by(-1) end + it 'responds successfully' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:ok) + expect(graphql_errors).to be_nil + expect(graphql_data_at(:ciJobTokenScopeRemoveProject, :ciJobTokenScope, :projects, :nodes)) + .to contain_exactly({ 'path' => project.path }) + end + context 'when invalid target project is provided' do before do variables[:target_project_path] = 'unknown/project' diff --git a/spec/requests/profiles/saved_replies_controller_spec.rb b/spec/requests/profiles/saved_replies_controller_spec.rb new file mode 100644 index 00000000000..27a961a201f --- /dev/null +++ b/spec/requests/profiles/saved_replies_controller_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Profiles::SavedRepliesController, feature_category: :user_profile do + let_it_be(:user) { create(:user) } + + before do + sign_in(user) + end + + describe 'GET #index' do + describe 'feature flag disabled' do + before do + stub_feature_flags(saved_replies: false) + + get '/-/profile/saved_replies' + end + + it { expect(response).to have_gitlab_http_status(:not_found) } + end + + describe 'feature flag enabled' do + before do + get '/-/profile/saved_replies' + end + + it { expect(response).to have_gitlab_http_status(:ok) } + + it 'sets hide search settings ivar' do + expect(assigns(:hide_search_settings)).to eq(true) + end + end + end +end diff --git a/spec/requests/projects/noteable_notes_spec.rb b/spec/requests/projects/noteable_notes_spec.rb index 084cf2d4a5c..55540447da0 100644 --- a/spec/requests/projects/noteable_notes_spec.rb +++ b/spec/requests/projects/noteable_notes_spec.rb @@ -43,7 +43,7 @@ RSpec.describe 'Project noteable notes', feature_category: :team_planning do expect(Gitlab::Metrics::RailsSlis.request_apdex).to( receive(:increment).with( labels: { - request_urgency: :low, + request_urgency: :medium, feature_category: "team_planning", endpoint_id: "Projects::NotesController#index" }, @@ -57,8 +57,8 @@ RSpec.describe 'Project noteable notes', feature_category: :team_planning do 'process_action.action_controller', a_hash_including( { - request_urgency: :low, - target_duration_s: 5, + request_urgency: :medium, + target_duration_s: 0.5, metadata: a_hash_including({ 'meta.feature_category' => 'team_planning', 'meta.caller_id' => "Projects::NotesController#index" diff --git a/spec/serializers/codequality_degradation_entity_spec.rb b/spec/serializers/codequality_degradation_entity_spec.rb index 0390e232fd5..32269e5475b 100644 --- a/spec/serializers/codequality_degradation_entity_spec.rb +++ b/spec/serializers/codequality_degradation_entity_spec.rb @@ -18,6 +18,7 @@ RSpec.describe CodequalityDegradationEntity do expect(subject[:file_path]).to eq("file_a.rb") expect(subject[:line]).to eq(10) expect(subject[:web_url]).to eq("http://localhost/root/test-project/-/blob/f572d396fae9206628714fb2ce00f72e94f2258f/file_a.rb#L10") + expect(subject[:engine_name]).to eq('structure') end end @@ -30,6 +31,7 @@ RSpec.describe CodequalityDegradationEntity do expect(subject[:file_path]).to eq("file_b.rb") expect(subject[:line]).to eq(10) expect(subject[:web_url]).to eq("http://localhost/root/test-project/-/blob/f572d396fae9206628714fb2ce00f72e94f2258f/file_b.rb#L10") + expect(subject[:engine_name]).to eq('rubocop') end end @@ -46,6 +48,7 @@ RSpec.describe CodequalityDegradationEntity do expect(subject[:file_path]).to eq("file_b.rb") expect(subject[:line]).to eq(10) expect(subject[:web_url]).to eq("http://localhost/root/test-project/-/blob/f572d396fae9206628714fb2ce00f72e94f2258f/file_b.rb#L10") + expect(subject[:engine_name]).to eq('rubocop') end end end diff --git a/spec/services/ci/job_token_scope/remove_project_service_spec.rb b/spec/services/ci/job_token_scope/remove_project_service_spec.rb index e154d8c0422..5b39f8908f2 100644 --- a/spec/services/ci/job_token_scope/remove_project_service_spec.rb +++ b/spec/services/ci/job_token_scope/remove_project_service_spec.rb @@ -23,7 +23,7 @@ RSpec.describe Ci::JobTokenScope::RemoveProjectService, feature_category: :conti end describe '#execute' do - subject(:result) { service.execute(target_project) } + subject(:result) { service.execute(target_project, :outbound) } it_behaves_like 'editable job token scope' do context 'when user has permissions on source and target project' do diff --git a/spec/services/projects/import_export/export_service_spec.rb b/spec/services/projects/import_export/export_service_spec.rb index 2c1ebe27014..be059aec697 100644 --- a/spec/services/projects/import_export/export_service_spec.rb +++ b/spec/services/projects/import_export/export_service_spec.rb @@ -2,11 +2,12 @@ require 'spec_helper' -RSpec.describe Projects::ImportExport::ExportService do +RSpec.describe Projects::ImportExport::ExportService, feature_category: :importers do describe '#execute' do let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be_with_reload(:project) { create(:project, group: group) } - let(:project) { create(:project) } let(:shared) { project.import_export_shared } let!(:after_export_strategy) { Gitlab::ImportExport::AfterExportStrategies::DownloadNotificationStrategy.new } @@ -220,5 +221,21 @@ RSpec.describe Projects::ImportExport::ExportService do expect { service.execute }.to raise_error(Gitlab::ImportExport::Error).with_message(expected_message) end end + + it "avoids N+1 when exporting project members" do + group.add_owner(user) + group.add_maintainer(create(:user)) + project.add_maintainer(create(:user)) + + # warm up + service.execute + + control = ActiveRecord::QueryRecorder.new { service.execute } + + group.add_maintainer(create(:user)) + project.add_maintainer(create(:user)) + + expect { service.execute }.not_to exceed_query_limit(control) + end end end diff --git a/spec/support/redis.rb b/spec/support/redis.rb index 6d313c8aa16..d5ae0bf1582 100644 --- a/spec/support/redis.rb +++ b/spec/support/redis.rb @@ -25,4 +25,10 @@ RSpec.configure do |config| instance_class.with(&:flushdb) end end + + config.before(:each, :use_null_store_as_repository_cache) do |example| + null_store = ActiveSupport::Cache::NullStore.new + + allow(Gitlab::Redis::RepositoryCache).to receive(:cache_store).and_return(null_store) + end end diff --git a/spec/tasks/cache/clear/redis_spec.rb b/spec/tasks/cache/clear/redis_spec.rb index 9b6ea3891d9..375d01bf2ba 100644 --- a/spec/tasks/cache/clear/redis_spec.rb +++ b/spec/tasks/cache/clear/redis_spec.rb @@ -3,7 +3,7 @@ require 'rake_helper' RSpec.describe 'clearing redis cache', :clean_gitlab_redis_repository_cache, :clean_gitlab_redis_cache, - :silence_stdout, feature_category: :redis do + :silence_stdout, :use_null_store_as_repository_cache, feature_category: :redis do before do Rake.application.rake_require 'tasks/cache' end @@ -20,37 +20,11 @@ RSpec.describe 'clearing redis cache', :clean_gitlab_redis_repository_cache, :cl create(:ci_pipeline, project: project).project.pipeline_status end - context 'when use_primary_and_secondary_stores_for_repository_cache MultiStore FF is enabled' do - # Initially, project:{id}:pipeline_status is explicitly cached in Gitlab::Redis::Cache, whereas repository is - # cached in Rails.cache (which is a NullStore). - # With the MultiStore feature flag enabled, we use Gitlab::Redis::RepositoryCache instance as primary store and - # Gitlab::Redis::Cache as secondary store. - # This ends up storing 2 extra keys (exists? and root_ref) in both Gitlab::Redis::RepositoryCache and - # Gitlab::Redis::Cache instances when loading project.pipeline_status - let(:keys_size_changed) { -3 } - - before do - stub_feature_flags(use_primary_and_secondary_stores_for_repository_cache: true) - allow(pipeline_status).to receive(:loaded).and_return(nil) - end - - it 'clears pipeline status cache' do - expect { run_rake_task('cache:clear:redis') }.to change { pipeline_status.has_cache? } - end - - it_behaves_like 'clears the cache' + before do + allow(pipeline_status).to receive(:loaded).and_return(nil) end - context 'when use_primary_and_secondary_stores_for_repository_cache and - use_primary_store_as_default_for_repository_cache feature flags are disabled' do - before do - stub_feature_flags(use_primary_and_secondary_stores_for_repository_cache: false) - stub_feature_flags(use_primary_store_as_default_for_repository_cache: false) - allow(pipeline_status).to receive(:loaded).and_return(nil) - end - - it_behaves_like 'clears the cache' - end + it_behaves_like 'clears the cache' end describe 'clearing set caches' do diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb index c583631e4d5..c0d46a206ce 100644 --- a/spec/workers/every_sidekiq_worker_spec.rb +++ b/spec/workers/every_sidekiq_worker_spec.rb @@ -364,6 +364,7 @@ RSpec.describe 'Every Sidekiq worker' do 'Onboarding::PipelineCreatedWorker' => 3, 'Onboarding::ProgressWorker' => 3, 'Onboarding::UserAddedWorker' => 3, + 'Namespaces::FreeUserCap::OverLimitNotificationWorker' => false, 'Namespaces::RefreshRootStatisticsWorker' => 3, 'Namespaces::RootStatisticsWorker' => 3, 'Namespaces::ScheduleAggregationWorker' => 3, |