Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
b3bdfebdb7
commit
a4c466dd6f
2
Gemfile
2
Gemfile
|
|
@ -89,7 +89,7 @@ gem 'coverband', '6.1.4', require: false, feature_category: :shared
|
|||
gem 'devise', '~> 4.9.3', feature_category: :system_access
|
||||
gem 'devise-pbkdf2-encryptable', '~> 0.0.0', path: 'vendor/gems/devise-pbkdf2-encryptable' # rubocop:todo Gemfile/MissingFeatureCategory
|
||||
gem 'bcrypt', '~> 3.1', '>= 3.1.14' # rubocop:todo Gemfile/MissingFeatureCategory
|
||||
gem 'doorkeeper', '~> 5.8', '>= 5.8.0', feature_category: :system_access
|
||||
gem 'doorkeeper', '~> 5.8', '>= 5.8.1', feature_category: :system_access
|
||||
gem 'doorkeeper-openid_connect', '~> 1.8.10', feature_category: :system_access
|
||||
gem 'doorkeeper-device_authorization_grant', '~> 1.0.0', feature_category: :system_access
|
||||
gem 'rexml', '~> 3.3.2' # rubocop:todo Gemfile/MissingFeatureCategory
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@
|
|||
{"name":"discordrb-webhooks","version":"3.5.0","platform":"ruby","checksum":"52fba8bce3b08059d4a41a1e73a9a152958e788a9330275450126b44f01c23b1"},
|
||||
{"name":"docile","version":"1.4.0","platform":"ruby","checksum":"5f1734bde23721245c20c3d723e76c104208e1aa01277a69901ce770f0ebb8d3"},
|
||||
{"name":"domain_name","version":"0.5.20190701","platform":"ruby","checksum":"000a600454cb4a344769b2f10b531765ea7bd3a304fe47ed12e5ca1eab969851"},
|
||||
{"name":"doorkeeper","version":"5.8.0","platform":"ruby","checksum":"5abc747ee0eaa3140e165b41e937941f05d5ce5bcd8b8ed6718e6711623a8105"},
|
||||
{"name":"doorkeeper","version":"5.8.1","platform":"ruby","checksum":"6d54f3c36755d8cfcb7e4f04fbcf1ff3492c816090ad78126ec8a722c292d26c"},
|
||||
{"name":"doorkeeper-device_authorization_grant","version":"1.0.3","platform":"ruby","checksum":"94c3ac12a0d50942850ecd58ed64298b397a5e903e8880cb68d4085600932679"},
|
||||
{"name":"doorkeeper-openid_connect","version":"1.8.10","platform":"ruby","checksum":"b28efaa9b52cbe9aca4efc825a7ef63cc950ae6a96f4a72ed8362f278a453742"},
|
||||
{"name":"dotenv","version":"2.7.6","platform":"ruby","checksum":"2451ed5e8e43776d7a787e51d6f8903b98e446146c7ad143d5678cc2c409d547"},
|
||||
|
|
|
|||
|
|
@ -535,7 +535,7 @@ GEM
|
|||
docile (1.4.0)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
doorkeeper (5.8.0)
|
||||
doorkeeper (5.8.1)
|
||||
railties (>= 5)
|
||||
doorkeeper-device_authorization_grant (1.0.3)
|
||||
doorkeeper (~> 5.5)
|
||||
|
|
@ -2032,7 +2032,7 @@ DEPENDENCIES
|
|||
diff_match_patch (~> 0.1.0)!
|
||||
diffy (~> 3.4)
|
||||
discordrb-webhooks (~> 3.5)
|
||||
doorkeeper (~> 5.8, >= 5.8.0)
|
||||
doorkeeper (~> 5.8, >= 5.8.1)
|
||||
doorkeeper-device_authorization_grant (~> 1.0.0)
|
||||
doorkeeper-openid_connect (~> 1.8.10)
|
||||
duo_api (~> 1.3)
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@
|
|||
{"name":"discordrb-webhooks","version":"3.5.0","platform":"ruby","checksum":"52fba8bce3b08059d4a41a1e73a9a152958e788a9330275450126b44f01c23b1"},
|
||||
{"name":"docile","version":"1.4.0","platform":"ruby","checksum":"5f1734bde23721245c20c3d723e76c104208e1aa01277a69901ce770f0ebb8d3"},
|
||||
{"name":"domain_name","version":"0.5.20190701","platform":"ruby","checksum":"000a600454cb4a344769b2f10b531765ea7bd3a304fe47ed12e5ca1eab969851"},
|
||||
{"name":"doorkeeper","version":"5.8.0","platform":"ruby","checksum":"5abc747ee0eaa3140e165b41e937941f05d5ce5bcd8b8ed6718e6711623a8105"},
|
||||
{"name":"doorkeeper","version":"5.8.1","platform":"ruby","checksum":"6d54f3c36755d8cfcb7e4f04fbcf1ff3492c816090ad78126ec8a722c292d26c"},
|
||||
{"name":"doorkeeper-device_authorization_grant","version":"1.0.3","platform":"ruby","checksum":"94c3ac12a0d50942850ecd58ed64298b397a5e903e8880cb68d4085600932679"},
|
||||
{"name":"doorkeeper-openid_connect","version":"1.8.10","platform":"ruby","checksum":"b28efaa9b52cbe9aca4efc825a7ef63cc950ae6a96f4a72ed8362f278a453742"},
|
||||
{"name":"dotenv","version":"2.7.6","platform":"ruby","checksum":"2451ed5e8e43776d7a787e51d6f8903b98e446146c7ad143d5678cc2c409d547"},
|
||||
|
|
|
|||
|
|
@ -544,7 +544,7 @@ GEM
|
|||
docile (1.4.0)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
doorkeeper (5.8.0)
|
||||
doorkeeper (5.8.1)
|
||||
railties (>= 5)
|
||||
doorkeeper-device_authorization_grant (1.0.3)
|
||||
doorkeeper (~> 5.5)
|
||||
|
|
@ -2060,7 +2060,7 @@ DEPENDENCIES
|
|||
diff_match_patch (~> 0.1.0)!
|
||||
diffy (~> 3.4)
|
||||
discordrb-webhooks (~> 3.5)
|
||||
doorkeeper (~> 5.8, >= 5.8.0)
|
||||
doorkeeper (~> 5.8, >= 5.8.1)
|
||||
doorkeeper-device_authorization_grant (~> 1.0.0)
|
||||
doorkeeper-openid_connect (~> 1.8.10)
|
||||
duo_api (~> 1.3)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
|
|||
import { keysFor, ISSUABLE_EDIT_DESCRIPTION } from '~/behaviors/shortcuts/keybindings';
|
||||
import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle';
|
||||
import { sanitize } from '~/lib/dompurify';
|
||||
import { STATUS_CLOSED, TYPE_ISSUE, issuableTypeText } from '~/issues/constants';
|
||||
import { STATUS_CLOSED, TYPE_ISSUE, TYPE_INCIDENT, issuableTypeText } from '~/issues/constants';
|
||||
import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants';
|
||||
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
|
||||
import { isLoggedIn } from '~/lib/utils/common_utils';
|
||||
|
|
@ -35,6 +35,7 @@ import issuesEventHub from '../event_hub';
|
|||
import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql';
|
||||
import updateIssueMutation from '../queries/update_issue.mutation.graphql';
|
||||
import DeleteIssueModal from './delete_issue_modal.vue';
|
||||
import HeaderActionsConfidentialityToggle from './header_actions_confidentiality_toggle.vue';
|
||||
|
||||
const trackingMixin = Tracking.mixin({ label: 'delete_issue' });
|
||||
|
||||
|
|
@ -70,6 +71,7 @@ export default {
|
|||
AbuseCategorySelector,
|
||||
SidebarSubscriptionsWidget,
|
||||
IssuableLockForm,
|
||||
HeaderActionsConfidentialityToggle,
|
||||
},
|
||||
directives: {
|
||||
GlModal: GlModalDirective,
|
||||
|
|
@ -216,6 +218,9 @@ export default {
|
|||
},
|
||||
};
|
||||
},
|
||||
showConfidentialityToggle() {
|
||||
return [TYPE_ISSUE, TYPE_INCIDENT].includes(this.issueType) && this.canUpdateIssue;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
eventHub.$on('toggle.issuable.state', this.toggleIssueState);
|
||||
|
|
@ -378,6 +383,10 @@ export default {
|
|||
<template v-if="showLockIssueOption">
|
||||
<issuable-lock-form :is-editable="false" data-testid="lock-issue-toggle" />
|
||||
</template>
|
||||
<header-actions-confidentiality-toggle
|
||||
v-if="showConfidentialityToggle"
|
||||
@closeActionsDropdown="closeActionsDropdown"
|
||||
/>
|
||||
<gl-disclosure-dropdown-item
|
||||
:data-clipboard-text="issuableReference"
|
||||
class="js-copy-reference"
|
||||
|
|
@ -478,6 +487,10 @@ export default {
|
|||
<template v-if="showLockIssueOption">
|
||||
<issuable-lock-form :is-editable="false" data-testid="lock-issue-toggle" />
|
||||
</template>
|
||||
<header-actions-confidentiality-toggle
|
||||
v-if="showConfidentialityToggle"
|
||||
@closeActionsDropdown="closeActionsDropdown"
|
||||
/>
|
||||
<gl-disclosure-dropdown-item
|
||||
:data-clipboard-text="issuableReference"
|
||||
class="js-copy-reference"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
<script>
|
||||
import { GlDisclosureDropdownItem } from '@gitlab/ui';
|
||||
|
||||
import { createAlert } from '~/alert';
|
||||
import { s__, __, sprintf } from '~/locale';
|
||||
|
||||
import { confidentialityQueries } from '~/sidebar/queries/constants';
|
||||
import { issuableTypeText } from '~/issues/constants';
|
||||
|
||||
export default {
|
||||
i18n: {
|
||||
enableConfidentiality: s__('WorkItem|Turn on confidentiality'),
|
||||
confidentialityEnabled: s__('WorkItem|Confidentiality turned on.'),
|
||||
disableConfidentiality: s__('WorkItem|Turn off confidentiality'),
|
||||
confidentialityDisabled: s__('WorkItem|Confidentiality turned off.'),
|
||||
},
|
||||
components: {
|
||||
GlDisclosureDropdownItem,
|
||||
},
|
||||
inject: ['iid', 'issueType', 'projectPath', 'fullPath'],
|
||||
data() {
|
||||
return {
|
||||
confidential: false,
|
||||
};
|
||||
},
|
||||
apollo: {
|
||||
confidential: {
|
||||
query() {
|
||||
return confidentialityQueries[this.issueType].query;
|
||||
},
|
||||
variables() {
|
||||
return {
|
||||
fullPath: this.fullPath,
|
||||
iid: String(this.iid),
|
||||
};
|
||||
},
|
||||
update(data) {
|
||||
return data.workspace?.issuable?.confidential || false;
|
||||
},
|
||||
skip() {
|
||||
return !this.iid;
|
||||
},
|
||||
error() {
|
||||
createAlert({
|
||||
message: sprintf(
|
||||
__('Something went wrong while getting %{issuableType} confidentiality status.'),
|
||||
{
|
||||
issuableType: this.issueTypeText,
|
||||
},
|
||||
),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
issueTypeText() {
|
||||
const { issueType } = this;
|
||||
|
||||
return issuableTypeText[issueType] ?? issueType;
|
||||
},
|
||||
confidentialItem() {
|
||||
return {
|
||||
text: this.confidential
|
||||
? this.$options.i18n.disableConfidentiality
|
||||
: this.$options.i18n.enableConfidentiality,
|
||||
};
|
||||
},
|
||||
confidentialityText() {
|
||||
return this.confidential
|
||||
? this.$options.i18n.confidentialityEnabled
|
||||
: this.$options.i18n.confidentialityDisabled;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleToggleWorkItemConfidentiality() {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: confidentialityQueries[this.issueType].mutation,
|
||||
variables: {
|
||||
input: {
|
||||
iid: String(this.iid),
|
||||
projectPath: this.projectPath,
|
||||
confidential: !this.confidential,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(
|
||||
({
|
||||
data: {
|
||||
issuableSetConfidential: { errors },
|
||||
},
|
||||
}) => {
|
||||
if (errors.length) {
|
||||
createAlert({
|
||||
message: errors[0],
|
||||
});
|
||||
} else {
|
||||
this.$emit('closeActionsDropdown');
|
||||
this.$toast.show(this.confidentialityText);
|
||||
}
|
||||
},
|
||||
)
|
||||
.catch(() => {
|
||||
createAlert({
|
||||
message: sprintf(
|
||||
__('Something went wrong while setting %{issuableType} confidentiality.'),
|
||||
{
|
||||
issuableType: this.issueTypeText,
|
||||
},
|
||||
),
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<gl-disclosure-dropdown-item
|
||||
:item="confidentialItem"
|
||||
data-testid="confidentiality-toggle-action"
|
||||
@action="handleToggleWorkItemConfidentiality"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -16,7 +16,6 @@ import Translate from '~/vue_shared/translate';
|
|||
import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
|
||||
import CollapsedAssigneeList from './components/assignees/collapsed_assignee_list.vue';
|
||||
import SidebarAssigneesWidget from './components/assignees/sidebar_assignees_widget.vue';
|
||||
import SidebarConfidentialityWidget from './components/confidential/sidebar_confidentiality_widget.vue';
|
||||
import CopyEmailToClipboard from './components/copy/copy_email_to_clipboard.vue';
|
||||
import SidebarDueDateWidget from './components/date/sidebar_date_widget.vue';
|
||||
import SidebarEscalationStatus from './components/incidents/sidebar_escalation_status.vue';
|
||||
|
|
@ -327,39 +326,6 @@ export function mountSidebarLabelsWidget() {
|
|||
});
|
||||
}
|
||||
|
||||
function mountSidebarConfidentialityWidget() {
|
||||
const el = document.querySelector('.js-sidebar-confidential-widget-root');
|
||||
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { fullPath, iid } = getSidebarOptions();
|
||||
const dataNode = document.getElementById('js-confidential-issue-data');
|
||||
const initialData = JSON.parse(dataNode.innerHTML);
|
||||
|
||||
return new Vue({
|
||||
el,
|
||||
name: 'SidebarConfidentialityWidgetRoot',
|
||||
apolloProvider,
|
||||
provide: {
|
||||
canUpdate: initialData.is_editable,
|
||||
isClassicSidebar: true,
|
||||
},
|
||||
render: (createElement) =>
|
||||
createElement(SidebarConfidentialityWidget, {
|
||||
props: {
|
||||
iid: String(iid),
|
||||
fullPath,
|
||||
issuableType:
|
||||
isInIssuePage() || isInIncidentPage() || isInDesignPage()
|
||||
? TYPE_ISSUE
|
||||
: TYPE_MERGE_REQUEST,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function mountSidebarDueDateWidget() {
|
||||
const el = document.querySelector('.js-sidebar-due-date-widget-root');
|
||||
|
||||
|
|
@ -762,7 +728,6 @@ export function mountSidebar(mediator, store) {
|
|||
mountSidebarCrmContacts();
|
||||
mountSidebarLabelsWidget();
|
||||
mountSidebarMilestoneWidget();
|
||||
mountSidebarConfidentialityWidget();
|
||||
mountSidebarDueDateWidget();
|
||||
mountSidebarReferenceWidget();
|
||||
mountIssuableLockForm(store);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import issuableDatesUpdatedSubscription from '~/graphql_shared/subscriptions/wor
|
|||
import {
|
||||
TYPE_ALERT,
|
||||
TYPE_EPIC,
|
||||
TYPE_INCIDENT,
|
||||
TYPE_ISSUE,
|
||||
TYPE_MERGE_REQUEST,
|
||||
TYPE_TEST_CASE,
|
||||
|
|
@ -115,6 +116,10 @@ export const userSearchQueries = {
|
|||
};
|
||||
|
||||
export const confidentialityQueries = {
|
||||
[TYPE_INCIDENT]: {
|
||||
query: issueConfidentialQuery,
|
||||
mutation: updateIssueConfidentialMutation,
|
||||
},
|
||||
[TYPE_ISSUE]: {
|
||||
query: issueConfidentialQuery,
|
||||
mutation: updateIssueConfidentialMutation,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
|
||||
import { availableGraphQLProjectActions } from '~/vue_shared/components/projects_list/utils';
|
||||
import { availableGraphQLProjectActions } from 'ee_else_ce/vue_shared/components/projects_list/utils';
|
||||
|
||||
export const formatGraphQLProjects = (projects) =>
|
||||
projects.map(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<script>
|
||||
import ListActions from '~/vue_shared/components/list_actions/list_actions.vue';
|
||||
import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
|
||||
|
||||
export default {
|
||||
name: 'ProjectListItemActionsCE',
|
||||
components: {
|
||||
ListActions,
|
||||
},
|
||||
props: {
|
||||
project: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
actions() {
|
||||
return {
|
||||
[ACTION_EDIT]: {
|
||||
href: this.project.editPath,
|
||||
},
|
||||
[ACTION_DELETE]: {
|
||||
action: this.onActionDelete,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onActionDelete() {
|
||||
this.$emit('delete');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<list-actions :actions="actions" :available-actions="project.availableActions" />
|
||||
</template>
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
deleteParams,
|
||||
} from 'ee_else_ce/vue_shared/components/resource_lists/utils';
|
||||
import ProjectListItemDescription from 'ee_else_ce/vue_shared/components/projects_list/project_list_item_description.vue';
|
||||
import ProjectListItemActions from 'ee_else_ce/vue_shared/components/projects_list/project_list_item_actions.vue';
|
||||
import ProjectListItemInactiveBadge from 'ee_else_ce/vue_shared/components/projects_list/project_list_item_inactive_badge.vue';
|
||||
import { VISIBILITY_TYPE_ICON, PROJECT_VISIBILITY_TYPE } from '~/visibility_level/constants';
|
||||
import { ACCESS_LEVEL_LABELS, ACCESS_LEVEL_NO_ACCESS_INTEGER } from '~/access_level/constants';
|
||||
|
|
@ -23,8 +24,7 @@ import { __, s__ } from '~/locale';
|
|||
import { numberToMetricPrefix } from '~/lib/utils/number_utils';
|
||||
import { truncate } from '~/lib/utils/text_utility';
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import ListActions from '~/vue_shared/components/list_actions/list_actions.vue';
|
||||
import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
|
||||
import { ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
|
||||
import DeleteModal from '~/projects/components/shared/delete_modal.vue';
|
||||
import {
|
||||
TIMESTAMP_TYPE_CREATED_AT,
|
||||
|
|
@ -47,7 +47,6 @@ export default {
|
|||
moreTopics: __('More topics'),
|
||||
[TIMESTAMP_TYPE_CREATED_AT]: __('Created'),
|
||||
[TIMESTAMP_TYPE_UPDATED_AT]: __('Updated'),
|
||||
actions: __('Actions'),
|
||||
project: __('Project'),
|
||||
deleteErrorMessage: s__(
|
||||
'Projects|An error occurred deleting the project. Please refresh the page to try again.',
|
||||
|
|
@ -63,8 +62,8 @@ export default {
|
|||
GlSprintf,
|
||||
TimeAgoTooltip,
|
||||
DeleteModal,
|
||||
ListActions,
|
||||
ProjectListItemDescription,
|
||||
ProjectListItemActions,
|
||||
ProjectListItemInactiveBadge,
|
||||
ProjectListItemDelayedDeletionModalFooter: () =>
|
||||
import(
|
||||
|
|
@ -207,16 +206,6 @@ export default {
|
|||
|
||||
return numberToMetricPrefix(this.project.openIssuesCount);
|
||||
},
|
||||
actions() {
|
||||
return {
|
||||
[ACTION_EDIT]: {
|
||||
href: this.project.editPath,
|
||||
},
|
||||
[ACTION_DELETE]: {
|
||||
action: this.onActionDelete,
|
||||
},
|
||||
};
|
||||
},
|
||||
hasActions() {
|
||||
return this.project.availableActions?.length;
|
||||
},
|
||||
|
|
@ -407,11 +396,11 @@ export default {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gl-ml-3 gl-flex gl-h-9 gl-items-center">
|
||||
<list-actions
|
||||
v-if="hasActions"
|
||||
:actions="actions"
|
||||
:available-actions="project.availableActions"
|
||||
<div v-if="hasActions" class="gl-ml-3 gl-flex gl-h-9 gl-items-center">
|
||||
<project-list-item-actions
|
||||
:project="project"
|
||||
@refetch="$emit('refetch')"
|
||||
@delete="onActionDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,10 @@ module Types
|
|||
description: 'Number of upvotes for the merge request.',
|
||||
resolver: Resolvers::UpVotesCountResolver
|
||||
|
||||
field :resolvable_discussions_count, GraphQL::Types::Int, null: true,
|
||||
description: 'Number of user discussions that are resolvable in the merge request.'
|
||||
field :resolved_discussions_count, GraphQL::Types::Int, null: true,
|
||||
description: 'Number of user discussions that are resolved in the merge request.'
|
||||
field :user_discussions_count, GraphQL::Types::Int, null: true,
|
||||
description: 'Number of user discussions in the merge request.',
|
||||
resolver: Resolvers::UserDiscussionsCountResolver
|
||||
|
|
@ -300,30 +304,6 @@ module Types
|
|||
markdown_field :title_html, null: true
|
||||
markdown_field :description_html, null: true
|
||||
|
||||
def user_notes_count
|
||||
BatchLoader::GraphQL.for(object.id).batch(key: :merge_request_user_notes_count) do |ids, loader, args|
|
||||
counts = Note.count_for_collection(ids, 'MergeRequest').index_by(&:noteable_id)
|
||||
|
||||
ids.each do |id|
|
||||
loader.call(id, counts[id]&.count || 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def user_discussions_count
|
||||
BatchLoader::GraphQL.for(object.id).batch(key: :merge_request_user_discussions_count) do |ids, loader, args|
|
||||
counts = Note.count_for_collection(
|
||||
ids,
|
||||
'MergeRequest',
|
||||
'COUNT(DISTINCT discussion_id) as count'
|
||||
).index_by(&:noteable_id)
|
||||
|
||||
ids.each do |id|
|
||||
loader.call(id, counts[id]&.count || 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def diff_stats(path: nil)
|
||||
stats = Array.wrap(object.diff_stats&.to_a)
|
||||
|
||||
|
|
@ -396,6 +376,29 @@ module Types
|
|||
def web_path
|
||||
::Gitlab::Routing.url_helpers.project_merge_request_path(object.project, object)
|
||||
end
|
||||
|
||||
def resolvable_discussions_count
|
||||
notes_count_for_collection(:merge_request_resolvable_discussions_count, &:resolvable)
|
||||
end
|
||||
|
||||
def resolved_discussions_count
|
||||
notes_count_for_collection(:merge_request_resolved_discussions_count, &:resolved)
|
||||
end
|
||||
|
||||
def notes_count_for_collection(key)
|
||||
BatchLoader::GraphQL.for(object.id).batch(key: key) do |ids, loader, args|
|
||||
counts = Note.count_for_collection(
|
||||
ids,
|
||||
'MergeRequest',
|
||||
'COUNT(DISTINCT discussion_id) as count'
|
||||
)
|
||||
counts = yield(counts).index_by(&:noteable_id)
|
||||
|
||||
ids.each do |id|
|
||||
loader.call(id, counts[id]&.count || 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ module Ci
|
|||
module JobToken
|
||||
class Authorization < Ci::ApplicationRecord
|
||||
extend Gitlab::InternalEventsTracking
|
||||
include EachBatch
|
||||
|
||||
self.table_name = 'ci_job_token_authorizations'
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Ci
|
||||
module JobToken
|
||||
class AuthorizationsCompactor
|
||||
attr_reader :allowlist_groups, :allowlist_projects
|
||||
|
||||
UnexpectedCompactionEntry = Class.new(StandardError)
|
||||
RedundantCompactionEntry = Class.new(StandardError)
|
||||
|
||||
def initialize(project_id)
|
||||
@project_id = project_id
|
||||
@allowlist_groups = []
|
||||
@allowlist_projects = []
|
||||
end
|
||||
|
||||
def origin_project_traversal_ids
|
||||
@origin_project_traversal_ids ||= begin
|
||||
origin_project_traversal_ids = []
|
||||
origin_project_id_batches = []
|
||||
|
||||
# Collecting id batches to avoid cross-database transactions.
|
||||
Ci::JobToken::Authorization.where(
|
||||
accessed_project_id: @project_id
|
||||
).each_batch(column: :origin_project_id) do |batch|
|
||||
origin_project_id_batches << batch.pluck(:origin_project_id) # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- pluck limited by batch size
|
||||
end
|
||||
|
||||
origin_project_id_batches.each do |batch|
|
||||
projects = Project.where(id: batch)
|
||||
origin_project_traversal_ids += projects.map { |p| p.project_namespace.traversal_ids }
|
||||
end
|
||||
|
||||
origin_project_traversal_ids
|
||||
end
|
||||
end
|
||||
|
||||
def compact(limit)
|
||||
compacted_traversal_ids = Gitlab::Utils::TraversalIdCompactor.compact(origin_project_traversal_ids, limit)
|
||||
|
||||
Gitlab::Utils::TraversalIdCompactor.validate!(origin_project_traversal_ids, compacted_traversal_ids)
|
||||
|
||||
namespace_ids = compacted_traversal_ids.map(&:last)
|
||||
namespaces = Namespace.where(id: namespace_ids)
|
||||
|
||||
namespaces.each do |namespace|
|
||||
if namespace.project_namespace?
|
||||
@allowlist_projects << namespace.project
|
||||
else
|
||||
@allowlist_groups << namespace
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -73,7 +73,14 @@
|
|||
- if issuable_sidebar.has_key?(:confidential)
|
||||
-# haml-lint:disable InlineJavaScript
|
||||
%script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe
|
||||
.js-sidebar-confidential-widget-root
|
||||
|
||||
-# REMOVE BLOCK - cf. https://gitlab.com/gitlab-org/gitlab/-/merge_requests/172577
|
||||
.block
|
||||
.hide-collapsed.gl-flex.gl-items-center.gl-font-bold.gl-leading-20.gl-text-default
|
||||
= _('Confidentiality')
|
||||
.hide-collapsed.gl-text-subtle
|
||||
= _("Confidentiality controls have moved to the issue actions menu (%{icon}) at the top of the page.").html_safe % { icon: sprite_icon('ellipsis_v', size: 12, css_class: 'gl-align-middle') }
|
||||
-# END REMOVE BLOCK
|
||||
|
||||
= render_if_exists 'shared/issuable/sidebar_cve_id_request', issuable_sidebar: issuable_sidebar
|
||||
|
||||
|
|
|
|||
|
|
@ -27414,6 +27414,8 @@ Defines which user roles, users, or groups can merge into a protected branch.
|
|||
| <a id="mergerequestprojectid"></a>`projectId` | [`Int!`](#int) | ID of the merge request project. |
|
||||
| <a id="mergerequestrebasecommitsha"></a>`rebaseCommitSha` | [`String`](#string) | Rebase commit SHA of the merge request. |
|
||||
| <a id="mergerequestrebaseinprogress"></a>`rebaseInProgress` | [`Boolean!`](#boolean) | Indicates if there is a rebase currently in progress for the merge request. |
|
||||
| <a id="mergerequestresolvablediscussionscount"></a>`resolvableDiscussionsCount` | [`Int`](#int) | Number of user discussions that are resolvable in the merge request. |
|
||||
| <a id="mergerequestresolveddiscussionscount"></a>`resolvedDiscussionsCount` | [`Int`](#int) | Number of user discussions that are resolved in the merge request. |
|
||||
| <a id="mergerequestretargeted"></a>`retargeted` | [`Boolean`](#boolean) | Indicates if merge request was retargeted. |
|
||||
| <a id="mergerequestreviewers"></a>`reviewers` | [`MergeRequestReviewerConnection`](#mergerequestreviewerconnection) | Users from whom a review has been requested. (see [Connections](#connections)) |
|
||||
| <a id="mergerequestsecurityautofix"></a>`securityAutoFix` **{warning-solid}** | [`Boolean`](#boolean) | **Deprecated** in GitLab 16.11. Security Auto Fix experiment feature was removed. It was always hidden behind `security_auto_fix` feature flag. |
|
||||
|
|
|
|||
|
|
@ -52,8 +52,7 @@ To change the confidentiality of an existing issue:
|
|||
1. On the left sidebar, select **Search or go to** and find your project.
|
||||
1. Select **Plan > Issues**.
|
||||
1. Select the title of your issue to view it.
|
||||
1. On the right sidebar, next to **Confidentiality**, select **Edit**.
|
||||
1. Select **Turn on** (or **Turn off** to make the issue non-confidential).
|
||||
1. In the upper-right corner, select **Issue actions** (**{ellipsis_v}**) and then **Turn on confidentiality** (or **Turn off confidentiality** to make the issue non-confidential).
|
||||
|
||||
Alternatively, you can use the `/confidential` [quick action](../quick_actions.md#issues-merge-requests-and-epics).
|
||||
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ of the below topic into "Special milestone filters" -->
|
|||
#### "Upcoming" and "Started" filters (deprecated)
|
||||
|
||||
WARNING:
|
||||
This feature was [deprecated](https://issue-link) in GitLab 17.7
|
||||
This feature was [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/501294) in GitLab 17.7
|
||||
and is [planned for change](https://gitlab.com/gitlab-org/gitlab/-/issues/429728) in 18.0.
|
||||
|
||||
The behavior of "Upcoming" and "Started" special filters is planned to change in upcoming GitLab major release 18.0.
|
||||
|
|
|
|||
|
|
@ -253,3 +253,5 @@ module ContainerRegistry
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
ContainerRegistry::GitlabApiClient.prepend_mod
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ module Gitlab
|
|||
return if !project.import_data.user_mapping_enabled? || ghost_user?
|
||||
|
||||
source_user_mapper.find_or_create_source_user(
|
||||
source_name: gitea_user[:login],
|
||||
source_username: gitea_user[:full_name] || gitea_user[:login],
|
||||
source_name: gitea_user[:full_name].presence || gitea_user[:login],
|
||||
source_username: gitea_user[:login],
|
||||
source_user_identifier: raw[:id]
|
||||
)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Utils
|
||||
class TraversalIdCompactor
|
||||
CompactionLimitCannotBeAchievedError = Class.new(StandardError)
|
||||
RedundantCompactionEntry = Class.new(StandardError)
|
||||
UnexpectedCompactionEntry = Class.new(StandardError)
|
||||
|
||||
class << self
|
||||
# This class compacts an array of traversal_ids by finding the most common namespace
|
||||
# and consolidating all children into an entry for that namespace. It continues this process
|
||||
# until the size of the final array is less than the limit. If it cannot achieve the limit
|
||||
# it raises a CompactionLimitCannotBeAchievedError.
|
||||
#
|
||||
# The traversal_ids input will look like the array below where each element in the sub-arrays
|
||||
# is a namespace id.
|
||||
#
|
||||
# [
|
||||
# [1, 21],
|
||||
# [1, 2, 3],
|
||||
# [1, 2, 4],
|
||||
# [1, 2, 5],
|
||||
# [1, 2, 12, 13],
|
||||
# [1, 6, 7],
|
||||
# [1, 6, 8],
|
||||
# [9, 10, 11]
|
||||
# ]
|
||||
#
|
||||
# The limit input is the maximum number of elements in the final array.
|
||||
|
||||
# The compact method calls the compact_once method until the size of the final array is less
|
||||
# than the limit. It then returns the compacted list of traversal_ids
|
||||
# If it cannot achieve the limit it raises a CompactionLimitCannotBeAchievedError.
|
||||
|
||||
def compact(traversal_ids, limit)
|
||||
traversal_ids = compact_once(traversal_ids) while traversal_ids.size > limit
|
||||
|
||||
traversal_ids
|
||||
end
|
||||
|
||||
# The compact_once method finds the most common namespace and compacts all children into an
|
||||
# entry for that namespace. It then returns the compacted list of traversal_ids.
|
||||
|
||||
def compact_once(traversal_ids)
|
||||
most_common_namespace_path = find_most_common_namespace_path(traversal_ids)
|
||||
|
||||
compacted_traversal_ids = traversal_ids.map do |traversal_id|
|
||||
if starts_with?(traversal_id, most_common_namespace_path)
|
||||
most_common_namespace_path
|
||||
else
|
||||
traversal_id
|
||||
end
|
||||
end
|
||||
|
||||
compacted_traversal_ids.uniq
|
||||
end
|
||||
|
||||
# The validate method performs two checks on the compacted_traversal_ids
|
||||
# 1. If there are redundant traversal_ids, for example [1,2,3,4] and [1,2,3]
|
||||
# 2. If there are unexpected entries, meaning a traversal_id not present in the origin_project_traversal_ids
|
||||
# If either case is found, it will raise an error
|
||||
# Otherwise, it will return true
|
||||
|
||||
def validate!(origin_project_traversal_ids, compacted_traversal_ids)
|
||||
compacted_traversal_ids.each do |compacted_path|
|
||||
# Fail if there are unexpected entries
|
||||
raise UnexpectedCompactionEntry unless origin_project_traversal_ids.find do |original_path|
|
||||
starts_with?(original_path, compacted_path)
|
||||
end
|
||||
|
||||
# Fail if there are redundant entries
|
||||
compacted_traversal_ids.each do |inner_compacted_path|
|
||||
next if inner_compacted_path == compacted_path
|
||||
|
||||
raise RedundantCompactionEntry if starts_with?(inner_compacted_path, compacted_path)
|
||||
end
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# find_most_common_namespace_path method takes an array of traversal_ids and returns the most common namespace
|
||||
# For example, given the following traversal_ids it would return [1, 2]
|
||||
#
|
||||
# [
|
||||
# [1, 21],
|
||||
# [1, 2, 3],
|
||||
# [1, 2, 4],
|
||||
# [1, 2, 5],
|
||||
# [1, 2, 12, 13],
|
||||
# [1, 6, 7],
|
||||
# [1, 6, 8],
|
||||
# [9, 10, 11]
|
||||
# ]
|
||||
|
||||
def find_most_common_namespace_path(traversal_ids)
|
||||
# namespace_counts is a tally of the number of times each namespace path occurs in the traversal_ids array
|
||||
# after removing any namespace paths that occur only once
|
||||
# The namespace path is the traversal_id without the last element
|
||||
namespace_counts = traversal_ids.each_with_object([]) do |traversal_id, result|
|
||||
result << traversal_id[0...-1] if traversal_id.size > 1
|
||||
end.tally
|
||||
|
||||
# namespace is the namespace path that occurs the most times in the traversal_ids array after removing
|
||||
# any namespace paths that occur only once since compaction isn't necessary for those
|
||||
namespace = namespace_counts.reject { |_k, v| v == 1 }.sort_by { |k, v| [k.size, v] }.reverse.to_h.first
|
||||
|
||||
# if namespace is nil it means there are no more namespaces to compact so
|
||||
# we raise a CompactionLimitCannotBeAchievedError
|
||||
raise CompactionLimitCannotBeAchievedError if namespace.nil?
|
||||
|
||||
# return the most common namespace path
|
||||
namespace.first
|
||||
end
|
||||
|
||||
# The starts_with? method returns true if the first n elements of the traversal_id match the namespace_path
|
||||
# For example:
|
||||
#
|
||||
# starts_with?([1, 2, 3], [1, 2]) #=> true
|
||||
# starts_with?([1, 2], [1, 2, 3]) #=> false
|
||||
# starts_with?([1, 2, 3], [1, 2, 3]) #=> true
|
||||
# starts_with?([1, 2, 3], [1, 2, 3, 4]) #=> false
|
||||
|
||||
def starts_with?(traversal_id, namespace_path)
|
||||
traversal_id.first(namespace_path.length) == namespace_path
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1483,6 +1483,9 @@ msgstr ""
|
|||
msgid "%{total} warnings found: showing first %{warningsDisplayed}"
|
||||
msgstr ""
|
||||
|
||||
msgid "%{type} '%{name}' has been successfully restored."
|
||||
msgstr ""
|
||||
|
||||
msgid "%{type} '%{name}' is being deleted."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -14651,6 +14654,9 @@ msgstr ""
|
|||
msgid "Confidentiality"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confidentiality controls have moved to the issue actions menu (%{icon}) at the top of the page."
|
||||
msgstr ""
|
||||
|
||||
msgid "Configuration help"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -44380,6 +44386,9 @@ msgstr ""
|
|||
msgid "Projects|An error occurred deleting the project. Please refresh the page to try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "Projects|An error occurred restoring the project. Please refresh the page to try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "Projects|Projects are where you can store your code, access issues, wiki, and other features of GitLab."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -53122,6 +53131,9 @@ msgstr ""
|
|||
msgid "Something went wrong while fetching the packages list."
|
||||
msgstr ""
|
||||
|
||||
msgid "Something went wrong while getting %{issuableType} confidentiality status."
|
||||
msgstr ""
|
||||
|
||||
msgid "Something went wrong while obtaining the Let's Encrypt certificate."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -62842,18 +62854,27 @@ msgstr ""
|
|||
msgid "WorkItemCustomField|Add option"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItemCustomField|An error occurred while loading the custom field"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItemCustomField|An error occurred while saving the custom field"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItemCustomField|At least one option is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItemCustomField|Edit custom field"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItemCustomField|Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItemCustomField|Name is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItemCustomField|New custom field"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItemCustomField|Options"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -63066,9 +63087,6 @@ msgstr ""
|
|||
msgid "WorkItem|Due"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|Edit field"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|Epic"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -63174,9 +63192,6 @@ msgstr ""
|
|||
msgid "WorkItem|New %{workItemType}"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|New custom field"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|New related %{workItemType}"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ class SemgrepResultProcessor
|
|||
ALLOWED_PROJECT_DIRS = %w[/builds/gitlab-org/gitlab].freeze
|
||||
ALLOWED_API_URLS = %w[https://gitlab.com/api/v4].freeze
|
||||
|
||||
UNIQUE_COMMENT_RULES_IDS = %w[builds.sast-custom-rules.appsec-pings.glappsec_ci-job-token builds.sast-custom-rules.secure-coding-guidelines.ruby.glappsec_insecure-regex].freeze
|
||||
# Remove this when the feature is fully working
|
||||
MESSAGE_FOOTER = <<~FOOTER
|
||||
|
||||
|
|
@ -36,6 +37,7 @@ class SemgrepResultProcessor
|
|||
return
|
||||
end
|
||||
|
||||
puts "Found the following unique results: #{unique_results}"
|
||||
create_inline_comments(unique_results)
|
||||
|
||||
rescue StandardError => e
|
||||
|
|
@ -77,10 +79,11 @@ class SemgrepResultProcessor
|
|||
# Remove version suffix from fingerprint
|
||||
fingerprint = result["extra"]["fingerprint"].sub(/_\d+$/, '')
|
||||
path = result["path"]
|
||||
check_id = result["check_id"]
|
||||
line = result["start"]["line"]
|
||||
message = result["extra"]["message"].tr('"\'', '')
|
||||
|
||||
fingerprint_message_dict[fingerprint] = { path: path, line: line, message: message }
|
||||
fingerprint_message_dict[fingerprint] = { path: path, line: line, message: message, check_id: check_id }
|
||||
end
|
||||
|
||||
# Print the results to console
|
||||
|
|
@ -106,6 +109,14 @@ class SemgrepResultProcessor
|
|||
existing_fingerprints = existing_headers.map do |message|
|
||||
JSON.parse(message)["fingerprint"]
|
||||
end
|
||||
unique_rule_findings = {}
|
||||
fingerprint_messages.each do |fingerprint, finding|
|
||||
next unless UNIQUE_COMMENT_RULES_IDS.include?(finding[:check_id])
|
||||
|
||||
fingerprint_messages.delete(fingerprint) if unique_rule_findings[finding[:check_id]]
|
||||
|
||||
unique_rule_findings[finding[:check_id]] = true
|
||||
end
|
||||
fingerprint_messages.reject do |fingerprint, _|
|
||||
existing_fingerprints.include?(fingerprint)
|
||||
end
|
||||
|
|
@ -116,7 +127,7 @@ class SemgrepResultProcessor
|
|||
|
||||
# Create new comments for remaining findings
|
||||
path_line_message_dict.each do |fingerprint, finding|
|
||||
header_information = JSON.dump({ 'fingerprint' => fingerprint })
|
||||
header_information = JSON.dump({ 'fingerprint' => fingerprint, 'check_id' => finding[:check_id] })
|
||||
message_header = "<!-- #{header_information} -->"
|
||||
new_line = finding[:line]
|
||||
message = finding[:message]
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ RSpec.describe 'issue header', :js, feature_category: :team_planning do
|
|||
let_it_be(:closed_issue) { create(:issue, :closed, project: project) }
|
||||
let_it_be(:closed_locked_issue) { create(:issue, :closed, :locked, project: project) }
|
||||
let_it_be(:authored_issue) { create(:issue, project: project, author: user) }
|
||||
let_it_be(:confidential_issue) { create(:issue, :confidential, project: project) }
|
||||
|
||||
context 'when user has permission to update' do
|
||||
before do
|
||||
|
|
@ -74,6 +75,32 @@ RSpec.describe 'issue header', :js, feature_category: :team_planning do
|
|||
expect(page).not_to have_button 'Report abuse'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the issue is not confidential' do
|
||||
before do
|
||||
visit project_issue_path(project, issue)
|
||||
wait_for_requests
|
||||
end
|
||||
|
||||
it 'shows "Turn on confidentiality" button in dropdown' do
|
||||
click_button 'Issue actions'
|
||||
|
||||
expect(page).to have_button 'Turn on confidentiality'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the issue is confidential' do
|
||||
before do
|
||||
visit project_issue_path(project, confidential_issue)
|
||||
wait_for_requests
|
||||
end
|
||||
|
||||
it 'shows "Turn off confidentiality" button in dropdown' do
|
||||
click_button 'Issue actions'
|
||||
|
||||
expect(page).to have_button 'Turn off confidentiality'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is admin and the project is set up for spam' do
|
||||
|
|
@ -119,6 +146,7 @@ RSpec.describe 'issue header', :js, feature_category: :team_planning do
|
|||
expect(page).to have_button 'Report abuse'
|
||||
expect(page).not_to have_link 'Submit as spam'
|
||||
expect(page).not_to have_button 'Delete issue'
|
||||
expect(page).not_to have_button 'Turn on confidentiality'
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -139,33 +139,6 @@ RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
|
|||
expect(page).not_to have_selector('.block.escalation-status')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when interacting with collapsed sidebar', :js do
|
||||
collapsed_sidebar_selector = 'aside.right-sidebar.right-sidebar-collapsed'
|
||||
expanded_sidebar_selector = 'aside.right-sidebar.right-sidebar-expanded'
|
||||
confidentiality_sidebar_block = '.block.confidentiality'
|
||||
collapsed_sidebar_block_icon = '.sidebar-collapsed-icon'
|
||||
|
||||
before do
|
||||
resize_screen_sm
|
||||
end
|
||||
|
||||
it 'confidentiality block expands then collapses sidebar' do
|
||||
expect(page).to have_css(collapsed_sidebar_selector)
|
||||
|
||||
page.within(confidentiality_sidebar_block) do
|
||||
find(collapsed_sidebar_block_icon).click
|
||||
end
|
||||
|
||||
expect(page).to have_css(expanded_sidebar_selector)
|
||||
|
||||
page.within(confidentiality_sidebar_block) do
|
||||
page.find('button', text: 'Cancel').click
|
||||
end
|
||||
|
||||
expect(page).to have_css(collapsed_sidebar_selector)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'as a guest' do
|
||||
|
|
|
|||
|
|
@ -26,25 +26,23 @@ RSpec.describe 'Issues > User sees live update', :js, feature_category: :team_pl
|
|||
end
|
||||
|
||||
describe 'confidential issue#show' do
|
||||
it 'shows confidential sidebar information as confidential and can be turned off' do
|
||||
it 'shows the confidentiality status that can be turned off' do
|
||||
issue = create(:issue, :confidential, project: project)
|
||||
|
||||
visit project_issue_path(project, issue)
|
||||
wait_for_requests
|
||||
|
||||
expect(page).to have_text('This is a confidential issue. People without permission will never get a notification.')
|
||||
expect(page).to have_css('.gl-badge', text: 'Confidential')
|
||||
|
||||
within '.block.confidentiality' do
|
||||
click_button 'Edit'
|
||||
click_button 'Issue actions'
|
||||
within '#new-actions-header-dropdown' do
|
||||
click_button 'Turn off confidentiality'
|
||||
end
|
||||
|
||||
expect(page).to have_text('You are going to turn off the confidentiality. This means everyone will be able to see and leave a comment on this issue.')
|
||||
|
||||
click_button 'Turn off'
|
||||
|
||||
visit project_issue_path(project, issue)
|
||||
wait_for_requests
|
||||
|
||||
expect(page).not_to have_css('.gl-badge', text: 'Confidential')
|
||||
expect(page).not_to have_text('This is a confidential issue. People without permission will never get a notification.')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import { GlDisclosureDropdownItem } from '@gitlab/ui';
|
||||
import Vue from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import { createAlert } from '~/alert';
|
||||
import HeaderActionsConfidentialityToggle from '~/issues/show/components/header_actions_confidentiality_toggle.vue';
|
||||
import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
|
||||
import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql';
|
||||
|
||||
jest.mock('~/alert', () => ({
|
||||
createAlert: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('HeaderActionsConfidentialityToggle', () => {
|
||||
let wrapper;
|
||||
let mockApollo;
|
||||
let mockMutation;
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
const mockConfidentialityQueryResponse = {
|
||||
data: {
|
||||
workspace: {
|
||||
id: '1',
|
||||
issuable: {
|
||||
id: '1',
|
||||
confidential: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockConfidentialityMutationResponse = {
|
||||
data: {
|
||||
issuableSetConfidential: {
|
||||
issuable: {
|
||||
id: '1',
|
||||
confidential: false,
|
||||
},
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockConfidentialityMutationErrorResponse = {
|
||||
data: {
|
||||
issuableSetConfidential: {
|
||||
issuable: {
|
||||
id: '1',
|
||||
confidential: false,
|
||||
},
|
||||
errors: ['An error occurred'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const createComponent = ({ queryHandler, mutationHandler } = {}) => {
|
||||
mockMutation = mutationHandler;
|
||||
mockApollo = createMockApollo([
|
||||
[issueConfidentialQuery, queryHandler],
|
||||
[updateIssueConfidentialMutation, mockMutation],
|
||||
]);
|
||||
|
||||
wrapper = shallowMountExtended(HeaderActionsConfidentialityToggle, {
|
||||
apolloProvider: mockApollo,
|
||||
provide: {
|
||||
iid: '1',
|
||||
issuePath: 'gitlab-org/gitlab-test/-/issues/1',
|
||||
projectPath: 'gitlab-org/gitlab-test',
|
||||
fullPath: 'gitlab-org/gitlab-test',
|
||||
issueType: 'issue',
|
||||
},
|
||||
mocks: {
|
||||
$toast: {
|
||||
show: jest.fn(),
|
||||
},
|
||||
},
|
||||
stubs: {
|
||||
GlDisclosureDropdownItem,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it('renders the component', () => {
|
||||
createComponent({
|
||||
queryHandler: jest.fn().mockResolvedValue(mockConfidentialityQueryResponse),
|
||||
});
|
||||
|
||||
expect(wrapper.findComponent(GlDisclosureDropdownItem).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('toggles confidentiality successfully', async () => {
|
||||
createComponent({
|
||||
queryHandler: jest.fn().mockResolvedValue(mockConfidentialityQueryResponse),
|
||||
mutationHandler: jest.fn().mockResolvedValue(mockConfidentialityMutationResponse),
|
||||
});
|
||||
|
||||
wrapper.findComponent(GlDisclosureDropdownItem).vm.$emit('action');
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(createAlert).not.toHaveBeenCalled();
|
||||
expect(mockMutation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error alert when toggling confidentiality fails', async () => {
|
||||
createComponent({
|
||||
queryHandler: jest.fn().mockResolvedValue(mockConfidentialityQueryResponse),
|
||||
mutationHandler: jest.fn().mockResolvedValue(mockConfidentialityMutationErrorResponse),
|
||||
});
|
||||
|
||||
wrapper.findComponent(GlDisclosureDropdownItem).vm.$emit('action');
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(createAlert).toHaveBeenCalledWith({
|
||||
message: 'An error occurred',
|
||||
});
|
||||
expect(mockMutation).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -38,6 +38,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
|
|||
import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
|
||||
import updateIssueMutation from '~/issues/show/queries/update_issue.mutation.graphql';
|
||||
import toast from '~/vue_shared/plugins/global_toast';
|
||||
import HeaderActionsConfidentialityToggle from '~/issues/show/components/header_actions_confidentiality_toggle.vue';
|
||||
|
||||
jest.mock('~/alert');
|
||||
jest.mock('~/issues/show/event_hub', () => ({ $emit: jest.fn() }));
|
||||
|
|
@ -602,6 +603,29 @@ describe('HeaderActions component', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('toggle confidentiality option', () => {
|
||||
it.each`
|
||||
issueType | canUpdateIssue | isVisible | showHide
|
||||
${TYPE_ISSUE} | ${true} | ${true} | ${'shows'}
|
||||
${TYPE_INCIDENT} | ${true} | ${true} | ${'shows'}
|
||||
${TYPE_ISSUE} | ${false} | ${false} | ${'hides'}
|
||||
${TYPE_INCIDENT} | ${false} | ${false} | ${'hides'}
|
||||
${'some_other_type'} | ${true} | ${false} | ${'hides'}
|
||||
`(
|
||||
'$showHide toggle confidentiality option for issueType $issueType and canUpdateIssue $canUpdateIssue',
|
||||
({ issueType, canUpdateIssue, isVisible }) => {
|
||||
wrapper = mountComponent({
|
||||
props: {
|
||||
issueType,
|
||||
canUpdateIssue,
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.findComponent(HeaderActionsConfidentialityToggle).exists()).toBe(isVisible);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('issue type text', () => {
|
||||
it.each`
|
||||
issueType | expectedText
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions
|
|||
|
||||
const MOCK_AVAILABLE_ACTIONS = [ACTION_EDIT, ACTION_DELETE];
|
||||
|
||||
jest.mock('~/vue_shared/components/projects_list/utils', () => ({
|
||||
jest.mock('ee_else_ce/vue_shared/components/projects_list/utils', () => ({
|
||||
availableGraphQLProjectActions: jest.fn(() => MOCK_AVAILABLE_ACTIONS),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import projects from 'test_fixtures/api/users/projects/get.json';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
|
||||
import ListActions from '~/vue_shared/components/list_actions/list_actions.vue';
|
||||
import ProjectListItemActions from '~/vue_shared/components/projects_list/project_list_item_actions.vue';
|
||||
import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
|
||||
|
||||
describe('ProjectListItemActionsCE', () => {
|
||||
let wrapper;
|
||||
|
||||
const [project] = convertObjectPropsToCamelCase(projects, { deep: true });
|
||||
|
||||
const editPath = '/foo/bar/edit';
|
||||
const projectWithActions = {
|
||||
...project,
|
||||
availableActions: [ACTION_EDIT, ACTION_DELETE],
|
||||
editPath,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
project: projectWithActions,
|
||||
};
|
||||
|
||||
const createComponent = ({ props = {} } = {}) => {
|
||||
wrapper = shallowMountExtended(ProjectListItemActions, {
|
||||
propsData: { ...defaultProps, ...props },
|
||||
});
|
||||
};
|
||||
|
||||
const findListActions = () => wrapper.findComponent(ListActions);
|
||||
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
it('displays actions dropdown', () => {
|
||||
expect(findListActions().props()).toMatchObject({
|
||||
actions: {
|
||||
[ACTION_EDIT]: {
|
||||
href: editPath,
|
||||
},
|
||||
[ACTION_DELETE]: {
|
||||
action: expect.any(Function),
|
||||
},
|
||||
},
|
||||
availableActions: [ACTION_EDIT, ACTION_DELETE],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when delete action is fired', () => {
|
||||
beforeEach(() => {
|
||||
findListActions().props('actions')[ACTION_DELETE].action();
|
||||
});
|
||||
|
||||
it('emits delete event', () => {
|
||||
expect(wrapper.emitted('delete')).toEqual([[]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -4,9 +4,9 @@ import uniqueId from 'lodash/uniqueId';
|
|||
import projects from 'test_fixtures/api/users/projects/get.json';
|
||||
import { mountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import ProjectListItemDescription from 'ee_else_ce/vue_shared/components/projects_list/project_list_item_description.vue';
|
||||
import ProjectListItemActions from 'ee_else_ce/vue_shared/components/projects_list/project_list_item_actions.vue';
|
||||
import ProjectListItemInactiveBadge from 'ee_else_ce/vue_shared/components/projects_list/project_list_item_inactive_badge.vue';
|
||||
import ProjectsListItem from '~/vue_shared/components/projects_list/projects_list_item.vue';
|
||||
import ListActions from '~/vue_shared/components/list_actions/list_actions.vue';
|
||||
import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
|
||||
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
|
||||
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
|
||||
|
|
@ -75,7 +75,7 @@ describe('ProjectsListItem', () => {
|
|||
const findProjectTopics = () => wrapper.findByTestId('project-topics');
|
||||
const findPopover = () => findProjectTopics().findComponent(GlPopover);
|
||||
const findVisibilityIcon = () => findAvatarLabeled().findComponent(GlIcon);
|
||||
const findListActions = () => wrapper.findComponent(ListActions);
|
||||
const findListActions = () => wrapper.findComponent(ProjectListItemActions);
|
||||
const findAccessLevelBadge = () => wrapper.findByTestId('access-level-badge');
|
||||
const findCiCatalogBadge = () => wrapper.findByTestId('ci-catalog-badge');
|
||||
const findProjectDescription = () => wrapper.findComponent(ProjectListItemDescription);
|
||||
|
|
@ -429,22 +429,12 @@ describe('ProjectsListItem', () => {
|
|||
});
|
||||
|
||||
it('displays actions dropdown', () => {
|
||||
expect(findListActions().props()).toMatchObject({
|
||||
actions: {
|
||||
[ACTION_EDIT]: {
|
||||
href: editPath,
|
||||
},
|
||||
[ACTION_DELETE]: {
|
||||
action: expect.any(Function),
|
||||
},
|
||||
},
|
||||
availableActions: [ACTION_EDIT, ACTION_DELETE],
|
||||
});
|
||||
expect(findListActions().exists()).toBe(true);
|
||||
});
|
||||
|
||||
describe('when delete action is fired', () => {
|
||||
beforeEach(() => {
|
||||
findListActions().props('actions')[ACTION_DELETE].action();
|
||||
findListActions().vm.$emit('delete');
|
||||
});
|
||||
|
||||
it('displays confirmation modal with correct props', () => {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ RSpec.describe GitlabSchema.types['MergeRequest'], feature_category: :code_revie
|
|||
project project_id source_project_id target_project_id source_branch
|
||||
target_branch target_branch_path draft merge_when_pipeline_succeeds diff_head_sha
|
||||
merge_commit_sha user_notes_count user_discussions_count should_remove_source_branch
|
||||
resolvable_discussions_count resolved_discussions_count
|
||||
diff_refs diff_stats diff_stats_summary
|
||||
force_remove_source_branch
|
||||
merge_status merge_status_enum
|
||||
|
|
@ -247,4 +248,70 @@ RSpec.describe GitlabSchema.types['MergeRequest'], feature_category: :code_revie
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#resolvable_discussions_count' do
|
||||
let_it_be(:project) { create(:project, :public, :repository) }
|
||||
let_it_be(:merge_request) { create(:merge_request, source_project: project) }
|
||||
let_it_be(:note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) }
|
||||
|
||||
let(:query) do
|
||||
%(
|
||||
query {
|
||||
project(fullPath: "#{project.full_path}") {
|
||||
mergeRequests {
|
||||
nodes {
|
||||
resolvableDiscussionsCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns resolvable discussions count' do
|
||||
result = GitlabSchema.execute(query, context: { current_user: create(:user) })
|
||||
|
||||
expect(result.dig('data', 'project', 'mergeRequests', 'nodes', 0, 'resolvableDiscussionsCount')).to eq(1)
|
||||
end
|
||||
|
||||
it_behaves_like 'avoids N+1 queries' do
|
||||
def create_additional_resources
|
||||
noteable = create(:merge_request, source_project: project, source_branch: 'improve/awesome')
|
||||
create(:discussion_note_on_merge_request, noteable: noteable, project: project)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#resolved_discussions_count' do
|
||||
let_it_be(:project) { create(:project, :public, :repository) }
|
||||
let_it_be(:merge_request) { create(:merge_request, source_project: project) }
|
||||
let_it_be(:note) { create(:discussion_note_on_merge_request, :resolved, noteable: merge_request, project: project) }
|
||||
|
||||
let(:query) do
|
||||
%(
|
||||
query {
|
||||
project(fullPath: "#{project.full_path}") {
|
||||
mergeRequests {
|
||||
nodes {
|
||||
resolvedDiscussionsCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns resolved discussions count' do
|
||||
result = GitlabSchema.execute(query, context: { current_user: create(:user) })
|
||||
|
||||
expect(result.dig('data', 'project', 'mergeRequests', 'nodes', 0, 'resolvedDiscussionsCount')).to eq(1)
|
||||
end
|
||||
|
||||
it_behaves_like 'avoids N+1 queries' do
|
||||
def create_additional_resources
|
||||
noteable = create(:merge_request, source_project: project, source_branch: 'improve/awesome')
|
||||
create(:discussion_note_on_merge_request, :resolved, noteable: noteable, project: project)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -139,6 +139,30 @@ RSpec.describe Gitlab::LegacyGithubImport::UserFormatter, feature_category: :imp
|
|||
expect { user_formatter.source_user }.to change { Import::SourceUser.count }.from(0).to(1)
|
||||
expect(user_formatter.source_user.class).to eq(Import::SourceUser)
|
||||
end
|
||||
|
||||
it "creates a placeholder with the user's full name and username" do
|
||||
source_user = user_formatter.source_user
|
||||
|
||||
expect(source_user).to have_attributes(
|
||||
source_user_identifier: gitea_user[:id].to_s,
|
||||
source_username: gitea_user[:login],
|
||||
source_name: gitea_user[:full_name]
|
||||
)
|
||||
end
|
||||
|
||||
context 'when the gitea user has no full name' do
|
||||
let(:gitea_user) { { id: 123456, login: 'octocat', email: 'user@email.com', full_name: '' } }
|
||||
|
||||
it 'falls back to the gitea username' do
|
||||
source_user = user_formatter.source_user
|
||||
|
||||
expect(source_user).to have_attributes(
|
||||
source_user_identifier: gitea_user[:id].to_s,
|
||||
source_username: gitea_user[:login],
|
||||
source_name: gitea_user[:login]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'and a source user already exists' do
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Utils::TraversalIdCompactor, feature_category: :secrets_management do
|
||||
let(:traversal_ids) do
|
||||
[
|
||||
[1, 21],
|
||||
[1, 2, 3],
|
||||
[1, 2, 4],
|
||||
[1, 2, 5],
|
||||
[1, 2, 12, 13],
|
||||
[1, 6, 7],
|
||||
[1, 6, 8],
|
||||
[9, 10, 11]
|
||||
]
|
||||
end
|
||||
|
||||
let(:compactor) { described_class }
|
||||
|
||||
describe '#compact' do
|
||||
it 'compacts the array of traversal_ids using compact_once two times until the limit is reached' do
|
||||
expect(compactor).to receive(:compact_once).twice.and_call_original
|
||||
|
||||
result = compactor.compact(traversal_ids, 4)
|
||||
|
||||
expect(result).to eq([
|
||||
[1, 21],
|
||||
[1, 2],
|
||||
[1, 6],
|
||||
[9, 10, 11]
|
||||
])
|
||||
end
|
||||
|
||||
it 'compacts the array of traversal_ids using compact_once three times until the limit is reached' do
|
||||
expect(compactor).to receive(:compact_once).exactly(3).times.and_call_original
|
||||
|
||||
result = compactor.compact(traversal_ids, 3)
|
||||
|
||||
expect(result).to eq([
|
||||
[1],
|
||||
[9, 10, 11]
|
||||
])
|
||||
end
|
||||
|
||||
it 'compacts the array of traversal_ids using compact_once one time to reach the limit' do
|
||||
traversal_ids = [
|
||||
[1, 2],
|
||||
[1, 3],
|
||||
[1, 4],
|
||||
[5, 6],
|
||||
[6, 7]
|
||||
]
|
||||
|
||||
expect(compactor).to receive(:compact_once).once.and_call_original
|
||||
|
||||
result = compactor.compact(traversal_ids, 3)
|
||||
|
||||
expect(result).to eq([
|
||||
[1],
|
||||
[5, 6],
|
||||
[6, 7]
|
||||
])
|
||||
end
|
||||
|
||||
it 'raises when the compaction limit can not be achieved' do
|
||||
expect do
|
||||
compactor.compact(traversal_ids, 1)
|
||||
end.to raise_error(described_class::CompactionLimitCannotBeAchievedError)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#compact_once' do
|
||||
it 'compacts the one most common namespace path and returns the newly compacted array of traversal_ids' do
|
||||
result = compactor.compact_once(traversal_ids)
|
||||
|
||||
expect(result).to eq([
|
||||
[1, 21],
|
||||
[1, 2],
|
||||
[1, 6, 7],
|
||||
[1, 6, 8],
|
||||
[9, 10, 11]
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
describe '#validate!' do
|
||||
it 'returns true when the compacted results are valid' do
|
||||
result = compactor.compact(traversal_ids, 4)
|
||||
expect(compactor.validate!(traversal_ids, result)).to be true
|
||||
end
|
||||
|
||||
it 'raises a RedundantCompactionEntry error when redundant entries are found' do
|
||||
result = compactor.compact(traversal_ids, 4)
|
||||
result << [1, 2, 3]
|
||||
expect do
|
||||
compactor.validate!(traversal_ids, result)
|
||||
end.to raise_error(described_class::RedundantCompactionEntry)
|
||||
end
|
||||
|
||||
it 'raises an UnexpectedCompactionEntry error when an unexpected entry is found' do
|
||||
result = compactor.compact(traversal_ids, 4)
|
||||
result << [1, 3, 4]
|
||||
expect do
|
||||
compactor.validate!(traversal_ids, result)
|
||||
end.to raise_error(described_class::UnexpectedCompactionEntry)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Ci::JobToken::AuthorizationsCompactor, feature_category: :secrets_management do
|
||||
let_it_be(:accessed_project) { create(:project) }
|
||||
let(:compactor) { described_class.new(accessed_project.id) }
|
||||
|
||||
# [1, 21], ns1, p1
|
||||
# [1, 2, 3], ns1, ns2, p2
|
||||
# [1, 2, 4], ns1, ns2, p3
|
||||
# [1, 2, 5], ns1, ns2, p4
|
||||
# [1, 2, 12, 13], ns1, ns2, ns3, p5
|
||||
# [1, 6, 7], ns1, ns4, p6
|
||||
# [1, 6, 8], ns1, ns4, p7
|
||||
# [9, 10, 11] ns5, ns6, p8
|
||||
|
||||
let_it_be(:ns1) { create(:group, name: 'ns1') }
|
||||
let_it_be(:ns2) { create(:group, parent: ns1, name: 'ns2') }
|
||||
let_it_be(:ns3) { create(:group, parent: ns2, name: 'ns3') }
|
||||
let_it_be(:ns4) { create(:group, parent: ns1, name: 'ns4') }
|
||||
let_it_be(:ns5) { create(:group, name: 'ns5') }
|
||||
let_it_be(:ns6) { create(:group, parent: ns5, name: 'ns6') }
|
||||
|
||||
let_it_be(:pns1) { create(:project_namespace, parent: ns1) }
|
||||
let_it_be(:pns2) { create(:project_namespace, parent: ns2) }
|
||||
let_it_be(:pns3) { create(:project_namespace, parent: ns2) }
|
||||
let_it_be(:pns4) { create(:project_namespace, parent: ns2) }
|
||||
let_it_be(:pns5) { create(:project_namespace, parent: ns3) }
|
||||
let_it_be(:pns6) { create(:project_namespace, parent: ns4) }
|
||||
let_it_be(:pns7) { create(:project_namespace, parent: ns4) }
|
||||
let_it_be(:pns8) { create(:project_namespace, parent: ns6) }
|
||||
|
||||
before do
|
||||
origin_project_namespaces = [
|
||||
pns1, pns2, pns3, pns4, pns5, pns6, pns7, pns8
|
||||
]
|
||||
|
||||
origin_project_namespaces.each do |project_namespace|
|
||||
create(:ci_job_token_authorization, origin_project: project_namespace.project, accessed_project: accessed_project,
|
||||
last_authorized_at: 1.day.ago)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#compact' do
|
||||
it 'compacts the allowlist groups and projects as expected for the given limit' do
|
||||
compactor.compact(4)
|
||||
|
||||
expect(compactor.allowlist_groups).to match_array([ns2, ns4])
|
||||
expect(compactor.allowlist_projects).to match_array([pns1.project, pns8.project])
|
||||
end
|
||||
|
||||
it 'compacts the allowlist groups and projects as expected for the given limit' do
|
||||
compactor.compact(3)
|
||||
|
||||
expect(compactor.allowlist_groups).to match_array([ns1])
|
||||
expect(compactor.allowlist_projects).to match_array([pns8.project])
|
||||
end
|
||||
|
||||
it 'raises when the limit cannot be achieved' do
|
||||
expect do
|
||||
compactor.compact(1)
|
||||
end.to raise_error(Gitlab::Utils::TraversalIdCompactor::CompactionLimitCannotBeAchievedError)
|
||||
end
|
||||
|
||||
it 'raises when an unexpected compaction entry is found' do
|
||||
allow(Gitlab::Utils::TraversalIdCompactor).to receive(:compact).and_wrap_original do |original_method, *args|
|
||||
original_response = original_method.call(*args)
|
||||
original_response << [1, 2, 3]
|
||||
end
|
||||
|
||||
expect { compactor.compact(5) }.to raise_error(Gitlab::Utils::TraversalIdCompactor::UnexpectedCompactionEntry)
|
||||
end
|
||||
|
||||
it 'raises when a redundant compaction entry is found' do
|
||||
allow(Gitlab::Utils::TraversalIdCompactor).to receive(:compact).and_wrap_original do |original_method, *args|
|
||||
original_response = original_method.call(*args)
|
||||
original_response << original_response.last.first(2)
|
||||
end
|
||||
|
||||
expect { compactor.compact(5) }.to raise_error(Gitlab::Utils::TraversalIdCompactor::RedundantCompactionEntry)
|
||||
end
|
||||
|
||||
context 'with three top-level namespaces' do
|
||||
# [1, 21], ns1, p1
|
||||
# [1, 2, 3], ns1, ns2, p2
|
||||
# [1, 2, 4], ns1, ns2, p3
|
||||
# [1, 2, 5], ns1, ns2, p4
|
||||
# [1, 2, 12, 13], ns1, ns2, ns3, p5
|
||||
# [1, 6, 7], ns1, ns4, p6
|
||||
# [1, 6, 8], ns1, ns4, p7
|
||||
# [9, 10, 11] ns5, ns6, p8
|
||||
# [14, 15] ns7, p9
|
||||
let(:ns7) { create(:group, name: 'ns7') }
|
||||
let(:pns9) { create(:project_namespace, parent: ns7) }
|
||||
|
||||
before do
|
||||
create(:ci_job_token_authorization, origin_project: pns9.project, accessed_project: accessed_project,
|
||||
last_authorized_at: 1.day.ago)
|
||||
end
|
||||
|
||||
it 'raises when the limit cannot be achieved' do
|
||||
expect do
|
||||
compactor.compact(2)
|
||||
end.to raise_error(Gitlab::Utils::TraversalIdCompactor::CompactionLimitCannotBeAchievedError)
|
||||
end
|
||||
|
||||
it 'does not raise when the limit cannot be achieved' do
|
||||
expect do
|
||||
compactor.compact(3)
|
||||
end.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -125,27 +125,31 @@ RSpec.describe SemgrepResultProcessor, feature_category: :tooling do
|
|||
|
||||
let(:existing_comments) do
|
||||
[
|
||||
{ "body" => "<!-- {\"fingerprint\":\"abc123\"} --> Some comment ", "author" => { "id" => 123 } },
|
||||
{ "body" => "<!-- {\"fingerprint\":\"def456\"} --> Another comment ", "author" => { "id" => 123 } },
|
||||
{ "body" => "<!-- {\"fingerprint\":\"ghi789\"} --> Yet another comment ", "author" => { "id" => 123 } }
|
||||
{ "body" => "<!-- {\"fingerprint\":\"abc123\",\"check_id\":\"RULE5\"} --> Some comment ",
|
||||
"author" => { "id" => 123 } },
|
||||
{ "body" => "<!-- {\"fingerprint\":\"def456\",\"check_id\":\"RULE6\"} --> Another comment ",
|
||||
"author" => { "id" => 123 } },
|
||||
{ "body" => "<!-- {\"fingerprint\":\"ghi789\",\"check_id\":\"RULE7\"} --> Yet another comment ",
|
||||
"author" => { "id" => 123 } }
|
||||
]
|
||||
end
|
||||
|
||||
let(:fingerprint_messages) do
|
||||
let(:first_unique_rule_id) { described_class::UNIQUE_COMMENT_RULES_IDS.first }
|
||||
let(:new_run_findings) do
|
||||
{
|
||||
"abc123" => "Duplicate finding 1",
|
||||
"def456" => "Duplicate finding 2",
|
||||
"new123" => "New finding 1",
|
||||
"new456" => "New finding 2"
|
||||
"abc123" => { path: "path/to/file1.rb", line: 10, message: "Duplicate finding 1", check_id: "RULE1" },
|
||||
"def456" => { path: "path/to/file2.rb", line: 20, message: "Duplicate finding 2", check_id: "RULE2" },
|
||||
"new123" => { path: "path/to/file3.rb", line: 30, message: "New finding 1", check_id: "RULE3" },
|
||||
"new456" => { path: "path/to/file4.rb", line: 40, message: "New finding 2", check_id: "RULE4" }
|
||||
}
|
||||
end
|
||||
|
||||
it 'filters out findings with fingerprints that are already in comments from the bot' do
|
||||
result = processor.filter_duplicate_findings(fingerprint_messages)
|
||||
result = processor.filter_duplicate_findings(new_run_findings)
|
||||
|
||||
expect(result).to eq({
|
||||
"new123" => "New finding 1",
|
||||
"new456" => "New finding 2"
|
||||
"new123" => { path: "path/to/file3.rb", line: 30, message: "New finding 1", check_id: "RULE3" },
|
||||
"new456" => { path: "path/to/file4.rb", line: 40, message: "New finding 2", check_id: "RULE4" }
|
||||
})
|
||||
end
|
||||
|
||||
|
|
@ -155,9 +159,9 @@ RSpec.describe SemgrepResultProcessor, feature_category: :tooling do
|
|||
{ "body" => "<!-- {\"fingerprint\":\"def456\"} --> Another comment", "author" => { "id" => 456 } }
|
||||
])
|
||||
|
||||
result = processor.filter_duplicate_findings(fingerprint_messages)
|
||||
result = processor.filter_duplicate_findings(new_run_findings)
|
||||
|
||||
expect(result).to eq(fingerprint_messages)
|
||||
expect(result).to eq(new_run_findings)
|
||||
end
|
||||
|
||||
it 'returns an empty hash if all fingerprints are already in bot comments' do
|
||||
|
|
@ -168,17 +172,34 @@ RSpec.describe SemgrepResultProcessor, feature_category: :tooling do
|
|||
{ "body" => "<!-- {\"fingerprint\":\"new456\"} --> Another existing comment", "author" => { "id" => 123 } }
|
||||
])
|
||||
|
||||
result = processor.filter_duplicate_findings(fingerprint_messages)
|
||||
result = processor.filter_duplicate_findings(new_run_findings)
|
||||
|
||||
expect(result).to eq({})
|
||||
end
|
||||
|
||||
it 'filters out findings with check_ids that are in the UNIQUE_COMMENT_RULES_IDS list' do
|
||||
new_run_findings["new789"] =
|
||||
{ path: "path/to/file4.rb", line: 40, message: "New finding 2", check_id: first_unique_rule_id }
|
||||
new_run_findings["new890"] =
|
||||
{ path: "path/to/file4.rb", line: 40, message: "New finding 2", check_id: first_unique_rule_id }
|
||||
result = processor.filter_duplicate_findings(new_run_findings)
|
||||
|
||||
expect(result).to eq({
|
||||
"new123" => { path: "path/to/file3.rb", line: 30, message: "New finding 1",
|
||||
check_id: "RULE3" },
|
||||
"new456" => { path: "path/to/file4.rb", line: 40, message: "New finding 2",
|
||||
check_id: "RULE4" },
|
||||
"new789" => { path: "path/to/file4.rb", line: 40, message: "New finding 2",
|
||||
check_id: first_unique_rule_id }
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get_sast_results' do
|
||||
let(:sample_non_versioned_fingerprint) { "a5adf24a2512f31141f460e0bc18f39c8388105e564f" }
|
||||
let(:sample_message) { "This is a sample SAST finding message" }
|
||||
let(:scanned_path) { "ee/lib/ai/context/dependencies/config_files/python_pip.rb" }
|
||||
|
||||
let(:check_id) { "builds.sast-custom-rules.secure-coding-guidelines.ruby.glappsec_insecure-regex" }
|
||||
let(:sample_data) do
|
||||
{
|
||||
"errors" => [],
|
||||
|
|
@ -194,7 +215,7 @@ RSpec.describe SemgrepResultProcessor, feature_category: :tooling do
|
|||
},
|
||||
"results" => [
|
||||
{
|
||||
"check_id" => "builds.sast-custom-rules.secure-coding-guidelines.ruby.glappsec_insecure-regex",
|
||||
"check_id" => check_id,
|
||||
"path" => scanned_path,
|
||||
"start" => { "line" => 9, "col" => 11, "offset" => 178 },
|
||||
"end" => { "line" => 9, "col" => 93, "offset" => 260 },
|
||||
|
|
@ -204,7 +225,7 @@ RSpec.describe SemgrepResultProcessor, feature_category: :tooling do
|
|||
}
|
||||
},
|
||||
{
|
||||
"check_id" => "builds.sast-custom-rules.secure-coding-guidelines.ruby.glappsec_insecure-regex",
|
||||
"check_id" => check_id,
|
||||
"path" => scanned_path,
|
||||
"start" => { "line" => 9, "col" => 32, "offset" => 199 },
|
||||
"end" => { "line" => 9, "col" => 93, "offset" => 260 },
|
||||
|
|
@ -227,12 +248,12 @@ RSpec.describe SemgrepResultProcessor, feature_category: :tooling do
|
|||
it 'parses the SAST report and prints findings correctly' do
|
||||
expected_output = {
|
||||
sample_non_versioned_fingerprint => {
|
||||
check_id: "builds.sast-custom-rules.secure-coding-guidelines.ruby.glappsec_insecure-regex",
|
||||
path: "ee/lib/ai/context/dependencies/config_files/python_pip.rb",
|
||||
line: 9,
|
||||
message: sample_message
|
||||
}
|
||||
}
|
||||
|
||||
result = processor.get_sast_results
|
||||
|
||||
expect(result).to eq(expected_output)
|
||||
|
|
@ -252,6 +273,23 @@ RSpec.describe SemgrepResultProcessor, feature_category: :tooling do
|
|||
|
||||
expect { processor.get_sast_results }.to raise_error(SystemExit).and output(/No findings./).to_stdout
|
||||
end
|
||||
|
||||
context 'when check_id is absent from SAST results' do
|
||||
let(:check_id) { nil }
|
||||
|
||||
it 'returns an empty check_id' do
|
||||
expected_output = {
|
||||
sample_non_versioned_fingerprint => {
|
||||
check_id: check_id,
|
||||
path: "ee/lib/ai/context/dependencies/config_files/python_pip.rb",
|
||||
line: 9,
|
||||
message: sample_message
|
||||
}
|
||||
}
|
||||
result = processor.get_sast_results
|
||||
expect(result).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_inline_comments' do
|
||||
|
|
|
|||
Loading…
Reference in New Issue