summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-05-03 15:12:58 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2023-05-03 15:12:58 +0000
commit27a5080c34c64a84219d855d652b994c5e344a0a (patch)
tree1f6bcb68378e4965b4e93a846d8a939af18aeec6 /app
parent2c01907a1ab4b328e2f20ddf9e10dfe6dc17105a (diff)
downloadgitlab-ce-27a5080c34c64a84219d855d652b994c5e344a0a.tar.gz
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/emoji/awards_app/store/actions.js6
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js1
-rw-r--r--app/assets/javascripts/notes/components/mr_discussion_filter.vue109
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue30
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue1
-rw-r--r--app/assets/javascripts/notes/components/notes_activity_header.vue7
-rw-r--r--app/assets/javascripts/notes/constants.js62
-rw-r--r--app/assets/javascripts/notes/stores/actions.js3
-rw-r--r--app/assets/javascripts/notes/stores/getters.js41
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js3
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js3
-rw-r--r--app/assets/javascripts/work_items/components/work_item_notes.vue23
-rw-r--r--app/controllers/projects/merge_requests_controller.rb1
-rw-r--r--app/models/concerns/protected_ref_access.rb1
-rw-r--r--app/models/note.rb3
-rw-r--r--app/models/notes/note_metadata.rb12
-rw-r--r--app/models/packages/dependency.rb7
-rw-r--r--app/models/sent_notification.rb13
-rw-r--r--app/policies/ci/build_policy.rb2
-rw-r--r--app/serializers/note_entity.rb14
-rw-r--r--app/services/notes/build_service.rb7
-rw-r--r--app/workers/all_queues.yml9
-rw-r--r--app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb87
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