Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-07-11 15:12:13 +00:00
parent 82bcb96b9a
commit 83968ce3f7
79 changed files with 3073 additions and 252 deletions

View File

@ -192,7 +192,6 @@ RSpec/VerifiedDoubleReference:
- 'spec/services/commits/commit_patch_service_spec.rb'
- 'spec/services/error_tracking/issue_update_service_spec.rb'
- 'spec/services/import/github/cancel_project_import_service_spec.rb'
- 'spec/services/import/github/gists_import_service_spec.rb'
- 'spec/services/incident_management/timeline_events/create_service_spec.rb'
- 'spec/services/issues/close_service_spec.rb'
- 'spec/services/merge_requests/after_create_service_spec.rb'

View File

@ -1 +1 @@
19c06cf576e10d1cb8c6d922d6311e6f4d50814e
b6e23a698ef223a575ed12a3e717836a30dcd636

View File

@ -1 +1 @@
37bc7f28335f1ba13198787bbf8194934a59c27f
373688d6b701b7ffea8a0198c3f391acc4b55100

View File

@ -755,4 +755,4 @@ gem 'paper_trail', '~> 16.0', feature_category: :shared
gem "i18n_data", "~> 0.13.1", feature_category: :system_access
gem "gitlab-cloud-connector", "~> 1.20", require: 'gitlab/cloud_connector', feature_category: :plan_provisioning
gem "gitlab-cloud-connector", "~> 1.21", require: 'gitlab/cloud_connector', feature_category: :plan_provisioning

View File

@ -218,7 +218,7 @@
{"name":"gitaly","version":"18.1.0.pre.rc1","platform":"ruby","checksum":"8f65a0c5bb3694c91c9fa4bfa7ceabfc131846b78feed8ee32a744aaacf6e70a"},
{"name":"gitlab","version":"4.19.0","platform":"ruby","checksum":"3f645e3e195dbc24f0834fbf83e8ccfb2056d8e9712b01a640aad418a6949679"},
{"name":"gitlab-chronic","version":"0.10.6","platform":"ruby","checksum":"a244d11a1396d2aac6ae9b2f326adf1605ec1ad20c29f06e8b672047d415a9ac"},
{"name":"gitlab-cloud-connector","version":"1.20.0","platform":"ruby","checksum":"259a703e2c635128a1d259d90f2b38edfaefd0b812332b363ed9cdca3200aa45"},
{"name":"gitlab-cloud-connector","version":"1.21.0","platform":"ruby","checksum":"16eb2a42f223c6c70efc20a8fd9e2bbe4fa91603894daa8a72f354f425a07709"},
{"name":"gitlab-crystalball","version":"1.1.1","platform":"ruby","checksum":"0464a113b0809e0e9fa7c0100bb6634fe38465af95aa04efa49541d64250b8ed"},
{"name":"gitlab-dangerfiles","version":"4.9.2","platform":"ruby","checksum":"d5c050f685d8720f6e70191a7d1216854d860dbdea5b455f87abe7542e005798"},
{"name":"gitlab-experiment","version":"0.9.1","platform":"ruby","checksum":"f230ee742154805a755d5f2539dc44d93cdff08c5bbbb7656018d61f93d01f48"},

View File

@ -735,7 +735,7 @@ GEM
terminal-table (>= 1.5.1)
gitlab-chronic (0.10.6)
numerizer (~> 0.2)
gitlab-cloud-connector (1.20.0)
gitlab-cloud-connector (1.21.0)
activesupport (~> 7.0)
jwt (~> 2.9.3)
gitlab-crystalball (1.1.1)
@ -2151,7 +2151,7 @@ DEPENDENCIES
gitlab-active-context!
gitlab-backup-cli!
gitlab-chronic (~> 0.10.5)
gitlab-cloud-connector (~> 1.20)
gitlab-cloud-connector (~> 1.21)
gitlab-crystalball (~> 1.1.0)
gitlab-dangerfiles (~> 4.9.0)
gitlab-duo-workflow-service-client (~> 0.2)!

View File

@ -218,7 +218,7 @@
{"name":"gitaly","version":"18.1.0.pre.rc1","platform":"ruby","checksum":"8f65a0c5bb3694c91c9fa4bfa7ceabfc131846b78feed8ee32a744aaacf6e70a"},
{"name":"gitlab","version":"4.19.0","platform":"ruby","checksum":"3f645e3e195dbc24f0834fbf83e8ccfb2056d8e9712b01a640aad418a6949679"},
{"name":"gitlab-chronic","version":"0.10.6","platform":"ruby","checksum":"a244d11a1396d2aac6ae9b2f326adf1605ec1ad20c29f06e8b672047d415a9ac"},
{"name":"gitlab-cloud-connector","version":"1.20.0","platform":"ruby","checksum":"259a703e2c635128a1d259d90f2b38edfaefd0b812332b363ed9cdca3200aa45"},
{"name":"gitlab-cloud-connector","version":"1.21.0","platform":"ruby","checksum":"16eb2a42f223c6c70efc20a8fd9e2bbe4fa91603894daa8a72f354f425a07709"},
{"name":"gitlab-crystalball","version":"1.1.1","platform":"ruby","checksum":"0464a113b0809e0e9fa7c0100bb6634fe38465af95aa04efa49541d64250b8ed"},
{"name":"gitlab-dangerfiles","version":"4.9.2","platform":"ruby","checksum":"d5c050f685d8720f6e70191a7d1216854d860dbdea5b455f87abe7542e005798"},
{"name":"gitlab-experiment","version":"0.9.1","platform":"ruby","checksum":"f230ee742154805a755d5f2539dc44d93cdff08c5bbbb7656018d61f93d01f48"},

View File

@ -729,7 +729,7 @@ GEM
terminal-table (>= 1.5.1)
gitlab-chronic (0.10.6)
numerizer (~> 0.2)
gitlab-cloud-connector (1.20.0)
gitlab-cloud-connector (1.21.0)
activesupport (~> 7.0)
jwt (~> 2.9.3)
gitlab-crystalball (1.1.1)
@ -2146,7 +2146,7 @@ DEPENDENCIES
gitlab-active-context!
gitlab-backup-cli!
gitlab-chronic (~> 0.10.5)
gitlab-cloud-connector (~> 1.20)
gitlab-cloud-connector (~> 1.21)
gitlab-crystalball (~> 1.1.0)
gitlab-dangerfiles (~> 4.9.0)
gitlab-duo-workflow-service-client (~> 0.2)!

View File

@ -0,0 +1,239 @@
<script>
import {
GlTable,
GlButton,
GlModal,
GlTooltipDirective,
GlLoadingIcon,
GlSprintf,
} from '@gitlab/ui';
import { createAlert, VARIANT_INFO } from '~/alert';
import { __, s__ } from '~/locale';
import CrudComponent from '~/vue_shared/components/crud_component.vue';
import { duoContextExclusionHelpPath } from '../constants';
import ManageExclusionsDrawer from './manage_exclusions_drawer.vue';
export default {
name: 'ExclusionSettings',
components: {
CrudComponent,
GlTable,
GlButton,
GlModal,
GlLoadingIcon,
GlSprintf,
ManageExclusionsDrawer,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
exclusionRules: {
type: Array,
required: false,
default: () => [],
},
},
i18n: {
title: s__('DuoFeatures|GitLab Duo context exclusions'),
description: s__(
'DuoFeatures|Specify project files and directories that GitLab Duo will not access. Excluded content is never sent to AI models. %{linkStart}learn more%{linkEnd}.',
),
manageExclusions: s__('DuoFeatures|Manage exclusions'),
actionsLabel: __('Actions'),
delete: __('Delete'),
deleteRuleModalTitle: s__('DuoFeatures|Delete exclusion rule?'),
deleteRuleModalText: s__('DuoFeatures|Do you want to delete this exclusion rule?'),
ruleDeletedMessage: s__('DuoFeatures|The exclusion rule was deleted.'),
deleteFailedMessage: s__('DuoFeatures|Failed to delete the exclusion rule. Try again.'),
emptyStateMessage: s__(
'DuoFeatures|No exclusion rules defined. Add a rule to exclude files from GitLab Duo context.',
),
},
data() {
return {
rules: [...this.exclusionRules],
ruleToDelete: null,
isDeleting: false,
isManageDrawerOpen: false,
duoContextExclusionHelpPath,
};
},
computed: {
fields() {
return [
{
key: 'pattern',
label: s__('DuoFeatures|Pattern'),
tdClass: '!gl-align-middle',
},
{
key: 'actions',
label: this.$options.i18n.actionsLabel,
thAlignRight: true,
tdClass: 'gl-text-right !gl-align-middle',
},
];
},
tableItems() {
return this.rules.map((pattern, index) => ({
id: index,
pattern,
isDeleting: false,
}));
},
deleteProps() {
return {
text: this.$options.i18n.delete,
attributes: { category: 'primary', variant: 'danger' },
};
},
cancelProps() {
return {
text: __('Cancel'),
};
},
},
watch: {
exclusionRules: {
handler(newRules) {
this.rules = [...newRules];
},
deep: true,
},
},
methods: {
openManageDrawer() {
this.isManageDrawerOpen = true;
},
confirmDeleteRule(rule) {
this.ruleToDelete = rule;
this.$refs.deleteModal.show();
},
deleteRule() {
if (!this.ruleToDelete) return;
this.isDeleting = true;
const index = this.ruleToDelete.id;
this.rules.splice(index, 1);
this.emitUpdate();
createAlert({
message: this.$options.i18n.ruleDeletedMessage,
variant: VARIANT_INFO,
});
this.isDeleting = false;
this.ruleToDelete = null;
},
emitUpdate() {
this.$emit('update', this.rules);
},
closeManageDrawer() {
this.isManageDrawerOpen = false;
},
saveExclusionRules(rules) {
this.rules = [...rules];
this.emitUpdate();
this.closeManageDrawer();
},
},
};
</script>
<template>
<div>
<crud-component
ref="crudComponent"
:title="$options.i18n.title"
:count="rules.length"
icon="remove"
data-testid="exclusion-settings-crud"
>
<template #description>
<gl-sprintf :message="$options.i18n.description">
<template #link="{ content }">
<a :href="duoContextExclusionHelpPath" target="_blank" rel="noopener noreferrer">{{
content
}}</a>
</template>
</gl-sprintf>
</template>
<template #actions>
<gl-button
variant="default"
data-testid="manage-exclusions-button"
@click="openManageDrawer"
>
{{ $options.i18n.manageExclusions }}
</gl-button>
</template>
<gl-table
:empty-text="$options.i18n.emptyStateMessage"
:fields="fields"
:items="tableItems"
stacked="md"
show-empty
class="b-table-fixed"
data-testid="exclusion-rules-table"
>
<template #cell(pattern)="{ item }">
<code class="gl-font-mono gl-rounded-sm gl-bg-gray-10 gl-px-2 gl-py-1">
{{ item.pattern }}
</code>
</template>
<template #cell(actions)="{ item }">
<div class="table-action-buttons gl-flex gl-justify-end gl-gap-2">
<gl-button
v-gl-tooltip
:disabled="isDeleting"
category="tertiary"
icon="remove"
size="medium"
:title="$options.i18n.delete"
:aria-label="$options.i18n.delete"
data-testid="delete-exclusion-rule"
@click="confirmDeleteRule(item)"
/>
<gl-loading-icon v-show="item.isDeleting" size="sm" :inline="true" />
</div>
</template>
</gl-table>
<gl-modal
ref="deleteModal"
modal-id="delete-exclusion-rule-modal"
:title="$options.i18n.deleteRuleModalTitle"
:action-primary="deleteProps"
:action-cancel="cancelProps"
data-testid="delete-exclusion-rule-modal"
@primary="deleteRule"
>
<div class="well gl-mb-4">
<code
v-if="ruleToDelete"
class="gl-font-mono gl-rounded-sm gl-bg-gray-10 gl-px-2 gl-py-1"
>
{{ ruleToDelete.pattern }}
</code>
</div>
<p>
<gl-sprintf :message="$options.i18n.deleteRuleModalText">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
</gl-modal>
<manage-exclusions-drawer
:open="isManageDrawerOpen"
:exclusion-rules="rules"
@save="saveExclusionRules"
@close="closeManageDrawer"
/>
</crud-component>
</div>
</template>

View File

