diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-03 15:12:58 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-03 15:12:58 +0000 |
commit | 27a5080c34c64a84219d855d652b994c5e344a0a (patch) | |
tree | 1f6bcb68378e4965b4e93a846d8a939af18aeec6 /app | |
parent | 2c01907a1ab4b328e2f20ddf9e10dfe6dc17105a (diff) | |
download | gitlab-ce-27a5080c34c64a84219d855d652b994c5e344a0a.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
24 files changed, 409 insertions, 38 deletions
diff --git a/app/assets/javascripts/emoji/awards_app/store/actions.js b/app/assets/javascripts/emoji/awards_app/store/actions.js index 427a504e038..677c11277a3 100644 --- a/app/assets/javascripts/emoji/awards_app/store/actions.js +++ b/app/assets/javascripts/emoji/awards_app/store/actions.js @@ -2,8 +2,6 @@ import * as Sentry from '@sentry/browser'; import axios from '~/lib/utils/axios_utils'; import { normalizeHeaders } from '~/lib/utils/common_utils'; import { joinPaths } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; -import showToast from '~/vue_shared/plugins/global_toast'; import { SET_INITIAL_DATA, FETCH_AWARDS_SUCCESS, @@ -62,8 +60,6 @@ export const toggleAward = async ({ commit, state }, name) => { throw err; }); - - showToast(__('Award removed')); } else { const optimisticAward = newOptimisticAward(name, state); @@ -78,8 +74,6 @@ export const toggleAward = async ({ commit, state }, name) => { }); commit(ADD_NEW_AWARD, data); - - showToast(__('Award added')); } } catch (error) { Sentry.captureException(error); diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index adee18184aa..1795363f24c 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -43,6 +43,7 @@ export default ({ editorAiActions = [] } = {}) => { reportAbusePath: notesDataset.reportAbusePath, newCommentTemplatePath: notesDataset.newCommentTemplatePath, editorAiActions, + mrFilter: true, }, data() { const noteableData = JSON.parse(notesDataset.noteableData); diff --git a/app/assets/javascripts/notes/components/mr_discussion_filter.vue b/app/assets/javascripts/notes/components/mr_discussion_filter.vue new file mode 100644 index 00000000000..2338c9eef67 --- /dev/null +++ b/app/assets/javascripts/notes/components/mr_discussion_filter.vue @@ -0,0 +1,109 @@ +<script> +import { GlCollapsibleListbox, GlButton, GlIcon, GlSprintf, GlButtonGroup } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import { __ } from '~/locale'; +import { MR_FILTER_OPTIONS } from '~/notes/constants'; + +export default { + components: { + GlCollapsibleListbox, + GlButton, + GlButtonGroup, + GlIcon, + GlSprintf, + LocalStorageSync, + }, + data() { + return { + selectedFilters: MR_FILTER_OPTIONS.map((f) => f.value), + }; + }, + computed: { + ...mapState({ + mergeRequestFilters: (state) => state.notes.mergeRequestFilters, + discussionSortOrder: (state) => state.notes.discussionSortOrder, + }), + selectedFilterText() { + const { length } = this.mergeRequestFilters; + + if (length === 0) return __('None'); + + const firstSelected = MR_FILTER_OPTIONS.find( + ({ value }) => this.mergeRequestFilters[0] === value, + ); + + if (length === MR_FILTER_OPTIONS.length) { + return __('All activity'); + } else if (length > 1) { + return `%{strongStart}${firstSelected.text}%{strongEnd} +${length - 1} more`; + } + + return firstSelected.text; + }, + isSortAsc() { + return this.discussionSortOrder === 'asc'; + }, + sortIcon() { + return this.isSortAsc ? 'sort-lowest' : 'sort-highest'; + }, + }, + methods: { + ...mapActions(['updateMergeRequestFilters', 'setDiscussionSortDirection']), + updateSortDirection() { + this.setDiscussionSortDirection({ + direction: this.isSortAsc ? 'desc' : 'asc', + }); + }, + applyFilters() { + this.updateMergeRequestFilters(this.selectedFilters); + }, + localSyncFilters(filters) { + this.updateMergeRequestFilters(filters); + this.selectedFilters = filters; + }, + }, + MR_FILTER_OPTIONS, +}; +</script> + +<template> + <div> + <local-storage-sync + :value="discussionSortOrder" + storage-key="sort_direction_merge_request" + as-string + @input="setDiscussionSortDirection({ direction: $event })" + /> + <local-storage-sync + :value="mergeRequestFilters" + storage-key="mr_activity_filters" + @input="localSyncFilters" + /> + <gl-button-group> + <gl-collapsible-listbox + v-model="selectedFilters" + :items="$options.MR_FILTER_OPTIONS" + multiple + placement="right" + @hidden="applyFilters" + > + <template #toggle> + <gl-button class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"> + <gl-sprintf :message="selectedFilterText"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + <gl-icon name="chevron-down" /> + </gl-button> + </template> + <template #list-item="{ item }"> + <strong v-if="item.value === '*'">{{ item.text }}</strong> + <span v-else>{{ item.text }}</span> + </template> + </gl-collapsible-listbox> + <gl-button :icon="sortIcon" @click="updateSortDirection" /> + </gl-button-group> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 7dc6b045b4d..5e776639a7a 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -73,6 +73,11 @@ export default { required: false, default: '', }, + emailParticipant: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -97,6 +102,11 @@ export default { hasAuthor() { return this.author && Object.keys(this.author).length; }, + isServiceDeskEmailParticipant() { + return ( + !this.isInternalNote && this.author.username === 'support-bot' && this.emailParticipant + ); + }, authorLinkClasses() { return { hover: this.isUsernameLinkHovered, @@ -108,7 +118,7 @@ export default { }; }, authorName() { - return this.author.name; + return this.isServiceDeskEmailParticipant ? this.emailParticipant : this.author.name; }, internalNoteTooltip() { return s__('Notes|This internal note will always remain confidential'); @@ -159,16 +169,27 @@ export default { </button> </div> <template v-if="hasAuthor"> + <span + v-if="emailParticipant" + class="note-header-author-name gl-font-weight-bold" + data-testid="author-name" + v-text="authorName" + ></span> <a + v-else ref="authorNameLink" :href="authorHref" :class="authorLinkClasses" :data-user-id="authorId" :data-username="author.username" > - <span class="note-header-author-name gl-font-weight-bold" v-text="authorName"></span> + <span + class="note-header-author-name gl-font-weight-bold" + data-testid="author-name" + v-text="authorName" + ></span> </a> - <span v-if="!isSystemNote" class="text-nowrap author-username"> + <span v-if="!isSystemNote && !emailParticipant" class="text-nowrap author-username"> <a ref="authorUsernameLink" class="author-username-link" @@ -180,6 +201,9 @@ export default { <slot name="note-header-info"></slot> <gitlab-team-member-badge v-if="author && author.is_gitlab_employee" /> </span> + <span v-if="emailParticipant" class="note-headline-light">{{ + __('(external participant)') + }}</span> </template> <span v-else>{{ __('A deleted user') }}</span> <span class="note-headline-light note-headline-meta"> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index ae2f94a5a80..5929e419247 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -477,6 +477,7 @@ export default { :note-id="note.id" :is-internal-note="note.internal" :noteable-type="noteableType" + :email-participant="note.external_author" > <template #note-header-info> <slot name="note-header-info"></slot> diff --git a/app/assets/javascripts/notes/components/notes_activity_header.vue b/app/assets/javascripts/notes/components/notes_activity_header.vue index 679c38d7721..a91c825710d 100644 --- a/app/assets/javascripts/notes/components/notes_activity_header.vue +++ b/app/assets/javascripts/notes/components/notes_activity_header.vue @@ -8,6 +8,7 @@ export default { DiscussionFilter, AiSummarizeNotes: () => import('ee_component/notes/components/note_actions/ai_summarize_notes.vue'), + MrDiscussionFilter: () => import('./mr_discussion_filter.vue'), }, mixins: [glFeatureFlagsMixin()], inject: { @@ -15,6 +16,9 @@ export default { default: false, }, resourceGlobalId: { default: null }, + mrFilter: { + default: false, + }, }, props: { notesFilters: { @@ -52,7 +56,8 @@ export default { :loading="aiLoading" /> <timeline-toggle v-if="showTimelineViewToggle" /> - <discussion-filter :filters="notesFilters" :selected-value="notesFilterValue" /> + <mr-discussion-filter v-if="mrFilter && glFeatures.mrActivityFilters" /> + <discussion-filter v-else :filters="notesFilters" :selected-value="notesFilterValue" /> </div> </div> </template> diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index 15eb4f95910..e7c3385ae5c 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -1,5 +1,5 @@ import { STATUS_CLOSED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; export const DISCUSSION_NOTE = 'DiscussionNote'; export const DIFF_NOTE = 'DiffNote'; @@ -56,3 +56,63 @@ export const toggleStateErrorMessage = { ), }, }; + +export const MR_FILTER_OPTIONS = [ + { + text: __('Approvals'), + value: 'approval', + systemNoteIcons: ['approval', 'unapproval'], + }, + { + text: __('Commits & branches'), + value: 'commit_branches', + systemNoteIcons: ['commit', 'fork'], + }, + { + text: __('Merge request status'), + value: 'status', + systemNoteIcons: ['git-merge', 'issue-close', 'issues'], + }, + { + text: __('Assignees & reviewers'), + value: 'assignees_reviewers', + noteText: [ + s__('IssuableEvents|requested review from'), + s__('IssuableEvents|removed review request for'), + s__('IssuableEvents|assigned to'), + s__('IssuableEvents|unassigned'), + ], + }, + { + text: __('Edits'), + value: 'edits', + systemNoteIcons: ['pencil', 'task-done'], + }, + { + text: __('Labels'), + value: 'labels', + systemNoteIcons: ['label'], + }, + { + text: __('Mentions'), + value: 'mentions', + systemNoteIcons: ['comment-dots'], + }, + { + text: __('Tracking'), + value: 'tracking', + noteType: ['MilestoneNote'], + systemNoteIcons: ['timer'], + }, + { + text: __('Comments'), + value: 'comments', + noteType: ['DiscussionNote', 'DiffNote'], + individualNote: true, + }, + { + text: __('Lock status'), + value: 'lock_status', + systemNoteIcons: ['lock', 'lock-open'], + }, +]; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index cdfa0d11f56..dc7f1577bbb 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -897,3 +897,6 @@ export const updateAssignees = ({ commit }, assignees) => { export const updateDiscussionPosition = ({ commit }, updatedPosition) => { commit(types.UPDATE_DISCUSSION_POSITION, updatedPosition); }; + +export const updateMergeRequestFilters = ({ commit }, newFilters) => + commit(types.SET_MERGE_REQUEST_FILTERS, newFilters); diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index f6373f24b74..3fb9913bdcb 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -22,10 +22,51 @@ const getDraftComments = (state) => { .sort((a, b) => a.id - b.id); }; +const hideActivity = (filters, discussion) => { + const firstNote = discussion.notes[0]; + + return constants.MR_FILTER_OPTIONS.some((f) => { + if (filters.includes(f.value) || f.value === '*') return false; + + if ( + // For all of the below firstNote is the first note of a discussion, whether that be + // the first in a discussion or a single note + // If the filter option filters based on icon check against the first notes system note icon + f.systemNoteIcons?.includes(firstNote.system_note_icon_name) || + // If the filter option filters based on note type user the first notes type + f.noteType?.includes(firstNote.type) || + // If the filter option filters based on the note text then check if it is sytem + // and filter based on the text of the system note + (firstNote.system && f.noteText?.some((t) => firstNote.note.includes(t))) || + // For individual notes we filter if the discussion is a single note and is not a sytem + (f.individualNote === discussion.individual_note && !firstNote.system) + ) { + return true; + } + + return false; + }); +}; + export const discussions = (state, getters, rootState) => { let discussionsInState = clone(state.discussions); // NOTE: not testing bc will be removed when backend is finished. + if ( + state.noteableData.targetType === 'merge_request' && + window.gon?.features?.mrActivityFilters + ) { + discussionsInState = discussionsInState.reduce((acc, discussion) => { + if (hideActivity(state.mergeRequestFilters, discussion)) { + return acc; + } + + acc.push(discussion); + + return acc; + }, []); + } + if (state.isTimelineEnabled) { discussionsInState = discussionsInState .reduce((acc, discussion) => { diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 81c4c42a49a..317fe6442d4 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -1,4 +1,4 @@ -import { ASC } from '../../constants'; +import { ASC, MR_FILTER_OPTIONS } from '../../constants'; import * as actions from '../actions'; import * as getters from '../getters'; import mutations from '../mutations'; @@ -51,6 +51,7 @@ export default () => ({ isTimelineEnabled: false, isFetching: false, isPollingInitialized: false, + mergeRequestFilters: MR_FILTER_OPTIONS.map((f) => f.value), }, actions, getters, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index bc1d5b5bba4..4008b40b57f 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -61,3 +61,5 @@ export const RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DELETE_DESCRIPT // Incidents export const SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS = 'SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS'; + +export const SET_MERGE_REQUEST_FILTERS = 'SET_MERGE_REQUEST_FILTERS'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 7a7aa0deb1d..c3407936847 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -432,4 +432,7 @@ export default { [types.SET_IS_POLLING_INITIALIZED](state, value) { state.isPollingInitialized = value; }, + [types.SET_MERGE_REQUEST_FILTERS](state, value) { + state.mergeRequestFilters = value; + }, }; 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 3f6a0643313..e34578e1f46 100644 --- a/app/assets/javascripts/work_items/components/work_item_notes.vue +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -97,7 +97,6 @@ export default { data() { return { isLoadingMore: false, - perPage: DEFAULT_PAGE_SIZE_NOTES, sortOrder: ASC, noteToDelete: null, discussionFilter: WORK_ITEM_NOTES_FILTER_ALL_NOTES, @@ -117,9 +116,6 @@ export default { hasNextPage() { return this.pageInfo?.hasNextPage; }, - showLoadingMoreSkeleton() { - return this.isLoadingMore && !this.changeNotesSortOrderAfterLoading; - }, disableActivityFilterSort() { return this.initialLoading || this.isLoadingMore; }, @@ -204,8 +200,6 @@ export default { this.$emit('error', i18n.fetchError); }, result() { - this.updateSortingOrderIfApplicable(); - if (this.hasNextPage) { this.fetchMoreNotes(); } else if (this.targetNoteHash) { @@ -268,17 +262,6 @@ export default { isSystemNote(note) { return note.notes.nodes[0].system; }, - updateSortingOrderIfApplicable() { - // when the sort order is DESC in local storage and there is only a single page, call - // changeSortOrder manually - if ( - this.changeNotesSortOrderAfterLoading && - this.perPage === DEFAULT_PAGE_SIZE_NOTES && - !this.hasNextPage - ) { - this.changeNotesSortOrder(DESC); - } - }, changeNotesSortOrder(direction) { this.sortOrder = direction; }, @@ -293,14 +276,10 @@ export default { }, async fetchMoreNotes() { this.isLoadingMore = true; - // copied from discussions batch logic - every fetchMore call has a higher - // amount of page size than the previous one with the limit being 100 - this.perPage = Math.min(Math.round(this.perPage * 1.5), 100); await this.$apollo.queries.workItemNotes .fetchMore({ variables: { ...this.queryVariables, - pageSize: this.perPage, after: this.pageInfo?.endCursor, }, }) @@ -429,7 +408,7 @@ export default { </div> </template> - <template v-if="showLoadingMoreSkeleton"> + <template v-if="isLoadingMore"> <gl-skeleton-loader v-for="index in $options.loader.repeat" :key="index" diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 89b7767aa40..d967aa89eb7 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -52,6 +52,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:hide_create_issue_resolve_all, project) push_frontend_feature_flag(:auto_merge_labels_mr_widget, project) push_frontend_feature_flag(:summarize_my_code_review, current_user) + push_frontend_feature_flag(:mr_activity_filters, current_user) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :diffs, :discussions] diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb index f5c4ec0c3b3..964a862d415 100644 --- a/app/models/concerns/protected_ref_access.rb +++ b/app/models/concerns/protected_ref_access.rb @@ -47,7 +47,6 @@ module ProtectedRefAccess def check_access(user) return false unless user - return true if user.admin? user.can?(:push_code, project) && project.team.max_member_access(user.id) >= access_level diff --git a/app/models/note.rb b/app/models/note.rb index d2f2a71b027..597ba767a11 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -87,6 +87,7 @@ class Note < ApplicationRecord inverse_of: :note, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_one :system_note_metadata + has_one :note_metadata, inverse_of: :note, class_name: 'Notes::NoteMetadata' has_one :note_diff_file, inverse_of: :diff_note, foreign_key: :diff_note_id has_many :diff_note_positions @@ -95,6 +96,8 @@ class Note < ApplicationRecord delegate :name, :email, to: :author, prefix: true delegate :title, to: :noteable, allow_nil: true + accepts_nested_attributes_for :note_metadata + validates :note, presence: true validates :note, length: { maximum: Gitlab::Database::MAX_TEXT_SIZE_LIMIT } validates :project, presence: true, if: :for_project_noteable? diff --git a/app/models/notes/note_metadata.rb b/app/models/notes/note_metadata.rb new file mode 100644 index 00000000000..96e0917734b --- /dev/null +++ b/app/models/notes/note_metadata.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Notes + class NoteMetadata < ApplicationRecord + self.table_name = :note_metadata + + belongs_to :note, inverse_of: :note_metadata + validates :email_participant, length: { maximum: 255 } + + alias_attribute :external_author, :email_participant + end +end diff --git a/app/models/packages/dependency.rb b/app/models/packages/dependency.rb index ad3944b5f21..c39b46dcc20 100644 --- a/app/models/packages/dependency.rb +++ b/app/models/packages/dependency.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true class Packages::Dependency < ApplicationRecord + include EachBatch + has_many :dependency_links, class_name: 'Packages::DependencyLink' validates :name, :version_pattern, presence: true @@ -41,6 +43,11 @@ class Packages::Dependency < ApplicationRecord pluck(:id, :name) end + def self.orphaned + subquery = Packages::DependencyLink.where(Packages::DependencyLink.arel_table[:dependency_id].eq(Packages::Dependency.arel_table[:id])) + where_not_exists(subquery) + end + def orphaned? self.dependency_links.empty? end diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index 8a3449e8f7c..580e4cd277c 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -103,9 +103,18 @@ class SentNotification < ApplicationRecord self.reply_key end - def create_reply(message, dryrun: false) + def create_reply(message, external_author = nil, dryrun: false) klass = dryrun ? Notes::BuildService : Notes::CreateService - klass.new(self.project, self.recipient, reply_params.merge(note: message)).execute + params = reply_params.merge( + note: message + ) + + params[:external_author] = external_author if external_author.present? + + klass.new(self.project, + self.recipient, + params + ).execute end private diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index fc154e6b465..73e4cbee54a 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -81,7 +81,7 @@ module Ci # Authorizing the user to access to protected entities. # There is a "jailbreak" mode to exceptionally bypass the authorization, # however, you should NEVER allow it, rather suspect it's a wrong feature/product design. - rule { ~can?(:jailbreak) & (archived | protected_ref | protected_environment) }.policy do + rule { ~can?(:jailbreak) & (archived | (protected_ref & ~admin) | protected_environment) }.policy do prevent :update_build prevent :update_commit_status prevent :erase_build diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index 679f829e852..e80b3be98bd 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -12,6 +12,8 @@ class NoteEntity < API::Entities::Note expose :type + expose :external_author + expose :author, using: NoteUserEntity unexpose :note, as: :body @@ -105,6 +107,18 @@ class NoteEntity < API::Entities::Note def with_base_discussion? options.fetch(:with_base_discussion, true) end + + def external_author + return unless Feature.enabled?(:external_note_author_service_desk, type: :ops) + + return unless object.note_metadata&.external_author + + if can?(current_user, :read_external_emails, object.project) + object.note_metadata.external_author + else + Gitlab::Utils::Email.obfuscated_email(object.note_metadata.external_author, deform: true) + end + end end NoteEntity.prepend_mod_with('NoteEntity') diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb index e6766273441..91993700e25 100644 --- a/app/services/notes/build_service.rb +++ b/app/services/notes/build_service.rb @@ -4,8 +4,15 @@ module Notes class BuildService < ::BaseService def execute in_reply_to_discussion_id = params.delete(:in_reply_to_discussion_id) + external_author = params.delete(:external_author) + discussion = nil + if external_author.present? + note_metadata = Notes::NoteMetadata.new(email_participant: external_author) + params[:note_metadata] = note_metadata + end + if in_reply_to_discussion_id.present? discussion = find_discussion(in_reply_to_discussion_id) diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 77b6bf573df..8d100f8b456 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -570,6 +570,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: cronjob:packages_cleanup_delete_orphaned_dependencies + :worker_name: Packages::Cleanup::DeleteOrphanedDependenciesWorker + :feature_category: :package_registry + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:packages_cleanup_package_registry :worker_name: Packages::CleanupPackageRegistryWorker :feature_category: :package_registry diff --git a/app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb b/app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb new file mode 100644 index 00000000000..0b3d3c98742 --- /dev/null +++ b/app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Packages + module Cleanup + class DeleteOrphanedDependenciesWorker + include ApplicationWorker + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + data_consistency :sticky + feature_category :package_registry + urgency :low + idempotent! + + # This cron worker is executed at an interval of 10 minutes and should not run for + # more than 2 minutes nor process more than 10 batches. + MAX_RUN_TIME = 2.minutes + MAX_BATCHES = 10 + BATCH_SIZE = 100 + LAST_PROCESSED_PACKAGES_DEPENDENCY_REDIS_KEY = 'last_processed_packages_dependency_id' + REDIS_EXPIRATION_TIME = 2.hours.to_i + + def perform + return unless enabled? + + start_time + + dependency_id = last_processed_dependency_id + batches_count = 0 + deleted_rows_count = 0 + + ::Packages::Dependency.id_in(dependency_id..).each_batch(of: BATCH_SIZE) do |batch| + batches_count += 1 + deleted_rows_count += batch.orphaned.delete_all + + if batches_count == MAX_BATCHES || over_time? + save_last_processed_dependency_id(batch.maximum(:id)) + break + end + end + + log_extra_metadata(deleted_rows_count) + reset_last_processed_dependency_id if batches_count < MAX_BATCHES && !over_time? + end + + private + + def enabled? + Feature.enabled?(:packages_delete_orphaned_dependencies_worker) + end + + def start_time + @start_time ||= ::Gitlab::Metrics::System.monotonic_time + end + + def over_time? + (::Gitlab::Metrics::System.monotonic_time - start_time) > MAX_RUN_TIME + end + + def save_last_processed_dependency_id(dependency_id) + with_redis do |redis| + redis.set(LAST_PROCESSED_PACKAGES_DEPENDENCY_REDIS_KEY, dependency_id, ex: REDIS_EXPIRATION_TIME) + end + end + + def last_processed_dependency_id + with_redis do |redis| + redis.get(LAST_PROCESSED_PACKAGES_DEPENDENCY_REDIS_KEY).to_i + end + end + + def reset_last_processed_dependency_id + with_redis do |redis| + redis.del(LAST_PROCESSED_PACKAGES_DEPENDENCY_REDIS_KEY) + end + end + + def with_redis(&block) + Gitlab::Redis::SharedState.with(&block) # rubocop:disable CodeReuse/ActiveRecord + end + + def log_extra_metadata(deleted_rows_count) + log_extra_metadata_on_done(:last_processed_packages_dependency_id, last_processed_dependency_id) + log_extra_metadata_on_done(:deleted_rows_count, deleted_rows_count) + end + end + end +end |