Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-12-10 21:35:42 +00:00
parent b3bdfebdb7
commit a4c466dd6f
37 changed files with 1083 additions and 170 deletions

View File

@ -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

View File

@ -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"},

View File

@ -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)

View File

@ -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"},

View File

@ -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)

View File

@ -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"

View File

@ -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>

View File

@ -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);

View File

@ -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,

View File

@ -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(

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -10,6 +10,7 @@ module Ci
module JobToken
class Authorization < Ci::ApplicationRecord
extend Gitlab::InternalEventsTracking
include EachBatch
self.table_name = 'ci_job_token_authorizations'

View File

@ -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

View File

@ -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

View File

@ -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. |

View File

@ -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).

View File

@ -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.

View File

@ -253,3 +253,5 @@ module ContainerRegistry
end
end
end
ContainerRegistry::GitlabApiClient.prepend_mod

View File

@ -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

View File

@ -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

View File

@ -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 ""

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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();
});
});

View File

@ -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

View File

@ -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),
}));

View File

@ -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([[]]);
});
});
});

View File

@ -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', () => {

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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