@ -5,6 +5,7 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, s__ } from '~/locale';
import { duoHelpPath, amazonQHelpPath } from '../constants';
import ProjectSettingRow from './project_setting_row.vue';
import ExclusionSettings from './exclusion_settings.vue';
export default {
i18n: {
@ -17,6 +18,7 @@ export default {
GlButton,
ProjectSettingRow,
CascadingLockIcon,
ExclusionSettings,
},
mixins: [glFeatureFlagMixin()],
props: {
@ -50,11 +52,17 @@ export default {
required: false,
default: false,
},
duoContextExclusionSettings: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
autoReviewEnabled: this.amazonQAutoReviewEnabled,
duoEnabled: this.duoFeaturesEnabled,
exclusionRules: this.duoContextExclusionSettings?.exclusion_rules || [],
};
},
computed: {
@ -76,6 +84,9 @@ export default {
return null;
},
shouldShowExclusionSettings() {
return this.licensedAiFeaturesAvailable && this.showDuoContextExclusion;
},
showCascadingButton() {
return (
this.duoFeaturesLocked &&
@ -83,6 +94,9 @@ export default {
Object.keys(this.cascadingSettingsData).length
);
},
showDuoContextExclusion() {
return this.glFeatures.useDuoContextExclusion;
},
},
watch: {
duoFeaturesEnabled(isEnabled) {
@ -93,6 +107,14 @@ export default {
}
},
},
methods: {
handleExclusionRulesUpdate(rules) {
this.exclusionRules = rules;
this.$nextTick(() => {
this.$el.closest('form')?.submit();
});
},
},
};
</script>
@ -153,6 +175,33 @@ export default {
</div>
</project-setting-row>
<exclusion-settings
v-if="shouldShowExclusionSettings"
:exclusion-rules="exclusionRules"
@update="handleExclusionRulesUpdate"
/>
<!-- Hidden inputs for form submission -->
<div v-if="exclusionRules.length > 0">
<input
v-for="(rule, index) in exclusionRules"
:key="index"
type="hidden"
:name="`project[project_setting_attributes][duo_context_exclusion_settings][exclusion_rules][]`"
:value="rule"
/>
</div>
<!-- need to use a null for empty array due to strong params deep_munge -->
<div v-if="exclusionRules.length === 0">
<input
type="hidden"
:name="`project[project_setting_attributes][duo_context_exclusion_settings][exclusion_rules]`"
data-testid="exclusion-rule-input-null"
:value="null"
/>
</div>
<template #footer>
<gl-button
variant="confirm"

View File

@ -0,0 +1,166 @@
<script>
import { GlDrawer, GlButton, GlFormTextarea, GlAccordion, GlAccordionItem } from '@gitlab/ui';
import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import { s__ } from '~/locale';
export default {
name: 'ManageExclusionsDrawer',
components: {
GlDrawer,
GlButton,
GlFormTextarea,
GlAccordion,
GlAccordionItem,
},
props: {
open: {
type: Boolean,
required: true,
},
exclusionRules: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
localRules: '',
};
},
computed: {
getDrawerHeaderHeight() {
return getContentWrapperHeight();
},
},
watch: {
exclusionRules: {
handler(newRules) {
this.localRules = newRules.join('\n');
},
immediate: true,
},
open(isOpen) {
if (isOpen) {
this.localRules = this.exclusionRules.join('\n');
}
},
},
methods: {
handleSave() {
const rules = this.localRules
.split('\n')
.map((rule) => rule.trim())
.filter((rule) => rule.length > 0);
// Update the exclusion rules in the parent component
this.$emit('save', rules);
},
handleCancel() {
this.localRules = this.exclusionRules.join('\n');
this.$emit('close');
},
},
i18n: {
title: s__('DuoFeatures|Manage Exclusions'),
filesAndDirectoriesLabel: s__('DuoFeatures|Files or directories'),
filesAndDirectoriesHelp: s__('DuoFeatures|Add each exclusion on a separate line.'),
saveExclusions: s__('DuoFeatures|Save exclusions'),
cancel: s__('DuoFeatures|Cancel'),
viewExamples: s__('DuoFeatures|View examples of exclusions.'),
exampleEnvFiles: s__('DuoFeatures|Excludes all .env files'),
exampleSecretsDirectory: s__('DuoFeatures|Excludes entire secrets directory'),
exampleKeyFiles: s__('DuoFeatures|Excludes all .key files in any subdirectory'),
exampleSpecificFile: s__('DuoFeatures|Excludes the specified file'),
exampleAllowFile: s__(
'DuoFeatures|Allows the specified file in the specified directory, even if excluded by previous rules',
),
},
DRAWER_Z_INDEX,
};
</script>
<template>
<gl-drawer
:header-height="getDrawerHeaderHeight"
:z-index="$options.DRAWER_Z_INDEX"
:open="open"
data-testid="manage-exclusions-drawer"
@close="handleCancel"
>
<template #title>
<h2 class="gl-heading-3 gl-mb-0">{{ $options.i18n.title }}</h2>
</template>
<div class="gl-flex gl-flex-col gl-gap-4 gl-p-4">
<div>
<label for="exclusion-rules-textarea" class="gl-mb-2 gl-block gl-font-bold">
{{ $options.i18n.filesAndDirectoriesLabel }}
</label>
<p class="gl-mb-3 gl-text-subtle">
{{ $options.i18n.filesAndDirectoriesHelp }}
</p>
<gl-form-textarea
id="exclusion-rules-textarea"
v-model="localRules"
class="gl-font-monospace"
rows="10"
data-testid="exclusion-rules-textarea"
/>
</div>
<gl-accordion :header-level="3" class="gl-border-t gl-pt-4">
<gl-accordion-item
:title="$options.i18n.viewExamples"
class="gl-font-normal"
data-testid="examples-accordion"
>
<div class="md">
<blockquote>
<ul class="gl-mb-0 gl-p-0">
<li>
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<code class="gl-font-monospace">*.env</code> - {{ $options.i18n.exampleEnvFiles }}
</li>
<li>
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<code class="gl-font-monospace">secrets/</code> -
{{ $options.i18n.exampleSecretsDirectory }}
</li>
<li>
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<code class="gl-font-monospace">**/*.key</code> -
{{ $options.i18n.exampleKeyFiles }}
</li>
<li>
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<code class="gl-font-monospace">config/production.yml</code> -
{{ $options.i18n.exampleSpecificFile }}
</li>
<li>
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<code class="gl-font-monospace">!secrets/file.json</code> -
{{ $options.i18n.exampleAllowFile }}
</li>
</ul>
</blockquote>
</div>
</gl-accordion-item>
</gl-accordion>
<div class="gl-border-t gl-flex gl-gap-3 gl-pt-4">
<gl-button
variant="confirm"
data-testid="save-exclusions-button"
@click.prevent.stop="handleSave"
>
{{ $options.i18n.saveExclusions }}
</gl-button>
<gl-button data-testid="cancel-button" @click="handleCancel">
{{ $options.i18n.cancel }}
</gl-button>
</div>
</div>
</gl-drawer>
</template>

View File

@ -58,6 +58,7 @@ export const extendedPratExpiryWebhooksExecuteHelpPath = helpPagePath(
export const duoHelpPath = helpPagePath('user/ai_features');
export const amazonQHelpPath = helpPagePath('user/duo_amazon_q/_index.md');
export const duoContextExclusionHelpPath = helpPagePath('user/gitlab_duo/context_exclusion');
export const pipelineExecutionPoliciesHelpPath = helpPagePath(
'user/application_security/policies/pipeline_execution_policies',

View File

@ -173,12 +173,10 @@ $merge-request-sticky-header-height: 45px;
&::after {
content: '';
top: $calc-application-header-height;
height: $merge-request-sticky-header-height;
@include translucent-glass-background;
@apply gl-border-b gl-absolute gl-z-0 gl-hidden gl-top-0;
left: calc((100% + var(--application-bar-left) - 100vw) / 2);
width: calc(100vw - var(--application-bar-left));
@apply gl-border-b gl-fixed gl-left-0 gl-right-0 gl-z-0 gl-hidden;
}
@container sticky-header scroll-state(stuck: top) {

View File

@ -0,0 +1,44 @@
# frozen_string_literal: true
module Mutations
module WorkItems
module Hierarchy
class AddChildrenItems < BaseMutation
graphql_name 'WorkItemHierarchyAddChildrenItems'
description "Adds children to a given work item's hierarchy by Global ID."
authorize :read_work_item
argument :children_ids, [::Types::GlobalIDType[::WorkItem]],
required: true,
description: 'Global IDs of children work items.',
loads: ::Types::WorkItemType,
as: :children
argument :id,
::Types::GlobalIDType[::WorkItem],
required: true,
description: 'Global ID of the work item.'
field :added_children, [::Types::WorkItemType],
null: false,
description: 'Work items that were added as children.'
def resolve(id:, **attributes)
Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/408575')
work_item = authorized_find!(id: id)
children = attributes[:children]
update_result = ::WorkItems::ParentLinks::CreateService
.new(work_item, current_user, { issuable_references: children })
.execute
{
added_children: update_result[:created_references]&.map(&:work_item) || [],
errors: Array.wrap(update_result[:message])
}
end
end
end
end
end

View File

@ -248,6 +248,7 @@ module Types
mount_mutation Mutations::WorkItems::LinkedItems::Remove, experiment: { milestone: '16.3' }
mount_mutation Mutations::WorkItems::AddClosingMergeRequest, experiment: { milestone: '17.1' }
mount_mutation Mutations::WorkItems::Hierarchy::Reorder, experiment: { milestone: '17.3' }
mount_mutation Mutations::WorkItems::Hierarchy::AddChildrenItems, experiment: { milestone: '18.2' }
mount_mutation Mutations::WorkItems::BulkUpdate, experiment: { milestone: '17.4' }
mount_mutation Mutations::WorkItems::BulkMove, experiment: { milestone: '18.2' }
mount_mutation Mutations::WorkItems::UserPreference::Update, experiment: { milestone: '17.10' }

View File

@ -4,9 +4,10 @@ module Pages
class LookupPath
include Gitlab::Utils::StrongMemoize
def initialize(deployment:, domain: nil, trim_prefix: nil, access_control: false)
def initialize(deployment:, root_namespace_id:, domain: nil, trim_prefix: nil, access_control: false)
@deployment = deployment
@project = deployment.project
@root_namespace_id = root_namespace_id
@domain = domain
@trim_prefix = trim_prefix || @project.full_path
@access_control = access_control
@ -71,15 +72,9 @@ module Pages
end
strong_memoize_attr :primary_domain
def top_level_namespace_path
project_path = project.full_path
project_path.split('/').first
end
strong_memoize_attr :top_level_namespace_path
private
attr_reader :project, :deployment, :trim_prefix, :domain
attr_reader :project, :root_namespace_id, :deployment, :trim_prefix, :domain
def prefix_value
return deployment.path_prefix if project.pages_url_builder.is_namespace_homepage?

View File

@ -18,7 +18,8 @@ module Pages
end
def lookup_paths
projects.flat_map { |project| lookup_paths_for(project) }
root_namespace_id = @namespace&.root_ancestor&.id
projects.flat_map { |project| lookup_paths_for(project, root_namespace_id) }
end
private
@ -33,10 +34,11 @@ module Pages
@namespace&.pages_access_control_trie&.covered?(project.namespace.traversal_ids)
end
def lookup_paths_for(project)
def lookup_paths_for(project, root_namespace_id)
deployments_for(project).map do |deployment|
Pages::LookupPath.new(
deployment: deployment,
root_namespace_id: root_namespace_id,
trim_prefix: trim_prefix,
domain: domain,
access_control: access_control_for(project)

View File

@ -16,7 +16,11 @@ module Import
start_import
success
rescue Octokit::Unauthorized
error('Access denied to the GitHub account.', 401)
error('Access denied to the GitHub account', 401)
rescue Octokit::TooManyRequests
# Because `check_user_token` method uses octokit directly, it may raise
# the default rate limiting exception rather than our own.
error('GitHub API rate limit exceeded', 429)
end
private

View File

@ -0,0 +1,10 @@
---
name: security_policy_access_token_push_bypass
description: Branch push that is blocked by a security policy is bypassed for configured access token
introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/549644
introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/196249
feature_category: security_policy_management
milestone: '18.2'
saved_to_database: true
scope: [Project]
streamed: true

View File

@ -0,0 +1,10 @@
---
name: security_policy_service_account_push_bypass
description: Branch push that is blocked by a security policy is bypassed for configured service account
introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/549644
introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/196249
feature_category: security_policy_management
milestone: '18.2'
saved_to_database: true
scope: [Project]
streamed: true

View File

@ -18,9 +18,9 @@ Doorkeeper.configure do
# Ensure user is redirected to redirect_uri after login
session[:user_return_to] = request.fullpath
namespace_path = request.query_parameters['top_level_namespace_path']
root_namespace_id = request.query_parameters['root_namespace_id']
resolver = Gitlab::Auth::OAuth::OauthResourceOwnerRedirectResolver.new(namespace_path)
resolver = Gitlab::Auth::OAuth::OauthResourceOwnerRedirectResolver.new(root_namespace_id)
redirect_to(resolver.resolve_redirect_url)
nil
end

View File

@ -3,6 +3,6 @@ migration_job_name: BackfillBulkImportTrackersNamespaceId
description: Backfills sharding key `bulk_import_trackers.namespace_id` from `bulk_import_entities`.
feature_category: importers
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180437
milestone: '17.9'
queued_migration_version: 20250205195337
finalized_by: 20250414075039
milestone: '18.2'
queued_migration_version: 20250709150739
finalized_by: # version of the migration that finalized this BBM

View File

@ -3,6 +3,6 @@ migration_job_name: BackfillBulkImportTrackersOrganizationId
description: Backfills sharding key `bulk_import_trackers.organization_id` from `bulk_import_entities`.
feature_category: importers
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180437
milestone: '17.9'
queued_migration_version: 20250205195342
finalized_by: 20250414075035
milestone: '18.2'
queued_migration_version: 20250709152228
finalized_by: # version of the migration that finalized this BBM

View File

@ -3,6 +3,6 @@ migration_job_name: BackfillBulkImportTrackersProjectId
description: Backfills sharding key `bulk_import_trackers.project_id` from `bulk_import_entities`.
feature_category: importers
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180437
milestone: '17.9'
queued_migration_version: 20250205195332
finalized_by: 20250414075044
milestone: '18.2'
queued_migration_version: 20250709130040
finalized_by: # version of the migration that finalized this BBM

View File

@ -10,31 +10,10 @@ class QueueBackfillBulkImportTrackersProjectId < Gitlab::Database::Migration[2.2
SUB_BATCH_SIZE = 100
def up
queue_batched_background_migration(
MIGRATION,
:bulk_import_trackers,
:id,
:project_id,
:bulk_import_entities,
:project_id,
:bulk_import_entity_id,
job_interval: DELAY_INTERVAL,
batch_size: BATCH_SIZE,
sub_batch_size: SUB_BATCH_SIZE
)
# no-op because the original migration ran but was not successful https://gitlab.com/gitlab-org/gitlab/-/merge_requests/188587#note_2457847947
end
def down
delete_batched_background_migration(
MIGRATION,
:bulk_import_trackers,
:id,
[
:project_id,
:bulk_import_entities,
:project_id,
:bulk_import_entity_id
]
)
# no-op because the original migration ran but was not successful https://gitlab.com/gitlab-org/gitlab/-/merge_requests/188587#note_2457847947
end
end

View File

@ -10,31 +10,10 @@ class QueueBackfillBulkImportTrackersNamespaceId < Gitlab::Database::Migration[2
SUB_BATCH_SIZE = 100
def up
queue_batched_background_migration(
MIGRATION,
:bulk_import_trackers,
:id,
:namespace_id,
:bulk_import_entities,
:namespace_id,
:bulk_import_entity_id,
job_interval: DELAY_INTERVAL,
batch_size: BATCH_SIZE,
sub_batch_size: SUB_BATCH_SIZE
)
# no-op because the original migration ran but was not successful https://gitlab.com/gitlab-org/gitlab/-/merge_requests/188587#note_2457847947
end
def down
delete_batched_background_migration(
MIGRATION,
:bulk_import_trackers,
:id,
[
:namespace_id,
:bulk_import_entities,
:namespace_id,
:bulk_import_entity_id
]
)
# no-op because the original migration ran but was not successful https://gitlab.com/gitlab-org/gitlab/-/merge_requests/188587#note_2457847947
end
end

View File

@ -10,31 +10,10 @@ class QueueBackfillBulkImportTrackersOrganizationId < Gitlab::Database::Migratio
SUB_BATCH_SIZE = 100
def up
queue_batched_background_migration(
MIGRATION,
:bulk_import_trackers,
:id,
:organization_id,
:bulk_import_entities,
:organization_id,
:bulk_import_entity_id,
job_interval: DELAY_INTERVAL,
batch_size: BATCH_SIZE,
sub_batch_size: SUB_BATCH_SIZE
)
# no-op because the original migration ran but was not successful https://gitlab.com/gitlab-org/gitlab/-/merge_requests/188587#note_2457847947
end
def down
delete_batched_background_migration(
MIGRATION,
:bulk_import_trackers,
:id,
[
:organization_id,
:bulk_import_entities,
:organization_id,
:bulk_import_entity_id
]
)
# no-op because the original migration ran but was not successful https://gitlab.com/gitlab-org/gitlab/-/merge_requests/188587#note_2457847947
end
end

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
class RequeueBackfillBulkImportTrackersProjectId < Gitlab::Database::Migration[2.3]
milestone '18.2'
restrict_gitlab_migration gitlab_schema: :gitlab_main_cell
MIGRATION = "BackfillBulkImportTrackersProjectId"
DELAY_INTERVAL = 2.minutes
BATCH_SIZE = 1000
SUB_BATCH_SIZE = 100
def up
begin
delete_batched_background_migration(
MIGRATION,
:bulk_import_trackers,
:id,
[
:project_id,
:bulk_import_entities,
:project_id,
:bulk_import_entity_id
]
)
rescue StandardError => e
# NOTE: It's possible that the BBM was already cleaned as it has been finalized before the requeue.
# We can safely ignore this failure.
Gitlab::AppLogger.warn("Failed to delete batched background migration: #{e.message}")
end
queue_batched_background_migration(
MIGRATION,
:bulk_import_trackers,
:id,
:project_id,
:bulk_import_entities,
:project_id,
:bulk_import_entity_id,
job_interval: DELAY_INTERVAL,
batch_size: BATCH_SIZE,
sub_batch_size: SUB_BATCH_SIZE
)
end
def down
delete_batched_background_migration(
MIGRATION,
:bulk_import_trackers,
:id,
[
:project_id,
:bulk_import_entities,
:project_id,
:bulk_import_entity_id
]
)
end
end

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
class RequeueBackfillBulkImportTrackersNamespaceId < Gitlab::Database::Migration[2.3]
milestone '18.2'
restrict_gitlab_migration gitlab_schema: :gitlab_main_cell
MIGRATION = "BackfillBulkImportTrackersNamespaceId"
DELAY_INTERVAL = 2.minutes
BATCH_SIZE = 1000
SUB_BATCH_SIZE = 100
def up
begin
delete_batched_background_migration(
MIGRATION,
:bulk_import_trackers,
:id,
[
:namespace_id,
:bulk_import_entities,
:namespace_id,
:bulk_import_entity_id
]
)
rescue StandardError => e
# NOTE: It's possible that the BBM was already cleaned as it has been finalized before the requeue.
# We can safely ignore this failure.
Gitlab::AppLogger.warn("Failed to delete batched background migration: #{e.message}")
end
queue_batched_background_migration(
MIGRATION,
:bulk_import_trackers,
:id,
:namespace_id,
:bulk_import_entities,
:namespace_id,
:bulk_import_entity_id,
job_interval: DELAY_INTERVAL,
batch_size: BATCH_SIZE,
sub_batch_size: SUB_BATCH_SIZE
)
end
def down
delete_batched_background_migration(
MIGRATION,
:bulk_import_trackers,
:id,
[
:namespace_id,
:bulk_import_entities,
:namespace_id,
:bulk_import_entity_id
]
)
end
end

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
class RequeueBackfillBulkImportTrackersOrganizationId < Gitlab::Database::Migration[2.3]
milestone '18.2'
restrict_gitlab_migration gitlab_schema: :gitlab_main_cell
MIGRATION = "BackfillBulkImportTrackersOrganizationId"
DELAY_INTERVAL = 2.minutes
BATCH_SIZE = 1000
SUB_BATCH_SIZE = 100
def up
begin
delete_batched_background_migration(
MIGRATION,
:bulk_import_trackers,
:id,
[
:organization_id,
:bulk_import_entities,
:organization_id,
:bulk_import_entity_id
]
)
rescue StandardError => e
# NOTE: It's possible that the BBM was already cleaned as it has been finalized before the requeue.
# We can safely ignore this failure.
Gitlab::AppLogger.warn("Failed to delete batched background migration: #{e.message}")
end
queue_batched_background_migration(
MIGRATION,
:bulk_import_trackers,
:id,
:organization_id,
:bulk_import_entities,
:organization_id,
:bulk_import_entity_id,
job_interval: DELAY_INTERVAL,
batch_size: BATCH_SIZE,
sub_batch_size: SUB_BATCH_SIZE
)
end
def down
delete_batched_background_migration(
MIGRATION,
:bulk_import_trackers,
:id,
[
:organization_id,
:bulk_import_entities,
:organization_id,
:bulk_import_entity_id
]
)
end
end

View File

@ -0,0 +1 @@
36c30919acf486a7116d0db6216de82e46157db4f3cc83e47129fc1edba2accc

View File

@ -0,0 +1 @@
7ce5d87376ba4e9b3b82944b79f49f18d3b38aeffc202d3252b3108885d12300

View File

@ -0,0 +1 @@
d3c8186f8cc6b32877cbb0d7a45d5de841a730fa0ac44c268cce19d56824bb23

View File

@ -2252,6 +2252,30 @@ Input type: `AiCatalogAgentCreateInput`
| <a id="mutationaicatalogagentcreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during the mutation. |
| <a id="mutationaicatalogagentcreateitem"></a>`item` | [`AiCatalogItem`](#aicatalogitem) | Item created. |
### `Mutation.aiCatalogAgentDelete`
{{< details >}}
**Introduced** in GitLab 18.2.
**Status**: Experiment.
{{< /details >}}
Input type: `AiCatalogAgentDeleteInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationaicatalogagentdeleteclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationaicatalogagentdeleteid"></a>`id` | [`AiCatalogItemID!`](#aicatalogitemid) | Global ID of the catalog Agent to delete. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationaicatalogagentdeleteclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationaicatalogagentdeleteerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during the mutation. |
| <a id="mutationaicatalogagentdeletesuccess"></a>`success` | [`Boolean!`](#boolean) | Returns true if catalog Agent was successfully deleted. |
### `Mutation.aiDuoWorkflowCreate`
{{< details >}}
@ -13357,6 +13381,33 @@ Input type: `WorkItemExportInput`
| <a id="mutationworkitemexporterrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during the mutation. |
| <a id="mutationworkitemexportmessage"></a>`message` | [`String`](#string) | Export request result message. |
### `Mutation.workItemHierarchyAddChildrenItems`
Adds children to a given work item's hierarchy by Global ID.
{{< details >}}
**Introduced** in GitLab 18.2.
**Status**: Experiment.
{{< /details >}}
Input type: `WorkItemHierarchyAddChildrenItemsInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationworkitemhierarchyaddchildrenitemschildrenids"></a>`childrenIds` | [`[WorkItemID!]!`](#workitemid) | Global IDs of children work items. |
| <a id="mutationworkitemhierarchyaddchildrenitemsclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemhierarchyaddchildrenitemsid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationworkitemhierarchyaddchildrenitemsaddedchildren"></a>`addedChildren` | [`[WorkItem!]!`](#workitem) | Work items that were added as children. |
| <a id="mutationworkitemhierarchyaddchildrenitemsclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemhierarchyaddchildrenitemserrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during the mutation. |
### `Mutation.workItemRemoveLinkedItems`
Remove items linked to the work item.
@ -36763,6 +36814,7 @@ Project-level settings for product analytics provider.
| <a id="projectdetailedimportstatus"></a>`detailedImportStatus` | [`DetailedImportStatus`](#detailedimportstatus) | Detailed import status of the project. |
| <a id="projectdora"></a>`dora` | [`Dora`](#dora) | Project's DORA metrics. |
| <a id="projectduoagenticchatavailable"></a>`duoAgenticChatAvailable` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Introduced** in GitLab 18.1. **Status**: Experiment. User access to Duo agentic Chat feature. |
| <a id="projectduocontextexclusionsettings"></a>`duoContextExclusionSettings` {{< icon name="warning-solid" >}} | [`DuoContextExclusionSettings`](#duocontextexclusionsettings) | **Introduced** in GitLab 18.2. **Status**: Experiment. Settings for excluding files from Duo context. |
| <a id="projectduofeaturesenabled"></a>`duoFeaturesEnabled` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Introduced** in GitLab 16.9. **Status**: Experiment. Indicates whether GitLab Duo features are enabled for the project. |
| <a id="projectduoworkflowstatuscheck"></a>`duoWorkflowStatusCheck` {{< icon name="warning-solid" >}} | [`DuoWorkflowEnablement`](#duoworkflowenablement) | **Introduced** in GitLab 17.7. **Status**: Experiment. Indicates whether Duo Agent Platform is enabled for the project. |
| <a id="projectexplorecatalogpath"></a>`exploreCatalogPath` {{< icon name="warning-solid" >}} | [`String`](#string) | **Introduced** in GitLab 17.6. **Status**: Experiment. Path to the project catalog resource. |

View File

@ -126,7 +126,7 @@ Before starting the flow, generate the `STATE`, the `CODE_VERIFIER` and the `COD
`/oauth/authorize` page with the following query parameters:
```plaintext
https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=STATE&scope=REQUESTED_SCOPES&code_challenge=CODE_CHALLENGE&code_challenge_method=S256&top_level_namespace_path=TOP_LEVEL_NAMESPACE
https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=STATE&scope=REQUESTED_SCOPES&code_challenge=CODE_CHALLENGE&code_challenge_method=S256&root_namespace_id=ROOT_NAMESPACE_ID
```
This page asks the user to approve the request from the app to access their
@ -134,7 +134,7 @@ Before starting the flow, generate the `STATE`, the `CODE_VERIFIER` and the `COD
redirected back to the specified `REDIRECT_URI`. The [scope parameter](../integration/oauth_provider.md#view-all-authorized-applications)
is a space-separated list of scopes associated with the user.
For example,`scope=read_user+profile` requests the `read_user` and `profile` scopes.
The `top_level_namespace_path` is the top level namespace path associated with the project. This optional parameter
The `root_namespace_id` is the root namespace ID associated with the project. This optional parameter
should be used when [SAML SSO](../user/group/saml_sso/_index.md) is configured for the associated group.
The redirect includes the authorization `code`, for example:
@ -220,7 +220,7 @@ be used as a CSRF token.
`/oauth/authorize` page with the following query parameters:
```plaintext
https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=STATE&scope=REQUESTED_SCOPES&top_level_namespace_path=TOP_LEVEL_NAMESPACE
https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=STATE&scope=REQUESTED_SCOPES&root_namespace_id=ROOT_NAMESPACE_ID
```
This page asks the user to approve the request from the app to access their
@ -228,7 +228,7 @@ be used as a CSRF token.
redirected back to the specified `REDIRECT_URI`. The [scope parameter](../integration/oauth_provider.md#view-all-authorized-applications)
is a space-separated list of scopes associated with the user.
For example,`scope=read_user+profile` requests the `read_user` and `profile` scopes.
The `top_level_namespace_path` is the top level namespace path associated with the project. This optional parameter
The `root_namespace_id` is the root namespace ID associated with the project. This optional parameter
should be used when [SAML SSO](../user/group/saml_sso/_index.md) is configured for the associated group.
The redirect includes the authorization `code`, for example:

View File

@ -144,7 +144,8 @@ Do not add an `H1` heading in Markdown because there can be only one for each pa
### Description lists in Markdown
To define terms or differentiate between options, use description lists.
To define terms or differentiate between options, use description lists. For a list of UI elements,
use a regular [list](#lists) instead of a description list.
Do not mix description lists with other styles.
@ -509,6 +510,8 @@ Use bold for:
- UI elements with a visible label. Match the text and capitalization of the label.
- Navigation paths.
Do not use bold for keywords or emphasis.
UI elements include:
- Buttons
@ -530,16 +533,16 @@ To make text bold, wrap it with double asterisks (`**`). For example:
1. Select **Cancel**.
```
When you use bold format for UI elements or keywords, place any punctuation outside the bold tag.
When you use bold format for UI elements, place any punctuation outside the bold tag.
This rule includes periods, commas, colons, and right-angle brackets (`>`).
The punctuation is part of the sentence structure rather than the UI element or keyword that you're emphasizing.
The punctuation is part of the sentence structure rather than the UI element that you're emphasizing.
Include punctuation in the bold tag when it's part of the UI element or keyword itself.
Include punctuation in the bold tag when it's part of the UI element itself.
For example:
- `**Option**: This an option description.`
- `**Start a review**: This a description of the button that starts a review.`
- `Select **Overview** > **Users**.`
### Inline code
@ -639,6 +642,14 @@ Use lists to present information in a format that is easier to scan.
- Do this other thing.
```
- Do not use [bold](#bold) formatting to define keywords or concepts in a list. Use bold for UI element labels only. For example:
- `**Start a review**: This a description of the button that starts a review.`
- `Offline environments: This is a description of offline environments.`
For keywords and concepts, consider a [reference topic](../topic_types/reference.md) or
[description list](#description-lists-in-markdown) for alternative formatting.
### Choose between an ordered or unordered list
Use ordered lists for a sequence of steps. For example:
@ -781,7 +792,7 @@ To turn on the setting:
- In the UI:
1. In the VS Code menu, go to **Code > Settings > Settings**.
1. In the VS Code menu, go to **Code** > **Settings** > **Settings**.
1. Search for `Limit Last Column Length`.
1. In the **Limit Last Column Length** dropdown list, select **Follow header row length**.
@ -1125,7 +1136,7 @@ elements:
### Names for UI elements
All UI elements [should be **bold**](#bold).
All UI elements [should be **bold**](#bold). The `>` in the navigation path should not be bold.
Guidance for individual UI elements is in [the word list](word_list.md).
@ -1139,7 +1150,7 @@ To open project settings:
```markdown
1. On the left sidebar, select **Search or go to** and find your project.
1. Select **Settings > CI/CD**.
1. Select **Settings** > **CI/CD**.
1. Expand **General pipelines**.
```
@ -1147,7 +1158,7 @@ To open group settings:
```markdown
1. On the left sidebar, select **Search or go to** and find your group.
1. Select **Settings > CI/CD**.
1. Select **Settings** > **CI/CD**.
1. Expand **General pipelines**.
```
@ -1156,7 +1167,7 @@ To open settings for a top-level group:
```markdown
1. On the left sidebar, select **Search or go to** and find your group.
This group must be at the top level.
1. Select **Settings > CI/CD**.
1. Select **Settings** > **CI/CD**.
1. Expand **General pipelines**.
```
@ -1164,7 +1175,7 @@ To open either project or group settings:
```markdown
1. On the left sidebar, select **Search or go to** and find your project or group.
1. Select **Settings > CI/CD**.
1. Select **Settings** > **CI/CD**.
1. Expand **General pipelines**.
```
@ -1184,7 +1195,7 @@ To open the **Admin** area:
```markdown
1. On the left sidebar, at the bottom, select **Admin**.
1. Select **Settings > CI/CD**.
1. Select **Settings** > **CI/CD**.
```
You do not have to repeat `On the left sidebar` in your second step.
@ -1262,7 +1273,7 @@ Use the phrase **Complete the fields**.
For example:
1. On the left sidebar, select **Search or go to** and find your project.
1. Select **Settings > Repository**.
1. Select **Settings** > **Repository**.
1. Expand **Push rules**.
1. Complete the fields.

View File

@ -450,6 +450,7 @@ SECRET_DETECTION_HISTORIC_SCAN: false
SAST_EXCLUDED_ANALYZERS: ''
DEFAULT_SAST_EXCLUDED_PATHS: spec, test, tests, tmp
DS_EXCLUDED_ANALYZERS: ''
SECURE_ENABLE_LOCAL_CONFIGURATION: true
```
In GitLab 16.9 and earlier:

View File

@ -546,6 +546,8 @@ Audit event types belong to the following product categories.
| [`policy_violations_detected`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/193482) | Security policy violation is detected in the merge request | {{< icon name="dotted-circle" >}} No | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/work_items/549811) | Project |
| [`policy_violations_resolved`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/193482) | Security policy violations are resolved in the merge request | {{< icon name="dotted-circle" >}} No | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/issues/549812) | Project |
| [`policy_yaml_invalidated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/196721) | The policy YAML is invalidated in security policy project | {{< icon name="check-circle" >}} Yes | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/work_items/550892) | Project |
| [`security_policy_access_token_push_bypass`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/196249) | Branch push that is blocked by a security policy is bypassed for configured access token | {{< icon name="check-circle" >}} Yes | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/issues/549644) | Project |
| [`security_policy_service_account_push_bypass`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/196249) | Branch push that is blocked by a security policy is bypassed for configured service account | {{< icon name="check-circle" >}} Yes | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/issues/549644) | Project |
### Security testing configuration

View File

@ -0,0 +1,26 @@
---
stage: AI-powered
group: Code Creation
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
title: Control GitLab Duo context exclusion
---
## Exclude context from GitLab Duo
{{< history >}}
- [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/17124) in GitLab 18.1 [with a flag](../../administration/feature_flags/_index.md) named `use_duo_context_exclusion`. Disabled by default.
{{< /history >}}
You can control which project content is included as context for GitLab Duo. Use this to protect sensitive information such as password files and configuration files.
### Manage GitLab Duo context exclusions
1. On the left sidebar, select **Search or go to** and find your project.
1. Select **Settings > General**.
1. Expand **Visibility, project features, permissions**.
1. Under **GitLab Duo**, in the **GitLab Duo Content Exclusions section**, select **Manage exclusions**.
1. Specify which project files and directories are excluded from GitLab Duo context, and select **Save exclusions**.
1. Optional. To delete an existing exclusion, select **Delete** ({{< icon name="remove" >}}) for the appropriate exclusion.
1. Select **Save changes**.

View File

@ -151,18 +151,22 @@ Agentic Chat extends Chat capabilities with the following features:
### Chat feature comparison
| Capability | Chat | Agentic Chat |
|------------|------| -------------|
| Ask general programming questions | Yes | Yes |
| Get answers about currently open file in the editor | Yes | Yes. Provide the path of the file in your question. |
| Provide context about specified files | Yes. Use `/include` to add a file to the conversation. | Yes. Provide the path of the file in your question. |
| Autonomously search project contents | No | Yes |
| Autonomously create files and change files | No | Yes. Ask it to change files. Note, it may overwrite changes that you have made manually and have not committed, yet. |
| Retrieve issues and MRs without specifying IDs | No | Yes. Search by other criteria. For example, an MR or issue's title or assignee. |
| Combine information from multiple sources | No | Yes |
| Analyze pipeline logs | Yes. Requires Duo Enterprise add-on. | Yes |
| Restart a conversation | Yes. Use `/reset`. | Yes. Use `/reset`. |
| Delete a conversation | Yes. Use `/clear`.| No. |
| Capability | Chat | Agentic Chat |
| ------------ |------| ------------- |
| Ask general programming questions | Yes | Yes |
| Get answers about currently open file in the editor | Yes | Yes. Provide the path of the file in your question. |
| Provide context about specified files | Yes. Use `/include` to add a file to the conversation. | Yes. Provide the path of the file in your question. |
| Autonomously search project contents | No | Yes |
| Autonomously create files and change files | No | Yes. Ask it to change files. Note, it may overwrite changes that you have made manually and have not committed, yet. |
| Retrieve issues and MRs without specifying IDs | No | Yes. Search by other criteria. For example, an MR or issue's title or assignee. |
| Combine information from multiple sources | No | Yes |
| Analyze pipeline logs | Yes. Requires Duo Enterprise add-on. | Yes |
| Restart a conversation | Yes. Use `/reset`. | Yes. Use `/reset`. |
| Delete a conversation | Yes. Use `/clear`.| No |
| Create issues and MRs | No | Yes |
| Use Git | No | Yes, IDEs only |
| Run Shell commands | No | Yes, IDEs only |
| Run MCP tools | No | Yes, IDEs only |
## Use cases

View File

@ -27,16 +27,15 @@ You don't need to authenticate if the project or the group is public.
If the project is internal, you must be a registered user on the GitLab instance.
An anonymous user cannot pull packages from an internal project.
To authenticate, you can use:
To authenticate, you can use either:
- A [personal access token](../../profile/personal_access_tokens.md)
- A [personal access token](../../profile/personal_access_tokens.md), [group access token](../../../user/group/settings/group_access_tokens.md), or [project access token](../../../user/project/settings/project_access_tokens.md)
with the scope set to `api`.
- A [deploy token](../../project/deploy_tokens/_index.md) with the scope set to
`read_package_registry`, `write_package_registry`, or both.
- A [CI/CD job token](../../../ci/jobs/ci_job_token.md).
- A [CI/CD job token](../../../ci/jobs/ci_job_token.md) if you want to publish packages with a CI/CD pipeline.
If your organization uses two-factor authentication (2FA), you must use a personal access token with the scope set to `api`.
If you want to publish a package with a CI/CD pipeline, you must use a CI/CD job token.
For more information, review the [guidance on tokens](../package_registry/supported_functionality.md#authenticate-with-the-registry).
Do not use authentication methods other than the methods documented here. Undocumented authentication methods might be removed in the future.

View File

@ -12,22 +12,22 @@ title: Store all of your packages in one GitLab project
{{< /details >}}
You can store all packages in one project's package registry and configure your remote repositories to
Store packages from multiple sources in one project's package registry and configure your remote repositories to
point to this project in GitLab.
Use this approach when you want to:
- Publish packages to GitLab in a different project than where your code is stored
- Publish packages to GitLab in a different project than where your code is stored.
- Group packages together in one project (for example, all npm packages, all packages for a specific
department, or all private packages in the same project)
- Use one remote repository when installing packages for other projects
- Migrate packages from a third-party package registry to a single location in GitLab
- Have CI/CD pipelines build all packages to one project so you can manage packages in the same location
department, or all private packages in the same project).
- Use one remote repository when installing packages for other projects.
- Migrate packages from a third-party package registry to a single location in GitLab.
- Have CI/CD pipelines build all packages to one project so you can manage packages in the same location.
## Example walkthrough
No functionality is specific to this feature. Instead, we're taking advantage of the functionality
of each package management system to publish different package types to the same place.
Use each package management system
to publish different package types in the same place.
- <i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
Watch a video of how to add Maven, npm, and Conan packages to [the same project](https://youtu.be/ui2nNBwN35c).
@ -42,63 +42,68 @@ Let's take a look at how you might create one project to host all of your packag
1. Create an access token for authentication. All package types in the package registry can be published by using:
- A [personal access token](../../profile/personal_access_tokens.md).
- A [group access token](../../../user/group/settings/group_access_tokens.md) or [project access token](../../../user/project/settings/project_access_tokens.md).
- A [CI/CD job token](../../../ci/jobs/ci_job_token.md) (`CI_JOB_TOKEN`) in a CI/CD job.
Any projects publishing packages to this project's registry should be listed
in this project's [job token allowlist](../../../ci/jobs/ci_job_token.md#add-a-group-or-project-to-the-job-token-allowlist).
The project's [job token allowlist](../../../ci/jobs/ci_job_token.md#add-a-group-or-project-to-the-job-token-allowlist) should list any projects publishing to this project's registry.
If the project is private, downloading packages requires authentication as well.
1. Configure your local project and publish the package.
You can upload all types of packages to the same project, or
split things up based on package type or package visibility level.
You can upload all package types to the same project, or
split up packages based on package type or visibility level.
### npm
If you're using npm, create an `.npmrc` file. Add the appropriate URL for publishing
packages to your project. Finally, add a section to your `package.json` file.
For npm packages:
Follow the instructions in the
[GitLab package registry npm documentation](../npm_registry/_index.md#authenticate-to-the-package-registry). After
you do this, you can publish your npm package to your project using `npm publish`, as described in the
[publishing packages](../npm_registry/_index.md#publish-to-gitlab-package-registry) section.
- Create an [`.npmrc` file](../npm_registry/_index.md#with-the-npmrc-file) to configure the registry URL.
- Scope your packages with the `publishConfig` option in the `package.json` file of your project.
- Publish packages with `npm publish`.
For more information, see [npm packages in the package registry](../npm_registry/_index.md).
### Maven
If you are using Maven, you update your `pom.xml` file with distribution sections. These updates include the
appropriate URL for your project, as described in the [GitLab Maven Repository documentation](../maven_repository/_index.md#naming-convention).
Then, you need to add a `settings.xml` file and [include your access token](../maven_repository/_index.md#authenticate-to-the-package-registry).
Now you can [publish Maven packages](../maven_repository/_index.md#publish-a-package) to your project.
For Maven packages:
1. Update your `pom.xml` file with `repository` and `distributionManagement` sections to configure the registry URL.
1. Add a `settings.xml` file and include your access token.
1. Publish packages with `mvn deploy`.
For more information, see [Maven packages in the package registry](../maven_repository/_index.md).
### Conan 1
For Conan 1, you must add GitLab as a Conan registry remote. For instructions, see
[Add the package registry as a Conan remote](../conan_1_repository/_index.md#add-the-package-registry-as-a-conan-remote).
Then, create your package using the plus-separated (`+`) project path as your Conan user. For example,
For Conan 1:
- Add the GitLab package registry as a Conan registry remote.
- [Create your Conan 1 package](build_packages.md#build-a-conan-1-package) using the plus-separated (`+`) project path as your Conan user. For example,
if your project is located at `https://gitlab.com/foo/bar/my-proj`,
[create your Conan package](build_packages.md#conan-1) using `conan create . foo+bar+my-proj/channel`.
`channel` is your package channel (such as `stable` or `beta`).
create your Conan package using `conan create . foo+bar+my-proj/channel`. `channel` is the package channel, such as `beta` or `stable`:
After you create your package, you're ready to [publish your package](../conan_1_repository/_index.md#publish-a-conan-package),
depending on your final package recipe. For example:
```shell
CONAN_LOGIN_USERNAME=<gitlab-username> CONAN_PASSWORD=<personal_access_token> conan upload MyPackage/1.0.0@foo+bar+my-proj/channel --all --remote=gitlab
```
```shell
CONAN_LOGIN_USERNAME=<gitlab-username> CONAN_PASSWORD=<personal_access_token> conan upload MyPackage/1.0.0@foo+bar+my-proj/channel --all --remote=gitlab
```
- Publish your package with `conan upload` or your package recipe.
For more information, see [Conan 1 packages in the package registry](../conan_1_repository/_index.md).
### Conan 2
For Conan 2, you must add GitLab as a Conan registry remote. For instructions, see
[Add the package registry as a Conan remote](../conan_2_repository/_index.md#add-the-package-registry-as-a-conan-remote).
Then, [create your Conan 2 package](build_packages.md#conan-2).
For Conan 2:
After you create your package, you're ready to [publish your package](../conan_2_repository/_index.md#publish-a-conan-2-package),
depending on your final package recipe.
- Add the GitLab package registry as a Conan registry remote.
- [Create your Conan 2 package](build_packages.md#conan-2).
- Publish your package with `conan upload` or your package recipe.
For more information, see [Conan 2 packages in the package registry](../conan_2_repository/_index.md).
### Composer
You can't publish a Composer package outside of its project. An [issue](https://gitlab.com/gitlab-org/gitlab/-/issues/250633)
exists to implement functionality that allows you to publish such packages to other projects.
You can't publish a Composer package outside of its project. Support for publishing Composer packages
in other projects is proposed in [issue 250633](https://gitlab.com/gitlab-org/gitlab/-/issues/250633).
### All other package types

View File

@ -15,6 +15,7 @@ require_relative 'validation/fixers/missing_index'
require_relative 'validation/validators/different_definition_indexes'
require_relative 'validation/validators/extra_indexes'
require_relative 'validation/validators/missing_indexes'
require_relative 'validation/validators/missing_sequences'
require_relative 'validation/validators/extra_table_columns'
require_relative 'validation/validators/missing_table_columns'
@ -35,12 +36,14 @@ require_relative 'validation/sources/connection_adapters/base'
require_relative 'validation/sources/connection_adapters/active_record_adapter'
require_relative 'validation/sources/connection_adapters/pg_adapter'
require_relative 'validation/sources/structure_sql'
require_relative 'validation/sources/sequence_structure_sql_parser'
require_relative 'validation/sources/database'
require_relative 'validation/sources/connection'
require_relative 'validation/schema_objects/base'
require_relative 'validation/schema_objects/column'
require_relative 'validation/schema_objects/index'
require_relative 'validation/schema_objects/sequence'
require_relative 'validation/schema_objects/table'
require_relative 'validation/schema_objects/trigger'
require_relative 'validation/schema_objects/foreign_key'
@ -49,6 +52,8 @@ require_relative 'validation/adapters/column_database_adapter'
require_relative 'validation/adapters/column_structure_sql_adapter'
require_relative 'validation/adapters/foreign_key_database_adapter'
require_relative 'validation/adapters/foreign_key_structure_sql_adapter'
require_relative 'validation/adapters/sequence_database_adapter'
require_relative 'validation/adapters/sequence_structure_sql_adapter'
module Gitlab
module Schema

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
module Gitlab
module Schema
module Validation
module Adapters
class SequenceDatabaseAdapter
def initialize(query_result)
@query_result = query_result
end
def name
return unless query_result['sequence_name']
"#{schema}.#{query_result['sequence_name']}"
end
def column_owner
return unless query_result['owned_by_column']
"#{schema}.#{query_result['owned_by_column']}"
end
def user_owner
query_result['user_owner']
end
def start_value
query_result['start_value']
end
def increment_by
query_result['increment_by']
end
def min_value
query_result['min_value']
end
def max_value
query_result['max_value']
end
def cycle
query_result['cycle']
end
private
attr_reader :query_result
def schema
query_result['schema'] || 'public'
end
end
end
end
end
end

View File

@ -0,0 +1,62 @@
# frozen_string_literal: true
module Gitlab
module Schema
module Validation
module Adapters
class SequenceStructureSqlAdapter
attr_reader :sequence_name, :schema_name
attr_accessor :owner_table, :owner_column, :owner_schema
def initialize(
sequence_name:, schema_name: nil, owner_table: nil,
owner_column: nil, owner_schema: nil)
@sequence_name = sequence_name
@schema_name = schema_name
@owner_table = owner_table
@owner_column = owner_column
@owner_schema = owner_schema
end
# Fully qualified sequence name (schema.sequence_name)
def name
"#{schema}.#{sequence_name}"
end
# Just the column name
def column_name
owner_column
end
def table_name
owner_table
end
# Fully qualified column reference (schema.table.column)
def column_owner
return unless owner_table && owner_column
"#{column_schema}.#{owner_table}.#{owner_column}"
end
# Get the schema this sequence belongs to
def schema
schema_name || owner_schema || 'public'
end
def column_schema
owner_schema || schema_name || 'public'
end
def to_s
"SequenceStructureSqlAdapter(#{name} -> #{column_owner})"
end
def inspect
"#<SequenceStructureSqlAdapter:#{object_id} #{name} -> #{column_owner}>"
end
end
end
end
end
end

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
module Gitlab
module Schema
module Validation
module SchemaObjects
class Sequence < Base
def initialize(adapter)
@adapter = adapter
end
# Sequence should include the schema, as the same name could be used across different schemas
#
# @example public.sequence_name
def name
@name ||= adapter.name
end
# Fully qualified column reference (schema.table.column)
def owner
@owner ||= adapter.column_owner
end
def table_name
@table_name ||= adapter.table_name
end
def statement
"CREATE SEQUENCE #{name}"
end
private
attr_reader :adapter
end
end
end
end
end

View File

@ -27,6 +27,10 @@ module Gitlab
table_map[table_name]
end
def fetch_sequence_by_name(sequence_name)
sequence_map[sequence_name]
end
def index_exists?(index_name)
index = index_map[index_name]
@ -59,6 +63,10 @@ module Gitlab
true
end
def sequence_exists?(sequence_name)
!!fetch_sequence_by_name(sequence_name)
end
def indexes
index_map.values
end
@ -75,6 +83,10 @@ module Gitlab
table_map.values
end
def sequences
sequence_map.values
end
private
attr_reader :connection
@ -113,6 +125,12 @@ module Gitlab
end
end
def sequence_map
@sequence_map ||= fetch_sequences.transform_values! do |stmt|
SchemaObjects::Sequence.new(Adapters::SequenceDatabaseAdapter.new(stmt.first))
end
end
def fetch_tables
# rubocop:disable Rails/SquishedSQLHeredocs
sql = <<~SQL
@ -187,6 +205,44 @@ module Gitlab
connection.exec_query(sql, [connection.current_schema])
end
# Fetch all the sequences
def fetch_sequences
# rubocop:disable Rails/SquishedSQLHeredocs
sql = <<~SQL
SELECT
c.relname AS sequence_name,
n.nspname AS schema,
pg_catalog.pg_get_userbyid(c.relowner) AS user_owner,
s.seqstart AS start_value,
s.seqincrement AS increment_by,
s.seqmin AS min_value,
s.seqmax AS max_value,
s.seqcycle AS cycle,
s.seqcache AS cache_size,
pg_catalog.obj_description(c.oid, 'pg_class') AS comment,
CASE
WHEN d.refobjid IS NOT NULL THEN
ref_class.relname || '.' || ref_attr.attname
ELSE NULL
END AS owned_by_column
FROM pg_catalog.pg_class c
INNER JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_catalog.pg_sequence s ON s.seqrelid = c.oid
LEFT JOIN pg_catalog.pg_depend d ON d.objid = c.oid
AND d.deptype = 'a'
AND d.classid = 'pg_class'::regclass
LEFT JOIN pg_catalog.pg_class ref_class ON ref_class.oid = d.refobjid
LEFT JOIN pg_catalog.pg_attribute ref_attr ON ref_attr.attrelid = d.refobjid
AND ref_attr.attnum = d.refobjsubid
WHERE c.relkind = 'S'
AND n.nspname IN ($1, $2)
ORDER BY c.relname, n.nspname
SQL
# rubocop:enable Rails/SquishedSQLHeredocs
connection.exec_query(sql, schemas).group_by { |seq| "#{seq['schema']}.#{seq['sequence_name']}" }
end
end
end
end

View File

@ -0,0 +1,185 @@
# frozen_string_literal: true
module Gitlab
module Schema
module Validation
module Sources
class SequenceStructureSqlParser
attr_reader :sequences
def initialize(parsed_structure, default_schema_name)
@parsed_structure = parsed_structure
@default_schema_name = default_schema_name
@sequences = {}
end
# Returns a map of sequence name to sequence structure objects
def execute
extract_sequences
@sequences
end
private
attr_reader :parsed_structure, :default_schema_name
def extract_sequences
parsed_structure.tree.stmts.each do |stmt|
case stmt.stmt.node
when :create_seq_stmt
process_create_sequence(stmt.stmt.create_seq_stmt)
when :alter_seq_stmt
process_alter_sequence(stmt.stmt.alter_seq_stmt)
when :alter_table_stmt
process_alter_table(stmt.stmt.alter_table_stmt)
end
end
end
# Process CREATE SEQUENCE SQL queries. For example:
#
# CREATE SEQUENCE web_hook_logs_id_seq
def process_create_sequence(create_seq)
sequence_name = create_seq.sequence.relname
schema_name = resolve_schema_name(create_seq.sequence.schemaname)
full_name = "#{schema_name}.#{sequence_name}"
@sequences[full_name] = ::Gitlab::Schema::Validation::Adapters::SequenceStructureSqlAdapter.new(
sequence_name: sequence_name,
schema_name: schema_name
)
end
# Processes ALTER SEQUENCE SQL queries to extract column owner. For example:
#
# ALTER SEQUENCE ai_code_suggestion_events_id_seq OWNED BY ai_code_suggestion_events.id;
def process_alter_sequence(alter_seq)
sequence_schema = alter_seq.sequence.schemaname
sequence_schema = default_schema_name if sequence_schema == ''
sequence_name = alter_seq.sequence.relname
# Look for OWNED BY option
return unless alter_seq.options
owner_schema = default_schema_name
owner_table = nil
owner_column = nil
alter_seq.options.each do |option|
def_elem = option.def_elem
next unless def_elem.defname == 'owned_by'
next unless def_elem.arg && def_elem.arg.node == :list
owned_by_list = def_elem.arg.list.items
next unless owned_by_list.length >= 2
# Handle schema.table.column or table.column
if owned_by_list.length == 3
owner_schema = owned_by_list[0].string.sval
owner_table = owned_by_list[1].string.sval
owner_column = owned_by_list[2].string.sval
else
owner_table = owned_by_list[0].string.sval
owner_column = owned_by_list[1].string.sval
end
end
full_name = "#{sequence_schema}.#{sequence_name}"
# Update or create sequence with ownership info
existing = @sequences[full_name]
unless existing
warn "Could not find sequence #{full_name} for ALTER SEQUENCE command"
return
end
existing.owner_table = owner_table
existing.owner_column = owner_column
existing.owner_schema = owner_schema
end
# Process ALTER TABLE commands to extract sequence owner. For example:
#
# ALTER TABLE ONLY web_hook_logs ALTER COLUMN id SET DEFAULT nextval('web_hook_logs_id_seq'::regclass);
def process_alter_table(alter_table)
table_name = alter_table.relation.relname
table_schema = resolve_schema_name(alter_table.relation.schemaname)
alter_table.cmds.each do |cmd|
alter_cmd = cmd.alter_table_cmd
# Look for SET DEFAULT nextval(...) commands
next unless alter_cmd.subtype == :AT_ColumnDefault
column_name = alter_cmd.name
sequence_name = extract_sequence_from_default(alter_cmd.def)
sequence_schema, sequence_name = process_sequence_name(sequence_name)
lookup_name = "#{sequence_schema}.#{sequence_name}"
# Update existing sequence or create new one
existing = @sequences[lookup_name]
@sequences[lookup_name] = ::Gitlab::Schema::Validation::Adapters::SequenceStructureSqlAdapter.new(
sequence_name: sequence_name,
schema_name: existing&.schema_name || table_schema,
owner_table: existing&.owner_table || table_name,
owner_column: existing&.owner_column || column_name,
owner_schema: existing&.owner_schema || table_schema
)
end
end
def extract_sequence_from_default(expr)
return nil unless expr
case expr.node
when :func_call
func_call = expr.func_call
return extract_string_from_expr(func_call.args.first) if nextval_func?(func_call)
when :type_cast
return extract_sequence_from_default(expr.type_cast.arg)
end
nil
end
def extract_string_from_expr(expr)
case expr.node
when :a_const
expr.a_const.sval.sval if expr.a_const.val == :sval
when :type_cast
extract_string_from_expr(expr.type_cast.arg)
end
end
def resolve_schema_name(schema_name)
return default_schema_name if schema_name == ''
schema_name
end
def process_sequence_name(sequence_name)
data = sequence_name.split('.', 2)
return default_schema_name, sequence_name if data.length == 1
[data.first, data.last]
end
def nextval_func?(func_call)
return false unless func_call.args.any?
func_call.funcname.any? do |name|
sval = name.string&.sval
next false unless sval
sval.casecmp('nextval').zero?
end
end
end
end
end
end
end

View File

@ -32,6 +32,10 @@ module Gitlab
table_map[table_name]
end
def fetch_sequence_by_name(sequence_name)
sequence_map[sequence_name]
end
def index_exists?(index_name)
!!fetch_index_by_name(index_name)
end
@ -48,6 +52,10 @@ module Gitlab
!!fetch_table_by_name(table_name)
end
def sequence_exists?(sequence_name)
!!fetch_sequence_by_name(sequence_name)
end
def indexes
@indexes ||= map_with_default_schema(index_statements, SchemaObjects::Index)
end
@ -56,6 +64,18 @@ module Gitlab
@triggers ||= map_with_default_schema(trigger_statements, SchemaObjects::Trigger)
end
def sequences
sequence_map.values
end
def sequence_map
@sequences ||= begin
parser = Gitlab::Schema::Validation::Sources::SequenceStructureSqlParser.new(
parsed_structure_file, schema_name)
parser.execute.transform_values! { |sequence| SchemaObjects::Sequence.new(sequence) }
end
end
def foreign_keys
@foreign_keys ||= foreign_key_statements.map do |stmt|
stmt.relation.schemaname = schema_name if stmt.relation.schemaname == ''
@ -132,7 +152,7 @@ module Gitlab
end
def parsed_structure_file
PgQuery.parse(File.read(structure_file_path))
@parsed_structure_file ||= PgQuery.parse(File.read(structure_file_path))
end
def map_with_default_schema(statements, validation_class)

View File

@ -19,6 +19,7 @@ module Gitlab
MissingIndexes,
MissingTriggers,
MissingForeignKeys,
MissingSequences,
DifferentDefinitionTables,
DifferentDefinitionIndexes,
DifferentDefinitionTriggers,

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
module Gitlab
module Schema
module Validation
module Validators
class MissingSequences < Base
ERROR_MESSAGE = "The sequence %s is missing from the database"
def execute
structure_sql.sequences.filter_map do |sequence|
next if database.sequence_exists?(sequence.name)
build_inconsistency(self.class, sequence, nil)
end
end
end
end
end
end
end

View File

@ -1,3 +1,7 @@
CREATE SEQUENCE missing_sequence;
CREATE SEQUENCE shared_audit_event_id_seq;
CREATE SEQUENCE abuse_events_id_seq;
CREATE INDEX missing_index ON events USING btree (created_at, author_id);
CREATE UNIQUE INDEX wrong_index ON table_name (column_name, column_name_2);

View File

@ -0,0 +1,170 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Schema::Validation::Adapters::SequenceDatabaseAdapter do
let(:query_result) do
{
'schema' => 'public',
'sequence_name' => 'users_id_seq',
'owned_by_column' => 'users.id',
'user_owner' => 'gitlab',
'start_value' => '1',
'increment_by' => '1',
'min_value' => '1',
'max_value' => '9223372036854775807',
'cycle' => false
}
end
subject(:adapter) { described_class.new(query_result) }
describe '#name' do
it 'returns formatted sequence name with schema' do
expect(adapter.name).to eq('public.users_id_seq')
end
context 'when schema or sequence_name is nil' do
let(:query_result) { { 'schema' => nil, 'sequence_name' => 'test_seq' } }
it 'defaults to public schema' do
expect(adapter.name).to eq('public.test_seq')
end
end
context 'when both schema and sequence_name are missing' do
let(:query_result) { {} }
it 'returns nil' do
expect(adapter.name).to be_nil
end
end
end
describe '#column_owner' do
it 'returns formatted column owner with schema' do
expect(adapter.column_owner).to eq('public.users.id')
end
context 'when owned_by_column contains table.column format' do
let(:query_result) do
{
'schema' => 'analytics',
'owned_by_column' => 'events.event_id'
}
end
it 'prepends schema correctly' do
expect(adapter.column_owner).to eq('analytics.events.event_id')
end
end
context 'when schema or owned_by_column is nil' do
let(:query_result) { { 'schema' => 'test', 'owned_by_column' => nil } }
it 'returns nil' do
expect(adapter.column_owner).to be_nil
end
end
end
describe '#user_owner' do
it 'returns the user owner' do
expect(adapter.user_owner).to eq('gitlab')
end
context 'when user_owner is nil' do
let(:query_result) { { 'user_owner' => nil } }
it 'returns nil' do
expect(adapter.user_owner).to be_nil
end
end
context 'when user_owner is missing' do
let(:query_result) { {} }
it 'returns nil' do
expect(adapter.user_owner).to be_nil
end
end
end
describe '#start_value' do
it 'returns the start value' do
expect(adapter.start_value).to eq('1')
end
context 'when start_value is different' do
let(:query_result) { { 'start_value' => '100' } }
it 'returns the correct value' do
expect(adapter.start_value).to eq('100')
end
end
end
describe '#increment_by' do
it 'returns the increment value' do
expect(adapter.increment_by).to eq('1')
end
context 'when increment_by is different' do
let(:query_result) { { 'increment_by' => '5' } }
it 'returns the correct value' do
expect(adapter.increment_by).to eq('5')
end
end
end
describe '#min_value' do
it 'returns the minimum value' do
expect(adapter.min_value).to eq('1')
end
context 'when min_value is different' do
let(:query_result) { { 'min_value' => '0' } }
it 'returns the correct value' do
expect(adapter.min_value).to eq('0')
end
end
end
describe '#max_value' do
it 'returns the maximum value' do
expect(adapter.max_value).to eq('9223372036854775807')
end
context 'when max_value is different' do
let(:query_result) { { 'max_value' => '1000' } }
it 'returns the correct value' do
expect(adapter.max_value).to eq('1000')
end
end
end
describe '#cycle' do
it 'returns the cycle value' do
expect(adapter.cycle).to be(false)
end
context 'when cycle is true' do
let(:query_result) { { 'cycle' => true } }
it 'returns true' do
expect(adapter.cycle).to be(true)
end
end
context 'when cycle is nil' do
let(:query_result) { { 'cycle' => nil } }
it 'returns nil' do
expect(adapter.cycle).to be_nil
end
end
end
end

View File

@ -0,0 +1,160 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Schema::Validation::Adapters::SequenceStructureSqlAdapter do
let(:sequence_name) { 'users_id_seq' }
let(:schema_name) { 'public' }
let(:owner_table) { 'users' }
let(:owner_column) { 'id' }
let(:owner_schema) { 'app_schema' }
subject(:sequence_sql_adapter) do
described_class.new(
sequence_name: sequence_name,
schema_name: schema_name,
owner_table: owner_table,
owner_column: owner_column,
owner_schema: owner_schema
)
end
describe '#initialize' do
context 'with all parameters' do
it 'sets all attributes correctly' do
expect(sequence_sql_adapter.sequence_name).to eq(sequence_name)
expect(sequence_sql_adapter.schema_name).to eq(schema_name)
expect(sequence_sql_adapter.owner_table).to eq(owner_table)
expect(sequence_sql_adapter.owner_column).to eq(owner_column)
expect(sequence_sql_adapter.owner_schema).to eq(owner_schema)
end
end
context 'with minimal parameters' do
let(:schema_name) { nil }
let(:owner_table) { nil }
let(:owner_column) { nil }
let(:owner_schema) { nil }
it 'sets sequence_name and defaults others to nil' do
expect(sequence_sql_adapter.sequence_name).to eq(sequence_name)
expect(sequence_sql_adapter.schema_name).to be_nil
expect(sequence_sql_adapter.owner_table).to be_nil
expect(sequence_sql_adapter.owner_column).to be_nil
expect(sequence_sql_adapter.owner_schema).to be_nil
end
end
end
describe '#name' do
context 'when schema_name is present' do
let(:owner_table) { nil }
let(:owner_column) { nil }
let(:owner_schema) { nil }
it 'returns fully qualified sequence name' do
expect(sequence_sql_adapter.name).to eq('public.users_id_seq')
end
end
context 'when schema_name and owner_schema are nil' do
let(:schema_name) { nil }
let(:owner_schema) { nil }
it 'returns sequence name with public schema' do
expect(sequence_sql_adapter.name).to eq('public.users_id_seq')
end
end
end
describe '#column_name' do
it 'returns the owner_column' do
expect(sequence_sql_adapter.column_name).to eq(owner_column)
end
end
describe '#table_name' do
it 'returns the owner_table' do
expect(sequence_sql_adapter.table_name).to eq(owner_table)
end
end
describe '#column_owner' do
context 'when owner_schema, owner_table, and owner_column are all present' do
it 'returns fully qualified column reference' do
expect(sequence_sql_adapter.column_owner).to eq('app_schema.users.id')
end
end
context 'when only owner_table and owner_column are present' do
let(:sequence_name) { nil }
let(:schema_name) { nil }
let(:owner_schema) { nil }
it 'returns table.column format' do
expect(sequence_sql_adapter.column_owner).to eq('public.users.id')
end
end
context 'when only owner_column is present' do
let(:owner_table) { nil }
it 'returns nil' do
expect(sequence_sql_adapter.column_owner).to be_nil
end
end
context 'when no owner information is present' do
let(:owner_table) { nil }
let(:owner_column) { nil }
it 'returns nil' do
expect(sequence_sql_adapter.column_owner).to be_nil
end
end
end
describe '#schema' do
context 'when schema_name is present' do
it 'returns schema_name' do
expect(sequence_sql_adapter.schema).to eq(schema_name)
end
end
context 'when schema_name is nil but owner_schema is present' do
let(:schema_name) { nil }
it 'returns owner_schema' do
expect(sequence_sql_adapter.schema).to eq(owner_schema)
end
end
context 'when both schema_name and owner_schema are nil' do
let(:schema_name) { nil }
let(:owner_schema) { nil }
it 'returns default public schema' do
expect(sequence_sql_adapter.schema).to eq('public')
end
end
context 'when schema_name is present and owner_schema is also present' do
it 'prioritizes schema_name over owner_schema' do
expect(sequence_sql_adapter.schema).to eq(schema_name)
end
end
end
describe '#to_s' do
it 'returns a string representation' do
expect(sequence_sql_adapter.to_s).to eq('SequenceStructureSqlAdapter(public.users_id_seq -> app_schema.users.id)')
end
end
describe '#inspect' do
it 'returns an inspect string with object_id' do
result = sequence_sql_adapter.inspect
expect(result).to match(/^#<SequenceStructureSqlAdapter:\d+ public\.users_id_seq -> app_schema\.users\.id>$/)
end
end
end

View File

@ -0,0 +1,247 @@
# frozen_string_literal: true
require 'spec_helper'
# rubocop:disable Rails/SquishedSQLHeredocs -- This gem does not depend on Rails
RSpec.describe Gitlab::Schema::Validation::Sources::SequenceStructureSqlParser, feature_category: :database do
let(:default_schema_name) { 'public' }
subject(:parser) { described_class.new(parsed_structure, default_schema_name) }
describe '#execute' do
let(:parsed_structure) { PgQuery.parse(sql) }
context 'with CREATE SEQUENCE statements' do
where(:sql, :expected_sequences) do
[
[
'CREATE SEQUENCE public.web_hook_logs_id_seq;',
{ 'public.web_hook_logs_id_seq' => { sequence_name: 'web_hook_logs_id_seq', schema_name: 'public' } }
],
[
'CREATE SEQUENCE web_hook_logs_id_seq;',
{ 'public.web_hook_logs_id_seq' => { sequence_name: 'web_hook_logs_id_seq', schema_name: 'public' } }
],
[
'CREATE SEQUENCE custom_schema.test_seq;',
{ 'custom_schema.test_seq' => { sequence_name: 'test_seq', schema_name: 'custom_schema' } }
]
]
end
with_them do
it 'creates sequences with correct attributes' do
result = parser.execute
expected_sequences.each do |full_name, expected_attrs|
expect(result).to have_key(full_name)
sequence = result[full_name]
expect(sequence).to be_a(Gitlab::Schema::Validation::Adapters::SequenceStructureSqlAdapter)
expect(sequence.sequence_name).to eq(expected_attrs[:sequence_name])
expect(sequence.schema_name).to eq(expected_attrs[:schema_name])
end
end
end
end
context 'with ALTER SEQUENCE OWNED BY statements' do
let(:sql) do
<<~SQL
CREATE SEQUENCE public.ai_code_suggestion_events_id_seq;
ALTER SEQUENCE public.ai_code_suggestion_events_id_seq OWNED BY ai_code_suggestion_events.id;
SQL
end
it 'sets ownership information' do
result = parser.execute
sequence = result['public.ai_code_suggestion_events_id_seq']
expect(sequence.sequence_name).to eq('ai_code_suggestion_events_id_seq')
expect(sequence.schema_name).to eq('public')
expect(sequence.owner_table).to eq('ai_code_suggestion_events')
expect(sequence.owner_column).to eq('id')
expect(sequence.owner_schema).to eq('public')
end
end
context 'with ALTER SEQUENCE OWNED BY with schema.table.column format' do
let(:sql) do
<<~SQL
CREATE SEQUENCE public.test_seq;
ALTER SEQUENCE public.test_seq OWNED BY custom_schema.test_table.test_column;
SQL
end
it 'sets ownership information with custom schema' do
result = parser.execute
sequence = result['public.test_seq']
expect(sequence.owner_table).to eq('test_table')
expect(sequence.owner_column).to eq('test_column')
expect(sequence.owner_schema).to eq('custom_schema')
end
end
context 'with ALTER TABLE SET DEFAULT nextval statements' do
# rubocop:disable Layout/LineLength -- Long SQL statements are unavoidable
where(:sql, :parsed_sequence_name, :expected_sequence_name, :expected_owner_table, :expected_owner_column) do
[
[
"CREATE SEQUENCE public.web_hook_logs_id_seq;
ALTER TABLE ONLY public.web_hook_logs ALTER COLUMN id SET DEFAULT nextval('web_hook_logs_id_seq'::regclass);",
'public.web_hook_logs_id_seq',
'web_hook_logs_id_seq',
'web_hook_logs',
'id'
],
[
"CREATE SEQUENCE public.issues_id_seq;
ALTER TABLE public.issues ALTER COLUMN id SET DEFAULT nextval('public.issues_id_seq'::regclass);",
'public.issues_id_seq',
'issues_id_seq',
'issues',
'id'
],
[
"CREATE SEQUENCE public.test_seq;
ALTER TABLE custom_schema.test_table ALTER COLUMN test_id SET DEFAULT nextval('test_seq'::regclass);",
'public.test_seq',
'test_seq',
'test_table',
'test_id'
]
]
# rubocop:enable Layout/LineLength
end
with_them do
it 'extracts sequence ownership from ALTER TABLE statements' do
result = parser.execute
sequence = result[parsed_sequence_name]
expect(sequence).to be_a(Gitlab::Schema::Validation::Adapters::SequenceStructureSqlAdapter)
expect(sequence.sequence_name).to eq(expected_sequence_name)
expect(sequence.owner_table).to eq(expected_owner_table)
expect(sequence.owner_column).to eq(expected_owner_column)
end
end
end
context 'with combined CREATE and ALTER statements' do
let(:sql) do
<<~SQL
CREATE SEQUENCE public.web_hook_logs_id_seq;
ALTER TABLE ONLY public.web_hook_logs ALTER COLUMN id SET DEFAULT nextval('web_hook_logs_id_seq'::regclass);
ALTER SEQUENCE public.web_hook_logs_id_seq OWNED BY web_hook_logs.id;
SQL
end
it 'processes all statements and merges information' do
result = parser.execute
sequence = result['public.web_hook_logs_id_seq']
expect(sequence.sequence_name).to eq('web_hook_logs_id_seq')
expect(sequence.schema_name).to eq('public')
expect(sequence.owner_table).to eq('web_hook_logs')
expect(sequence.owner_column).to eq('id')
expect(sequence.owner_schema).to eq('public')
end
end
context 'with multiple sequences' do
let(:sql) do
<<~SQL
CREATE SEQUENCE public.users_id_seq;
CREATE SEQUENCE public.projects_id_seq;
ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('users_id_seq'::regclass);
ALTER TABLE ONLY public.projects ALTER COLUMN id SET DEFAULT nextval('projects_id_seq'::regclass);
SQL
end
it 'processes multiple sequences correctly' do
result = parser.execute
expect(result).to have_key('public.users_id_seq')
expect(result).to have_key('public.projects_id_seq')
users_seq = result['public.users_id_seq']
projects_seq = result['public.projects_id_seq']
expect(users_seq.owner_table).to eq('users')
expect(projects_seq.owner_table).to eq('projects')
end
end
context 'with ALTER SEQUENCE on non-existent sequence' do
let(:sql) do
<<~SQL
ALTER SEQUENCE public.non_existent_seq OWNED BY test_table.id;
SQL
end
it 'warns about missing sequence' do
expect(parser).to receive(:warn).with(
'Could not find sequence public.non_existent_seq for ALTER SEQUENCE command')
parser.execute
end
end
context 'with non-sequence statements' do
let(:sql) do
<<~SQL
CREATE TABLE public.test_table (id integer);
CREATE INDEX idx_test ON public.test_table (id);
ALTER TABLE public.test_table ADD COLUMN name varchar(255);
SQL
end
it 'ignores non-sequence statements' do
result = parser.execute
expect(result).to be_empty
end
end
context 'with empty SQL' do
let(:sql) { '' }
it 'retuSQL.squishSQL.squishrns empty hash' do
result = parser.execute
expect(result).to eq({})
end
end
context 'with complex nextval expressions' do
let(:sql) do
<<~SQL
CREATE SEQUENCE public.test_seq;
ALTER TABLE public.test_table ALTER COLUMN id SET DEFAULT nextval(('test_seq'::text)::regclass);
SQL
end
it 'extracts sequence name from complex expressions' do
result = parser.execute
sequence = result['public.test_seq']
expect(sequence.sequence_name).to eq('test_seq')
expect(sequence.owner_table).to eq('test_table')
expect(sequence.owner_column).to eq('id')
end
end
end
describe 'default schema handling' do
context 'with custom default schema' do
let(:default_schema_name) { 'custom_default' }
let(:sql) { 'CREATE SEQUENCE test_seq;' }
let(:parsed_structure) { PgQuery.parse(sql) }
it 'uses custom default schema' do
result = parser.execute
sequence = result['custom_default.test_seq']
expect(sequence.schema_name).to eq('custom_default')
end
end
end
end
# rubocop:enable Rails/SquishedSQLHeredocs

View File

@ -24,6 +24,7 @@ RSpec.describe Gitlab::Schema::Validation::Validators::Base, feature_category: :
Gitlab::Schema::Validation::Validators::MissingIndexes,
Gitlab::Schema::Validation::Validators::MissingTriggers,
Gitlab::Schema::Validation::Validators::MissingForeignKeys,
Gitlab::Schema::Validation::Validators::MissingSequences,
Gitlab::Schema::Validation::Validators::DifferentDefinitionTables,
Gitlab::Schema::Validation::Validators::DifferentDefinitionIndexes,
Gitlab::Schema::Validation::Validators::DifferentDefinitionTriggers,

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Schema::Validation::Validators::MissingSequences, feature_category: :database do
missing_sequences = %w[
public.missing_sequence
public.shared_audit_event_id_seq
public.abuse_events_id_seq
]
include_examples 'sequence validators', described_class, missing_sequences
end

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.shared_examples 'sequence validators' do |validator, expected_result|
let(:structure_file_path) { 'spec/fixtures/structure.sql' }
let(:database_sequences) do
%w[
wrong_sequence
extra_sequence
shared_audit_event_id_seq
]
end
let(:inconsistency_type) { validator.name }
let(:connection_class) { class_double(Class, name: 'ActiveRecord::ConnectionAdapters::PostgreSQLAdapter') }
# rubocop:disable RSpec/VerifiedDoubleReference
let(:connection) do
instance_double('connection', class: connection_class, current_schema: 'public')
end
# rubocop:enable RSpec/VerifiedDoubleReference
let(:schema) { 'public' }
let(:database) { Gitlab::Schema::Validation::Sources::Database.new(connection) }
let(:structure_file) { Gitlab::Schema::Validation::Sources::StructureSql.new(structure_file_path, schema) }
before do
allow(database).to receive(:sequence_exists?) do |sequence_name|
database_sequences.include?(sequence_name)
end
end
subject(:result) { validator.new(structure_file, database).execute }
it 'returns sequence inconsistencies' do
expect(result.map(&:object_name)).to match_array(expected_result)
expect(result.map(&:type)).to all(eql inconsistency_type)
end
end

View File

@ -13,7 +13,7 @@ module API
:unique_host,
:root_directory,
:primary_domain,
:top_level_namespace_path
:root_namespace_id
end
end
end

View File

@ -6,10 +6,10 @@ module Gitlab
class OauthResourceOwnerRedirectResolver
include ::Gitlab::Routing
attr_reader :top_level_namespace_path
attr_reader :root_namespace_id
def initialize(top_level_namespace_path)
@top_level_namespace_path = top_level_namespace_path
def initialize(root_namespace_id)
@root_namespace_id = root_namespace_id
end
def resolve_redirect_url

View File

@ -23984,6 +23984,66 @@ msgstr ""
msgid "DuoEnterprise|You now have access to GitLab Duo Enterprise, which boosts your efficiency and effectiveness by reducing the time required to write and understand code."
msgstr ""
msgid "DuoFeatures|Add each exclusion on a separate line."
msgstr ""
msgid "DuoFeatures|Allows the specified file in the specified directory, even if excluded by previous rules"
msgstr ""
msgid "DuoFeatures|Cancel"
msgstr ""
msgid "DuoFeatures|Delete exclusion rule?"
msgstr ""
msgid "DuoFeatures|Do you want to delete this exclusion rule?"
msgstr ""
msgid "DuoFeatures|Excludes all .env files"
msgstr ""
msgid "DuoFeatures|Excludes all .key files in any subdirectory"
msgstr ""
msgid "DuoFeatures|Excludes entire secrets directory"
msgstr ""
msgid "DuoFeatures|Excludes the specified file"
msgstr ""
msgid "DuoFeatures|Failed to delete the exclusion rule. Try again."
msgstr ""
msgid "DuoFeatures|Files or directories"
msgstr ""
msgid "DuoFeatures|GitLab Duo context exclusions"
msgstr ""
msgid "DuoFeatures|Manage Exclusions"
msgstr ""
msgid "DuoFeatures|Manage exclusions"
msgstr ""
msgid "DuoFeatures|No exclusion rules defined. Add a rule to exclude files from GitLab Duo context."
msgstr ""
msgid "DuoFeatures|Pattern"
msgstr ""
msgid "DuoFeatures|Save exclusions"
msgstr ""
msgid "DuoFeatures|Specify project files and directories that GitLab Duo will not access. Excluded content is never sent to AI models. %{linkStart}learn more%{linkEnd}."
msgstr ""
msgid "DuoFeatures|The exclusion rule was deleted."
msgstr ""
msgid "DuoFeatures|View examples of exclusions."
msgstr ""
msgid "DuoProDiscover|A single platform integrates the best AI model for each use case across the entire development workflow."
msgstr ""

View File

@ -65,7 +65,7 @@
"@gitlab/fonts": "^1.3.0",
"@gitlab/query-language-rust": "0.13.1",
"@gitlab/svgs": "3.138.0",
"@gitlab/ui": "115.0.1",
"@gitlab/ui": "115.4.0",
"@gitlab/vue-router-vue3": "npm:vue-router@4.5.1",
"@gitlab/vuex-vue3": "npm:vuex@4.1.0",
"@gitlab/web-ide": "^0.0.1-dev-20250704091020",

View File

@ -1,30 +1,41 @@
ARG MISE_DATA_DIR=/home/gdk/.local/share/mise
ARG GDK_DIR=/home/gdk/gitlab-development-kit
ARG GDK_SHA=ee1e28c9048d9adba0c575b78adf868eeb70e26f
# Use tag prefix when running on 'stable' branch to make sure 'protected' image is used which is not deleted by registry cleanup
ARG GDK_BASE_TAG_PREFIX
FROM registry.gitlab.com/gitlab-org/gitlab-development-kit/mise-bootstrapped-verify/main:${GDK_BASE_TAG_PREFIX}${GDK_SHA} AS base
ARG MISE_DATA_DIR
ARG GDK_DIR
ENV GITLAB_LICENSE_MODE=test \
GDK_KILL_CONFIRM=true \
TOOL_VERSION_MANAGER=mise
MISE_DATA_DIR=${MISE_DATA_DIR} \
MISE_TRUSTED_CONFIG_PATHS=${GDK_DIR}
# Disable idiomatic version files like .ruby-version by default and only rely on .tool-versions
RUN mise settings add idiomatic_version_file_enable_tools "[]"
# Clone GDK at specific sha and bootstrap packages
# Clone GDK at specific sha
#
ARG GDK_SHA
RUN set -eux; \
git clone --depth 1 https://gitlab.com/gitlab-org/gitlab-development-kit.git && cd gitlab-development-kit; \
git fetch --depth 1 origin ${GDK_SHA} && git -c advice.detachedHead=false checkout ${GDK_SHA}; \
mkdir gitlab \
&& make bootstrap \
&& sudo apt-get autoclean
mkdir gitlab
WORKDIR /home/gdk/gitlab-development-kit
WORKDIR ${GDK_DIR}
COPY --chown=gdk:gdk qa/gdk/gdk.yml ./
# Bootstrap gdk
RUN set -eux; \
make bootstrap \
&& sudo apt-get autoclean \
&& rm -rf ${MISE_DATA_DIR}/downloads \
&& rm -rf ${MISE_DATA_DIR}/installs/ruby/*/lib/ruby/gems/*/cache
# Build gitlab-shell
#
FROM base AS gitlab-shell
@ -32,8 +43,7 @@ FROM base AS gitlab-shell
COPY --chown=gdk:gdk GITLAB_SHELL_VERSION ./gitlab/
RUN make gitlab-shell-setup \
&& cd gitlab-shell \
&& go clean -cache -modcache -r \
&& rm -rf /home/gdk/.local/share/mise/installs/ruby/*/lib/ruby/gems/*/cache
&& go clean -cache -modcache -r
# Build gitlab-workhorse
#
@ -64,44 +74,62 @@ FROM base AS gitlab-gems
COPY --chown=gdk:gdk Gemfile Gemfile.lock .tool-versions ./gitlab/
COPY --chown=gdk:gdk vendor/gems/ ./gitlab/vendor/gems/
COPY --chown=gdk:gdk gems/ ./gitlab/gems/
RUN set -x \
&& make gitlab-asdf-install \
RUN set -eux; \
make gitlab-asdf-install \
&& make .gitlab-bundle \
&& cd gitlab \
&& rm -rf /home/gdk/.local/share/mise/installs/ruby/*/lib/ruby/gems/*/cache
&& rm -rf ${MISE_DATA_DIR}/installs/ruby/*/lib/ruby/gems/*/cache
# Install gitlab npm dependencies
#
FROM base AS gitlab-node-modules
COPY --chown=gdk:gdk package.json yarn.lock ./gitlab/
COPY --chown=gdk:gdk package.json yarn.lock .tool-versions ./gitlab/
COPY --chown=gdk:gdk scripts/frontend/postinstall.js ./gitlab/scripts/frontend/postinstall.js
COPY --chown=gdk:gdk scripts/frontend/preinstall.mjs ./gitlab/scripts/frontend/preinstall.mjs
RUN make .gitlab-yarn && yarn cache clean
# Build gitlab-topology-service
#
FROM base AS gitlab-topology-service
RUN make gitlab-topology-service-setup
# Build gitlab-http-router
#
FROM base AS gitlab-http-router
RUN make gitlab-http-router-setup
# Build final image
#
FROM base AS gdk
ARG GDK_DIR
# Set global defaults so we can initialize empty git repo
RUN git config --global init.defaultBranch master \
&& git config --global user.email "gdk@example.com" \
&& git config --global user.name "gdk"
# Copy all components from separate docker stages
COPY --from=gitlab-shell --chown=gdk:gdk /home/gdk/gitlab-development-kit/gitlab-shell ./gitlab-shell/
COPY --from=gitaly --chown=gdk:gdk /home/gdk/gitlab-development-kit/gitaly ./gitaly/
COPY --from=workhorse --chown=gdk:gdk /home/gdk/gitlab-development-kit/gitlab/workhorse ./gitlab/workhorse/
COPY --from=gitlab-gems --chown=gdk:gdk /home/gdk/.local/share/mise/installs/ruby /home/gdk/.local/share/mise/installs/ruby/
COPY --from=gitlab-node-modules --chown=gdk:gdk /home/gdk/gitlab-development-kit/gitlab/node_modules ./gitlab/node_modules/
COPY --from=gitlab-shell --chown=gdk:gdk ${GDK_DIR}/gitlab-shell ./gitlab-shell/
COPY --from=gitlab-http-router --chown=gdk:gdk ${GDK_DIR}/gitlab-http-router ./gitlab-http-router
COPY --from=gitaly --chown=gdk:gdk ${GDK_DIR}/gitaly ./gitaly/
COPY --from=workhorse --chown=gdk:gdk ${GDK_DIR}/gitlab/workhorse ./gitlab/workhorse/
COPY --from=gitlab-node-modules --chown=gdk:gdk ${GDK_DIR}/gitlab/node_modules ./gitlab/node_modules/
# TODO: Check if skipping setup can be added same way as gitaly and other services, otherwise 'make all' rebuilds topology-service on all rails code changes
# https://gitlab.com/gitlab-org/gitlab-development-kit/-/issues/2802
COPY --from=gitlab-topology-service --chown=gdk:gdk ${GDK_DIR}/gitlab-topology-service ./gitlab-topology-service
# Copy all mise tools and gems for main rails app
COPY --from=gitlab-gems --chown=gdk:gdk ${MISE_DATA_DIR}/installs/ ${MISE_DATA_DIR}/installs/
COPY --from=gitlab-gems --chown=gdk:gdk ${MISE_DATA_DIR}/shims/ ${MISE_DATA_DIR}/shims/
COPY --from=gitlab-gems --chown=gdk:gdk ${MISE_DATA_DIR}/plugins/ ${MISE_DATA_DIR}/plugins/
# Copy code
COPY --chown=gdk:gdk ./ ./gitlab/
COPY --chown=gdk:gdk qa/gdk/entrypoint ../
# Create custom hook for E2E tests
RUN mkdir -p /home/gdk/gitlab-development-kit/gitaly-custom-hooks/pre-receive.d
COPY --chown=gdk:gdk --chmod=700 qa/gdk/pre-receive /home/gdk/gitlab-development-kit/gitaly-custom-hooks/pre-receive.d
RUN mkdir -p ${GDK_DIR}/gitaly-custom-hooks/pre-receive.d
COPY --chown=gdk:gdk --chmod=700 qa/gdk/pre-receive ${GDK_DIR}/gitaly-custom-hooks/pre-receive.d
# Set up GDK
RUN set -eux; \
@ -112,8 +140,6 @@ RUN set -eux; \
gdk config set gitaly.skip_setup true \
&& gdk config set workhorse.skip_setup true \
&& gdk config set gitlab_shell.skip_setup true \
&& cp .ruby-version ./gitlab/ \
&& cp .tool-versions ./gitlab/ \
&& make redis/redis.conf all \
&& gdk kill \
&& rm -rf ./gitlab/.git

View File

@ -1,6 +1,8 @@
---
asdf:
opt_out: true
mise:
enabled: true
hostname: gdk.test
sshd:
additional_config: 'AcceptEnv GIT_PROTOCOL'
@ -29,7 +31,6 @@ gitlab:
- ["*", "default"]
sidekiq_cron:
enabled: true
rails:
bootsnap: false
hostname: gdk.test
@ -49,5 +50,9 @@ tracer:
enabled: false
gitlab_http_router:
enabled: true
mise:
auto_update: false
gitlab_topology_service:
enabled: true
auto_update: false
workhorse:
skip_compile: false

View File

@ -70,9 +70,10 @@
"null"
]
},
"top_level_namespace_path": {
"root_namespace_id": {
"type": [
"string"
"integer",
"null"
]
}
},

View File

@ -0,0 +1,235 @@
import { GlTable } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import CrudComponent from '~/vue_shared/components/crud_component.vue';
import ExclusionSettings from '~/pages/projects/shared/permissions/components/exclusion_settings.vue';
import ManageExclusionsDrawer from '~/pages/projects/shared/permissions/components/manage_exclusions_drawer.vue';
import { createAlert } from '~/alert';
jest.mock('~/alert');
const defaultProps = {
exclusionRules: ['*.log', 'node_modules/', 'secrets.json'],
};
describe('ExclusionSettings', () => {
let wrapper;
const mountComponent = (props = {}, mountFn = mountExtended) => {
const propsData = {
...defaultProps,
...props,
};
return mountFn(ExclusionSettings, {
propsData,
});
};
const findCrudComponent = () =>
wrapper.findByTestId('exclusion-settings-crud').findComponent(CrudComponent);
const findTable = () => wrapper.findByTestId('exclusion-rules-table').findComponent(GlTable);
const findDeleteButtons = () => wrapper.findAllByTestId('delete-exclusion-rule');
const findManageExclusionsButton = () => wrapper.findByTestId('manage-exclusions-button');
const findManageExclusionsDrawer = () => wrapper.findComponent(ManageExclusionsDrawer);
beforeEach(() => {
wrapper = mountComponent();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('rendering', () => {
it('renders the CRUD component with correct props', () => {
const crudComponent = findCrudComponent();
expect(crudComponent.exists()).toBe(true);
expect(crudComponent.props()).toMatchObject({
title: wrapper.vm.$options.i18n.title,
count: 3,
icon: 'remove',
});
});
it('renders table items correctly', () => {
const expectedItems = [
{ id: 0, pattern: '*.log', isDeleting: false },
{ id: 1, pattern: 'node_modules/', isDeleting: false },
{ id: 2, pattern: 'secrets.json', isDeleting: false },
];
expect(wrapper.vm.tableItems).toEqual(expectedItems);
});
it('renders delete buttons for each rule', () => {
const deleteButtons = findDeleteButtons();
expect(deleteButtons).toHaveLength(3);
});
});
describe('empty state', () => {
beforeEach(() => {
wrapper = mountComponent({ exclusionRules: [] });
});
it('shows empty state message when no rules exist', () => {
const table = findTable();
// Check that the empty-text prop is set to the component's i18n message
expect(table.text()).toContain(wrapper.vm.$options.i18n.emptyStateMessage);
expect(findCrudComponent().props('count')).toBe(0);
});
});
describe('deleting rules', () => {
it('opens delete modal when delete button is clicked', async () => {
const deleteButton = findDeleteButtons().at(0);
// Spy on the confirmDeleteRule method
const confirmDeleteRuleSpy = jest.spyOn(wrapper.vm, 'confirmDeleteRule');
await deleteButton.trigger('click');
// Check that confirmDeleteRule was called with the correct item
expect(confirmDeleteRuleSpy).toHaveBeenCalledWith({
id: 0,
pattern: '*.log',
isDeleting: false,
});
});
it('deletes a rule when confirmed in modal', async () => {
// Simulate the confirmDeleteRule method being called
const ruleToDelete = {
id: 1,
pattern: 'node_modules/',
isDeleting: false,
};
wrapper.vm.ruleToDelete = ruleToDelete;
// Confirm deletion by calling the deleteRule method directly
await wrapper.vm.deleteRule();
expect(wrapper.emitted('update')).toHaveLength(1);
expect(wrapper.emitted('update')[0][0]).toEqual([
'*.log',
'secrets.json', // node_modules/ removed
]);
});
it('shows success alert when rule is deleted', async () => {
// Simulate the confirmDeleteRule method being called
const ruleToDelete = {
id: 0,
pattern: '*.log',
isDeleting: false,
};
wrapper.vm.ruleToDelete = ruleToDelete;
// Call deleteRule method directly
await wrapper.vm.deleteRule();
expect(createAlert).toHaveBeenCalledWith({
message: wrapper.vm.$options.i18n.ruleDeletedMessage,
variant: 'info',
});
});
it('shows correct rule in delete modal', async () => {
const deleteButton = findDeleteButtons().at(1);
// Spy on the confirmDeleteRule method
const confirmDeleteRuleSpy = jest.spyOn(wrapper.vm, 'confirmDeleteRule');
await deleteButton.trigger('click');
expect(confirmDeleteRuleSpy).toHaveBeenCalledWith({
id: 1,
pattern: 'node_modules/',
isDeleting: false,
});
});
});
describe('props watching', () => {
it('updates internal rules when exclusionRules prop changes', async () => {
const newRules = ['*.backup', 'cache/'];
await wrapper.setProps({ exclusionRules: newRules });
expect(wrapper.vm.rules).toEqual(newRules);
});
});
describe('manage exclusions drawer', () => {
it('renders the manage exclusions button', () => {
const button = findManageExclusionsButton();
expect(button.exists()).toBe(true);
expect(button.text()).toBe(wrapper.vm.$options.i18n.manageExclusions);
});
it('renders the manage exclusions drawer', () => {
const drawer = findManageExclusionsDrawer();
expect(drawer.exists()).toBe(true);
expect(drawer.props('open')).toBe(false);
expect(drawer.props('exclusionRules')).toEqual(defaultProps.exclusionRules);
});
it('opens the drawer when manage exclusions button is clicked', async () => {
const button = findManageExclusionsButton();
await button.trigger('click');
expect(wrapper.vm.isManageDrawerOpen).toBe(true);
expect(findManageExclusionsDrawer().props('open')).toBe(true);
});
it('closes the drawer when close event is emitted', async () => {
// Open drawer first
await findManageExclusionsButton().trigger('click');
expect(wrapper.vm.isManageDrawerOpen).toBe(true);
// Close drawer
const drawer = findManageExclusionsDrawer();
await drawer.vm.$emit('close');
expect(wrapper.vm.isManageDrawerOpen).toBe(false);
});
it('saves exclusion rules when save event is emitted from drawer', async () => {
const newRules = ['*.tmp', 'build/', 'dist/'];
const drawer = findManageExclusionsDrawer();
await drawer.vm.$emit('save', newRules);
expect(wrapper.vm.rules).toEqual(newRules);
expect(wrapper.emitted('update')).toHaveLength(1);
expect(wrapper.emitted('update')[0][0]).toEqual(newRules);
});
it('closes drawer and shows success message when save is successful', async () => {
const newRules = ['*.tmp', 'build/', 'dist/'];
const drawer = findManageExclusionsDrawer();
// Open drawer first
await findManageExclusionsButton().trigger('click');
expect(wrapper.vm.isManageDrawerOpen).toBe(true);
// Save rules
await drawer.vm.$emit('save', newRules);
expect(wrapper.vm.isManageDrawerOpen).toBe(false);
});
it('passes updated rules to drawer when internal rules change', async () => {
const newRules = ['*.backup', 'cache/'];
await wrapper.setProps({ exclusionRules: newRules });
const drawer = findManageExclusionsDrawer();
expect(drawer.props('exclusionRules')).toEqual(newRules);
});
});
});

View File

@ -1,5 +1,7 @@
import { nextTick } from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import GitlabDuoSettings from '~/pages/projects/shared/permissions/components/gitlab_duo_settings.vue';
import ExclusionSettings from '~/pages/projects/shared/permissions/components/exclusion_settings.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
const defaultProps = {
@ -10,12 +12,15 @@ const defaultProps = {
amazonQAutoReviewEnabled: false,
duoFeaturesLocked: false,
licensedAiFeaturesAvailable: true,
duoContextExclusionSettings: {
exclusion_rules: ['*.log', 'node_modules/'],
},
};
describe('GitlabDuoSettings', () => {
let wrapper;
const mountComponent = (props = {}, mountFn = mountExtended) => {
const mountComponent = (props = {}, provide = {}, mountFn = mountExtended) => {
const propsData = {
...defaultProps,
...props,
@ -23,6 +28,12 @@ describe('GitlabDuoSettings', () => {
return mountFn(GitlabDuoSettings, {
propsData,
provide: {
glFeatures: {
useDuoContextExclusion: true,
...provide,
},
},
});
};
@ -30,11 +41,23 @@ describe('GitlabDuoSettings', () => {
const findSaveButton = () => wrapper.findByTestId('gitlab-duo-save-button');
const findDuoSettings = () => wrapper.findByTestId('duo-settings');
const findDuoCascadingLockIcon = () => wrapper.findByTestId('duo-cascading-lock-icon');
const findExclusionSettings = () => wrapper.findComponent(ExclusionSettings);
const findExclusionRulesHiddenInputs = () =>
wrapper.findAll(
'input[name="project[project_setting_attributes][duo_context_exclusion_settings][exclusion_rules][]"]',
);
beforeEach(() => {
wrapper = mountComponent();
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
it('renders the component correctly', () => {
expect(findCard().exists()).toBe(true);
expect(findSaveButton().exists()).toBe(true);
@ -139,7 +162,7 @@ describe('GitlabDuoSettings', () => {
});
describe('when areDuoSettingsLocked is true', () => {
beforeEach(() => {
it('shows CascadingLockIcon when cascadingSettingsData is provided', () => {
wrapper = mountComponent({
cascadingSettingsData: {
lockedByAncestor: false,
@ -148,13 +171,18 @@ describe('GitlabDuoSettings', () => {
},
duoFeaturesLocked: true,
});
});
it('shows CascadingLockIcon when cascadingSettingsData is provided', () => {
expect(findDuoCascadingLockIcon().exists()).toBe(true);
});
it('passes correct props to CascadingLockIcon', () => {
wrapper = mountComponent({
cascadingSettingsData: {
lockedByAncestor: false,
lockedByApplicationSetting: false,
ancestorNamespace: null,
},
duoFeaturesLocked: true,
});
expect(findDuoCascadingLockIcon().props()).toMatchObject({
isLockedByGroupAncestor: false,
isLockedByApplicationSettings: false,
@ -194,4 +222,149 @@ describe('GitlabDuoSettings', () => {
});
});
});
describe('ExclusionSettings', () => {
it('renders ExclusionSettings component when duo features are available', () => {
wrapper = mountComponent(
{ licensedAiFeaturesAvailable: true },
{ useDuoContextExclusion: true },
);
expect(findExclusionSettings().exists()).toBe(true);
expect(findExclusionSettings().props('exclusionRules')).toEqual(['*.log', 'node_modules/']);
});
it('does not render ExclusionSettings when duo features are not available', () => {
wrapper = mountComponent(
{ licensedAiFeaturesAvailable: false },
{ useDuoContextExclusion: true },
);
expect(findExclusionSettings().exists()).toBe(false);
});
it('does not render ExclusionSettings when feature flag is disabled', () => {
wrapper = mountComponent(
{ licensedAiFeaturesAvailable: true },
{ useDuoContextExclusion: false },
);
expect(findExclusionSettings().exists()).toBe(false);
});
it('updates exclusion rules when ExclusionSettings emits update', async () => {
wrapper = mountComponent(
{ licensedAiFeaturesAvailable: true },
{ useDuoContextExclusion: true },
);
const newRules = ['*.log', 'node_modules/', '*.tmp'];
const exclusionSettings = findExclusionSettings();
expect(exclusionSettings.exists()).toBe(true);
await exclusionSettings.vm.$emit('update', newRules);
expect(wrapper.vm.exclusionRules).toEqual(newRules);
});
it('renders hidden inputs for exclusion rules form submission', () => {
wrapper = mountComponent(
{ licensedAiFeaturesAvailable: true },
{ useDuoContextExclusion: true },
);
const hiddenInputs = findExclusionRulesHiddenInputs();
expect(hiddenInputs).toHaveLength(2);
expect(hiddenInputs.at(0).attributes('value')).toBe('*.log');
expect(hiddenInputs.at(1).attributes('value')).toBe('node_modules/');
});
it('updates hidden inputs when exclusion rules change', async () => {
wrapper = mountComponent(
{ licensedAiFeaturesAvailable: true },
{ useDuoContextExclusion: true },
);
const newRules = ['*.tmp', 'cache/'];
const exclusionSettings = findExclusionSettings();
expect(exclusionSettings.exists()).toBe(true);
await exclusionSettings.vm.$emit('update', newRules);
const hiddenInputs = findExclusionRulesHiddenInputs();
expect(hiddenInputs).toHaveLength(2);
expect(hiddenInputs.at(0).attributes('value')).toBe('*.tmp');
expect(hiddenInputs.at(1).attributes('value')).toBe('cache/');
const nullHiddenInput = wrapper.findByTestId('exclusion-rule-input-null');
expect(nullHiddenInput.exists()).toBe(false);
});
it('handles empty exclusion rules', () => {
wrapper = mountComponent(
{
licensedAiFeaturesAvailable: true,
duoContextExclusionSettings: { exclusion_rules: [] },
},
{ useDuoContextExclusion: true },
);
expect(findExclusionSettings().exists()).toBe(true);
expect(findExclusionSettings().props('exclusionRules')).toEqual([]);
expect(findExclusionRulesHiddenInputs()).toHaveLength(0);
// Check that a null hidden input is created for empty exclusion rules
const nullHiddenInput = wrapper.findByTestId('exclusion-rule-input-null');
expect(nullHiddenInput.exists()).toBe(true);
});
it('handles missing duo context exclusion settings', () => {
wrapper = mountComponent(
{
licensedAiFeaturesAvailable: true,
duoContextExclusionSettings: {},
},
{ useDuoContextExclusion: true },
);
expect(findExclusionSettings().exists()).toBe(true);
expect(findExclusionSettings().props('exclusionRules')).toEqual([]);
});
it('submits form after DOM is updated when exclusion rules are updated', async () => {
// Create a mock form element
const mockForm = document.createElement('form');
const mockSubmit = jest.fn();
mockForm.submit = mockSubmit;
// Mock the closest method to return our mock form
const mockClosest = jest.fn().mockReturnValue(mockForm);
wrapper = mountComponent(
{ licensedAiFeaturesAvailable: true },
{ useDuoContextExclusion: true },
);
// Mock the $el.closest method
wrapper.vm.$el.closest = mockClosest;
const newRules = ['*.log', 'node_modules/', '*.tmp'];
const exclusionSettings = findExclusionSettings();
// Emit the update event
await exclusionSettings.vm.$emit('update', newRules);
// Wait for nextTick to ensure DOM updates are processed
await nextTick();
// Verify that closest was called with 'form'
expect(mockClosest).toHaveBeenCalledWith('form');
// Verify that form.submit() was called
expect(mockSubmit).toHaveBeenCalled();
// Verify that exclusion rules were updated
expect(wrapper.vm.exclusionRules).toEqual(newRules);
});
});
});

View File

@ -0,0 +1,237 @@
import { GlDrawer, GlAccordion, GlAccordionItem } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ManageExclusionsDrawer from '~/pages/projects/shared/permissions/components/manage_exclusions_drawer.vue';
const defaultProps = {
open: true,
exclusionRules: ['*.log', 'node_modules/', 'secrets.json'],
};
describe('ManageExclusionsDrawer', () => {
let wrapper;
const mountComponent = (props = {}, mountFn = mountExtended) => {
const propsData = {
...defaultProps,
...props,
};
return mountFn(ManageExclusionsDrawer, {
propsData,
});
};
const findDrawer = () => wrapper.findComponent(GlDrawer);
const findTextarea = () => wrapper.findByTestId('exclusion-rules-textarea');
const findSaveButton = () => wrapper.findByTestId('save-exclusions-button');
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findAccordion = () => wrapper.findComponent(GlAccordion);
const findAccordionItem = () => wrapper.findComponent(GlAccordionItem);
beforeEach(() => {
wrapper = mountComponent();
});
describe('rendering', () => {
it('renders the drawer with correct props', () => {
const drawer = findDrawer();
expect(drawer.exists()).toBe(true);
expect(drawer.props('open')).toBe(true);
});
it('renders the correct title', () => {
expect(wrapper.text()).toContain('Manage Exclusions');
});
it('renders the textarea with correct label', () => {
const textarea = findTextarea();
expect(textarea.exists()).toBe(true);
expect(wrapper.text()).toContain('Files or directories');
});
it('renders save and cancel buttons', () => {
expect(findSaveButton().exists()).toBe(true);
expect(findCancelButton().exists()).toBe(true);
expect(findSaveButton().text()).toBe('Save exclusions');
expect(findCancelButton().text()).toBe('Cancel');
});
it('renders the examples accordion', () => {
const accordion = findAccordion();
const accordionItem = findAccordionItem();
expect(accordion.exists()).toBe(true);
expect(accordionItem.exists()).toBe(true);
expect(accordionItem.props('title')).toBe('View examples of exclusions.');
});
});
describe('data initialization', () => {
it('initializes local rules from props', () => {
expect(wrapper.vm.localRules).toBe('*.log\nnode_modules/\nsecrets.json');
});
it('handles empty exclusion rules', () => {
wrapper = mountComponent({ exclusionRules: [] });
expect(wrapper.vm.localRules).toBe('');
});
it('updates local rules when exclusion rules prop changes', async () => {
const newRules = ['*.tmp', 'build/'];
await wrapper.setProps({ exclusionRules: newRules });
expect(wrapper.vm.localRules).toBe('*.tmp\nbuild/');
});
it('resets local rules when drawer opens', async () => {
// Change local rules
wrapper.vm.localRules = 'modified content';
// Close and open drawer
await wrapper.setProps({ open: false });
await wrapper.setProps({ open: true });
expect(wrapper.vm.localRules).toBe('*.log\nnode_modules/\nsecrets.json');
});
});
describe('user interactions', () => {
it('updates local rules when textarea content changes', async () => {
const textarea = findTextarea();
const newContent = '*.tmp\nbuild/\ndist/';
await textarea.setValue(newContent);
expect(wrapper.vm.localRules).toBe(newContent);
});
it('emits save event with parsed rules when save button is clicked', async () => {
const textarea = findTextarea();
await textarea.setValue('*.tmp\n\nbuild/\n \ndist/\n');
await findSaveButton().trigger('click');
expect(wrapper.emitted('save')).toHaveLength(1);
expect(wrapper.emitted('save')[0][0]).toEqual(['*.tmp', 'build/', 'dist/']);
});
it('filters out empty lines and trims whitespace when saving', async () => {
const textarea = findTextarea();
await textarea.setValue(' *.tmp \n\n build/ \n\n \n dist/ \n');
await findSaveButton().trigger('click');
expect(wrapper.emitted('save')[0][0]).toEqual(['*.tmp', 'build/', 'dist/']);
});
it('emits close event when cancel button is clicked', async () => {
await findCancelButton().trigger('click');
expect(wrapper.emitted('close')).toHaveLength(1);
});
it('resets local rules to original when cancel is clicked', async () => {
const textarea = findTextarea();
await textarea.setValue('modified content');
await findCancelButton().trigger('click');
expect(wrapper.vm.localRules).toBe('*.log\nnode_modules/\nsecrets.json');
});
it('emits close event when drawer close event is triggered', async () => {
const drawer = findDrawer();
await drawer.vm.$emit('close');
expect(wrapper.emitted('close')).toHaveLength(1);
});
});
describe('examples accordion', () => {
it('shows example patterns when accordion is expanded', () => {
const accordionContent = wrapper.text();
// Check for example descriptions
expect(accordionContent).toContain('Excludes all .env files');
expect(accordionContent).toContain('Excludes entire secrets directory');
expect(accordionContent).toContain('Excludes all .key files in any subdirectory');
expect(accordionContent).toContain('Excludes the specified file');
expect(accordionContent).toContain(
'Allows the specified file in the specified directory, even if excluded by previous rules',
);
});
it('includes specific example patterns', () => {
const accordionContent = wrapper.text();
// Check for specific examples
expect(accordionContent).toContain('*.env');
expect(accordionContent).toContain('secrets/');
expect(accordionContent).toContain('**/*.key');
expect(accordionContent).toContain('config/production.yml');
expect(accordionContent).toContain('!secrets/file.json');
});
});
describe('drawer visibility', () => {
it('shows drawer when open prop is true', () => {
wrapper = mountComponent({ open: true });
expect(findDrawer().props('open')).toBe(true);
});
it('hides drawer when open prop is false', () => {
wrapper = mountComponent({ open: false });
expect(findDrawer().props('open')).toBe(false);
});
});
describe('edge cases', () => {
it('handles single rule correctly', () => {
wrapper = mountComponent({ exclusionRules: ['single-rule.txt'] });
expect(wrapper.vm.localRules).toBe('single-rule.txt');
});
it('handles rules with special characters', () => {
const specialRules = ['*.log', 'path/with spaces/', 'file-with-dashes.txt'];
wrapper = mountComponent({ exclusionRules: specialRules });
expect(wrapper.vm.localRules).toBe('*.log\npath/with spaces/\nfile-with-dashes.txt');
});
it('saves empty rules array when textarea is empty', async () => {
const textarea = findTextarea();
await textarea.setValue('');
await findSaveButton().trigger('click');
expect(wrapper.emitted('save')[0][0]).toEqual([]);
});
it('saves empty rules array when textarea contains only whitespace', async () => {
const textarea = findTextarea();
await textarea.setValue(' \n\n \n ');
await findSaveButton().trigger('click');
expect(wrapper.emitted('save')[0][0]).toEqual([]);
});
});
describe('accessibility', () => {
it('has proper form labels', () => {
const label = wrapper.find('label[for="exclusion-rules-textarea"]');
expect(label.exists()).toBe(true);
expect(label.text()).toContain('Files or directories');
});
it('provides help text for the textarea', () => {
expect(wrapper.text()).toContain('Add each exclusion on a separate line.');
});
});
});

View File

@ -40,6 +40,7 @@ describe('ExtendedDashboardPanel', () => {
it('sets the default props for the dashboard panel', () => {
expect(findDashboardPanel().props()).toStrictEqual({
containerClass: 'grid-stack-item-content',
bodyContentClass: '',
borderColorClass: '',
title: '',
titleIcon: '',

View File

@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe Gitlab::Auth::OAuth::OauthResourceOwnerRedirectResolver, feature_category: :system_access do
let(:resolver) { described_class.new(namespace_path) }
let(:namespace_path) { nil }
let(:resolver) { described_class.new(namespace_id) }
let(:namespace_id) { nil }
let(:group) { create(:group) }
describe '#resolve_redirect_url' do
@ -14,8 +14,8 @@ RSpec.describe Gitlab::Auth::OAuth::OauthResourceOwnerRedirectResolver, feature_
allow(resolver).to receive(:new_user_session_url).and_return('/login')
end
context 'with any namespace path' do
let(:namespace_path) { group.full_path }
context 'with any namespace id' do
let(:namespace_id) { group.id }
it 'returns new_user_session_url' do
expect(resolver).to receive(:new_user_session_url)
@ -23,8 +23,8 @@ RSpec.describe Gitlab::Auth::OAuth::OauthResourceOwnerRedirectResolver, feature_
end
end
context 'with nil namespace path' do
let(:namespace_path) { nil }
context 'with nil namespace id' do
let(:namespace_id) { nil }
it 'returns new_user_session_url' do
expect(resolve_redirect_url).to eq('/login')

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
require 'spec_helper'
require_migration!
RSpec.describe RequeueBackfillBulkImportTrackersProjectId, feature_category: :importers do
let!(:batched_migration) { described_class::MIGRATION }
it 'schedules a new batched migration' do
reversible_migration do |migration|
migration.before -> {
expect(batched_migration).not_to have_scheduled_batched_migration
}
migration.after -> {
expect(batched_migration).to have_scheduled_batched_migration(
table_name: :bulk_import_trackers,
column_name: :id,
interval: described_class::DELAY_INTERVAL,
batch_size: described_class::BATCH_SIZE,
sub_batch_size: described_class::SUB_BATCH_SIZE,
gitlab_schema: :gitlab_main_cell,
job_arguments: [
:project_id,
:bulk_import_entities,
:project_id,
:bulk_import_entity_id
]
)
}
end
end
end

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
require 'spec_helper'
require_migration!
RSpec.describe RequeueBackfillBulkImportTrackersNamespaceId, feature_category: :importers do
let!(:batched_migration) { described_class::MIGRATION }
it 'schedules a new batched migration' do
reversible_migration do |migration|
migration.before -> {
expect(batched_migration).not_to have_scheduled_batched_migration
}
migration.after -> {
expect(batched_migration).to have_scheduled_batched_migration(
table_name: :bulk_import_trackers,
column_name: :id,
interval: described_class::DELAY_INTERVAL,
batch_size: described_class::BATCH_SIZE,
sub_batch_size: described_class::SUB_BATCH_SIZE,
gitlab_schema: :gitlab_main_cell,
job_arguments: [
:namespace_id,
:bulk_import_entities,
:namespace_id,
:bulk_import_entity_id
]
)
}
end
end
end

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
require 'spec_helper'
require_migration!
RSpec.describe RequeueBackfillBulkImportTrackersOrganizationId, feature_category: :importers do
let!(:batched_migration) { described_class::MIGRATION }
it 'schedules a new batched migration' do
reversible_migration do |migration|
migration.before -> {
expect(batched_migration).not_to have_scheduled_batched_migration
}
migration.after -> {
expect(batched_migration).to have_scheduled_batched_migration(
table_name: :bulk_import_trackers,
column_name: :id,
interval: described_class::DELAY_INTERVAL,
batch_size: described_class::BATCH_SIZE,
sub_batch_size: described_class::SUB_BATCH_SIZE,
gitlab_schema: :gitlab_main_cell,
job_arguments: [
:organization_id,
:bulk_import_entities,
:organization_id,
:bulk_import_entity_id
]
)
}
end
end
end

View File

@ -31,6 +31,7 @@ RSpec.describe Pages::LookupPath, feature_category: :pages do
subject(:lookup_path) do
described_class.new(
deployment: deployment,
root_namespace_id: project.namespace.root_ancestor.id,
trim_prefix: trim_prefix,
access_control: access_control
)
@ -52,12 +53,6 @@ RSpec.describe Pages::LookupPath, feature_category: :pages do
end
end
describe '#top_level_namespace_path' do
it 'returns the top level namespace path' do
expect(lookup_path.top_level_namespace_path).to eq(group.path)
end
end
describe '#access_control' do
context 'when access control is enabled' do
let(:access_control) { true }
@ -85,7 +80,9 @@ RSpec.describe Pages::LookupPath, feature_category: :pages do
end
describe '#https_only' do
subject(:lookup_path) { described_class.new(deployment: deployment, domain: domain) }
subject(:lookup_path) do
described_class.new(deployment: deployment, root_namespace_id: project.namespace.root_ancestor.id, domain: domain)
end
context 'when no domain provided' do
let(:domain) { nil }

View File

@ -0,0 +1,160 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'work item add children items', feature_category: :team_planning do
include GraphqlHelpers
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:author) { create(:user, reporter_of: group) }
let_it_be(:guest) { create(:user, guest_of: group) }
let_it_be(:planner) { create(:user, planner_of: group) }
let_it_be(:work_item, refind: true) { create(:work_item, project: project, author: author) }
let_it_be(:valid_child1) { create(:work_item, :task, project: project, created_at: 5.minutes.ago) }
let_it_be(:valid_child2) { create(:work_item, :task, project: project, created_at: 5.minutes.from_now) }
let(:children_ids) { [valid_child1.to_global_id.to_s, valid_child2.to_global_id.to_s] }
let(:input) { { 'childrenIds' => children_ids } }
let(:fields) do
<<~FIELDS
addedChildren {
id
}
errors
FIELDS
end
let(:mutation_work_item) { work_item }
let(:mutation) do
graphql_mutation(:workItemHierarchyAddChildrenItems, input.merge('id' => mutation_work_item.to_gid.to_s), fields)
end
let(:mutation_response) { graphql_mutation_response(:work_item_hierarchy_add_children_items) }
let(:added_children_response) { mutation_response['addedChildren'] }
before_all do
# Ensure support bot user is created so creation doesn't count towards query limit
# and we don't try to obtain an exclusive lease within a transaction.
# See https://gitlab.com/gitlab-org/gitlab/-/issues/509629
Users::Internal.support_bot_id
end
shared_examples 'request with error' do |message|
it 'ignores update and returns an error' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(added_children_response).to be_empty
expect(mutation_response['errors'].first).to include(message)
end
end
context 'when user has permissions to update a work item' do
let(:current_user) { planner }
context 'when updating children' do
let_it_be(:invalid_child) { create(:work_item, project: project) }
let(:error) do
"#{invalid_child.to_reference} cannot be added: it's not allowed to add this type of parent item"
end
context 'when child work item type is invalid' do
let(:children_ids) { [invalid_child.to_global_id.to_s] }
it 'returns response with errors' do
post_graphql_mutation(mutation, current_user: current_user)
expect(added_children_response).to be_empty
expect(mutation_response['errors']).to match_array([error])
end
end
context 'when there is a mix of existing and non existing work items' do
let(:children_ids) { [valid_child1.to_global_id.to_s, "gid://gitlab/WorkItem/#{non_existing_record_id}"] }
it 'returns a top level error and does not add valid work item' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
work_item.reload
end.not_to change { work_item.work_item_children.count }
expect(graphql_errors.first['message']).to include('No object found for `childrenIds')
end
end
context 'when child work item type is valid' do
it 'updates the work item children' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
work_item.reload
end.to change { work_item.work_item_children.count }.by(2)
expect(response).to have_gitlab_http_status(:success)
expect(added_children_response).to match_array([
{ 'id' => valid_child2.to_global_id.to_s },
{ 'id' => valid_child1.to_global_id.to_s }
])
end
end
context 'when updating hierarchy for incident' do
let_it_be(:incident) { create(:work_item, :incident, project: project) }
let_it_be(:child_item) { create(:work_item, :task, project: project) }
let(:mutation_work_item) { incident }
let(:input) do
{ 'childrenIds' => [child_item.to_global_id.to_s] }
end
context 'when user is a guest' do
let(:current_user) { guest }
it 'returns an error and does not update' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
incident.reload
end.not_to change { incident.work_item_children.count }
expect(added_children_response).to be_empty
expect(mutation_response['errors']).to include(
"No matching work item found. Make sure that you are adding a valid work item ID."
)
end
end
context 'when user is an admin' do
let_it_be(:admin) { create(:admin) }
let(:current_user) { admin }
it 'successfully updates the incident hierarchy' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
incident.reload
end.to change { incident.work_item_children.count }.by(1)
expect(response).to have_gitlab_http_status(:success)
expect(added_children_response).to match_array([{ 'id' => child_item.to_global_id.to_s }])
end
end
context 'when guest updates hierarchy for non-incident work item' do
let(:current_user) { guest }
let(:mutation_work_item) { work_item } # regular work item, not incident
it 'successfully updates the work item hierarchy' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
work_item.reload
end.to change { work_item.work_item_children.count }.by(1)
expect(response).to have_gitlab_http_status(:success)
expect(added_children_response).to match_array([{ 'id' => child_item.to_global_id.to_s }])
end
end
end
end
end
end

View File

@ -351,9 +351,10 @@ RSpec.describe API::ImportGithub, feature_category: :importers do
context 'when rate limit reached' do
before do
allow(Import::Github::GistsImportService)
.to receive(:new).with(user, client, access_params)
.and_raise(Gitlab::GithubImport::RateLimitError)
allow_next_instance_of(Import::Github::GistsImportService) do |service|
allow(service).to receive(:execute)
.and_return({ status: :error, message: 'GitHub API rate limit exceeded', http_status: 429 })
end
end
it 'returns 429 error' do

View File

@ -115,7 +115,7 @@ RSpec.describe API::Internal::Pages, feature_category: :pages do
'unique_host' => nil,
'root_directory' => deployment.root_directory,
'primary_domain' => nil,
'top_level_namespace_path' => project.namespace.root_ancestor.path
'root_namespace_id' => project.namespace.root_ancestor.id
}
]
)
@ -186,7 +186,7 @@ RSpec.describe API::Internal::Pages, feature_category: :pages do
'unique_host' => 'unique-domain.example.com',
'root_directory' => 'public',
'primary_domain' => nil,
'top_level_namespace_path' => project.namespace.root_ancestor.path
'root_namespace_id' => project.namespace.root_ancestor.id
}
]
)
@ -232,7 +232,7 @@ RSpec.describe API::Internal::Pages, feature_category: :pages do
'unique_host' => 'unique-domain.example.com',
'root_directory' => 'public',
'primary_domain' => 'https://pages.io',
'top_level_namespace_path' => project.namespace.root_ancestor.path
'root_namespace_id' => project.namespace.root_ancestor.id
}
]
)
@ -284,7 +284,7 @@ RSpec.describe API::Internal::Pages, feature_category: :pages do
'unique_host' => nil,
'root_directory' => 'public',
'primary_domain' => nil,
'top_level_namespace_path' => project.namespace.root_ancestor.path
'root_namespace_id' => project.namespace.root_ancestor.id
}
]
)
@ -444,7 +444,7 @@ RSpec.describe API::Internal::Pages, feature_category: :pages do
'unique_host' => nil,
'root_directory' => 'public',
'primary_domain' => nil,
'top_level_namespace_path' => project.namespace.root_ancestor.path
'root_namespace_id' => project.namespace.root_ancestor.id
}
]
)

View File

@ -7,7 +7,7 @@ RSpec.describe Import::Github::GistsImportService, feature_category: :importers
let_it_be(:user) { create(:user) }
let(:params) { { github_access_token: 'token' } }
let(:import_status) { instance_double('Gitlab::GithubGistsImport::Status') }
let(:import_status) { instance_double(Gitlab::GithubGistsImport::Status) }
let(:client) { Gitlab::GithubImport::Client.new(params[:github_access_token]) }
let(:octokit_user) { { login: 'user_login' } }
@ -56,7 +56,7 @@ RSpec.describe Import::Github::GistsImportService, feature_category: :importers
let(:expected_result) do
{
http_status: 401,
message: 'Access denied to the GitHub account.',
message: 'Access denied to the GitHub account',
status: :error
}
end
@ -65,5 +65,24 @@ RSpec.describe Import::Github::GistsImportService, feature_category: :importers
expect(import.execute).to eq(expected_result)
end
end
context 'when the rate limit is exceeded' do
before do
allow(client.octokit).to receive(:user).and_raise(Octokit::TooManyRequests)
allow(import_status).to receive(:started?).and_return(false)
end
let(:expected_result) do
{
http_status: 429,
message: 'GitHub API rate limit exceeded',
status: :error
}
end
it 'returns 429 error' do
expect(import.execute).to eq(expected_result)
end
end
end
end

View File

@ -1338,21 +1338,13 @@
resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-3.1.1.tgz#af3aea7f1e52ec916d8b5c9dcc0f09d4c060a3fc"
integrity sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==
"@floating-ui/core@^1.7.1", "@floating-ui/core@^1.7.2":
"@floating-ui/core@^1.7.2":
version "1.7.2"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.2.tgz#3d1c35263950b314b6d5a72c8bfb9e3c1551aefd"
integrity sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==
dependencies:
"@floating-ui/utils" "^0.2.10"
"@floating-ui/dom@1.7.1":
version "1.7.1"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.1.tgz#76a4e3cbf7a08edf40c34711cf64e0cc8053d912"
integrity sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==
dependencies:
"@floating-ui/core" "^1.7.1"
"@floating-ui/utils" "^0.2.9"
"@floating-ui/dom@1.7.2", "@floating-ui/dom@^1.0.0", "@floating-ui/dom@^1.7.2":
version "1.7.2"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.2.tgz#3540b051cf5ce0d4f4db5fb2507a76e8ea5b4a45"
@ -1368,7 +1360,7 @@
dependencies:
"@floating-ui/dom" "^1.0.0"
"@floating-ui/utils@^0.2.10", "@floating-ui/utils@^0.2.9":
"@floating-ui/utils@^0.2.10":
version "0.2.10"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c"
integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==
@ -1464,13 +1456,14 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.138.0.tgz#5db6d76ceedcf3716e9ce624b272a58052d8d121"
integrity sha512-Jzd7GhmKxsQdCTttOe6C4AjqGvq8L91N6uUYnAmwnLGeY3aRD12BKBSgId5FrTH6rvk2w36o1+AwIqP+YuHV4g==
"@gitlab/ui@115.0.1":
version "115.0.1"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-115.0.1.tgz#a449a00b1b9352952d542a456cca3a210a02c706"
integrity sha512-gZU8w2W1N36tqDjzzTsH1Mg3xjyVU+ki+2J3bHvw65ovQVF/+A7qRrn2CT07IV0L70zCNOP8RO9VKJKINxpp6A==
"@gitlab/ui@115.4.0":
version "115.4.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-115.4.0.tgz#f5fb265560c0d7bc98e5537c82ca701318be72f0"
integrity sha512-IjeeXHFZdnXIUoGqACdwwAPXTDT7OZdJ2kVPDOTfvc66Yow4OIXRNsZr300Fd3dvItyyDy5f/hCqhbyLMWb9hQ==
dependencies:
"@floating-ui/dom" "1.7.1"
"@floating-ui/dom" "1.7.2"
echarts "^5.6.0"
gridstack "^12.2.1"
iframe-resizer "^4.4.5"
lodash "^4.17.21"
popper.js "^1.16.1"