Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-11-28 18:30:41 +00:00
parent be6b9a238f
commit 44d49505c7
59 changed files with 628 additions and 709 deletions

View File

@ -1,10 +1,4 @@
---
# Cop supports --autocorrect.
Lint/RedundantCopDisableDirective:
# Offense count: 3
# Temporarily disabled due to too many offenses
Enabled: false
Exclude:
- 'ee/app/controllers/admin/gitlab_duo/configuration_controller.rb'
- 'ee/app/presenters/ee/onboarding/status_presenter.rb'
- 'ee/spec/helpers/admin/application_settings_helper_spec.rb'
Details: grace period

View File

@ -40,3 +40,10 @@ export const TRACKING_CATEGORIES = {
tests: 'pipeline_tests_tab',
listbox: 'pipeline_id_iid_listbox',
};
// For pipeline polling
export const PIPELINE_POLL_INTERVAL_DEFAULT = 1000 * 8;
export const PIPELINE_POLL_INTERVAL_BACKOFF = 1.2;
export const FOUR_MINUTES_IN_MS = 1000 * 60 * 4;
export const NETWORK_STATUS_READY = 7;

View File

@ -66,7 +66,3 @@ export const DEFAULT_FIELDS = [
columnClass: 'gl-w-1/5',
},
];
// Pipeline Mini Graph
export const PIPELINE_MINI_GRAPH_POLL_INTERVAL = 5000;

View File

@ -4,6 +4,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__ } from '~/locale';
import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.query.graphql';
import getPipelineStatusQuery from '~/ci/pipeline_editor/graphql/queries/get_pipeline_status.query.graphql';
import getPipelineEtag from '~/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql';
import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@ -59,31 +60,17 @@ export default {
context() {
return getQueryHeaders(this.pipelineEtag);
},
query: getPipelineQuery,
query() {
return this.isUsingPipelineMiniGraphQueries ? getPipelineStatusQuery : getPipelineQuery;
},
variables() {
return {
fullPath: this.projectFullPath,
sha: this.commitSha,
};
},
update(data) {
const {
id,
iid,
commit = {},
detailedStatus = {},
stages,
status,
} = data.project?.pipeline || {};
return {
id,
iid,
commit,
detailedStatus,
stages,
status,
};
update({ project }) {
return project?.pipeline || {};
},
result(res) {
if (res.data?.project?.pipeline) {

View File

@ -0,0 +1,21 @@
query getPipelineStatus($fullPath: ID!, $sha: String!) {
project(fullPath: $fullPath) {
id
pipeline(sha: $sha) {
id
iid
commit {
id
title
webPath
}
detailedStatus {
id
detailsPath
icon
group
text
}
}
}
}

View File

@ -66,6 +66,7 @@ export default {
required: true,
},
},
emits: ['jobActionExecuted'],
data() {
return {
isLoading: false,
@ -107,6 +108,7 @@ export default {
reportToSentry(this.$options.name, error);
} finally {
this.isLoading = false;
this.$emit('jobActionExecuted');
}
},
},

View File

@ -23,6 +23,7 @@ export default {
required: true,
},
},
emits: ['jobActionExecuted'],
computed: {
hasJobAction() {
return Boolean(this.status?.action?.id);
@ -64,6 +65,7 @@ export default {
:job-id="job.id"
:job-action="status.action"
:job-name="job.name"
@jobActionExecuted="$emit('jobActionExecuted')"
/>
</div>
</template>

View File

@ -1,7 +1,7 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import { normalizeDownstreamPipelines, normalizeStages } from '../utils';
import { normalizeDownstreamPipelines, normalizeStages } from '../utils/data_utils';
import DownstreamPipelines from '../downstream_pipelines.vue';
import PipelineStages from '../pipeline_stages.vue';
/**

View File

@ -1,11 +1,13 @@
<script>
import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import Visibility from 'visibilityjs';
import { createAlert } from '~/alert';
import { __ } from '~/locale';
import { reportToSentry } from '~/ci/utils';
import { getIncreasedPollInterval } from '~/ci/utils/polling_utils';
import { NETWORK_STATUS_READY, PIPELINE_POLL_INTERVAL_DEFAULT } from '~/ci/constants';
import { keepLatestDownstreamPipelines } from '~/ci/pipeline_details/utils/parsing_utils';
import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils';
import { PIPELINE_MINI_GRAPH_POLL_INTERVAL } from '~/ci/pipeline_details/constants';
import { getQueryHeaders } from '~/ci/pipeline_details/graph/utils';
import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import getPipelineMiniGraphQuery from './graphql/queries/get_pipeline_mini_graph.query.graphql';
import DownstreamPipelines from './downstream_pipelines.vue';
@ -47,15 +49,11 @@ export default {
required: false,
default: false,
},
pollInterval: {
type: Number,
required: false,
default: PIPELINE_MINI_GRAPH_POLL_INTERVAL,
},
},
emits: ['miniGraphStageClick'],
data() {
return {
pollInterval: PIPELINE_POLL_INTERVAL_DEFAULT,
pipeline: {},
};
},
@ -65,21 +63,29 @@ export default {
return getQueryHeaders(this.pipelineEtag);
},
query: getPipelineMiniGraphQuery,
pollInterval() {
return this.pollInterval;
},
notifyOnNetworkStatusChange: true,
variables() {
return {
fullPath: this.fullPath,
iid: this.iid,
};
},
pollInterval() {
return this.pollInterval;
},
result({ networkStatus }) {
// We need this for handling the reactive poll interval while also using frontend cache
// a status of 7 = 'ready'
if (networkStatus !== NETWORK_STATUS_READY) return;
this.pollInterval = this.increasePollInterval();
},
update({ project }) {
return project?.pipeline || {};
},
error(error) {
createAlert({ message: this.$options.i18n.pipelineMiniGraphFetchError });
reportToSentry(this.$options.name, error);
this.pollInterval = 0;
},
},
},
@ -104,7 +110,28 @@ export default {
},
},
mounted() {
toggleQueryPollingByVisibility(this.$apollo.queries.pipeline);
Visibility.change(() => {
this.handlePolling();
});
},
methods: {
/** Note: cannot use `toggleQueryPollingByVisibility` because interval is dynamic */
handlePolling() {
if (!Visibility.hidden()) {
this.resetPollInterval();
} else {
this.pollInterval = 0;
}
},
increasePollInterval() {
return getIncreasedPollInterval(this.pollInterval);
},
onJobActionExecuted() {
this.resetPollInterval();
},
resetPollInterval() {
this.pollInterval = PIPELINE_POLL_INTERVAL_DEFAULT;
},
},
};
</script>
@ -133,6 +160,7 @@ export default {
<pipeline-stages
:is-merge-train="isMergeTrain"
:stages="pipelineStages"
@jobActionExecuted="onJobActionExecuted"
@miniGraphStageClick="$emit('miniGraphStageClick')"
/>
<gl-icon

View File

@ -3,11 +3,11 @@ import { GlButton, GlDisclosureDropdown, GlLoadingIcon, GlTooltipDirective } fro
import { createAlert } from '~/alert';
import { s__, __, sprintf } from '~/locale';
import { reportToSentry } from '~/ci/utils';
import { PIPELINE_MINI_GRAPH_POLL_INTERVAL } from '~/ci/pipeline_details/constants';
import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils';
import { graphqlEtagStagePath } from '~/ci/pipeline_details/utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { PIPELINE_POLL_INTERVAL_DEFAULT } from '~/ci/constants';
import getPipelineStageJobsQuery from './graphql/queries/get_pipeline_stage_jobs.query.graphql';
import JobItem from './job_item.vue';
@ -36,22 +36,16 @@ export default {
required: false,
default: false,
},
pollInterval: {
type: Number,
required: false,
default: PIPELINE_MINI_GRAPH_POLL_INTERVAL,
},
stage: {
type: Object,
required: true,
},
},
emits: ['miniGraphStageClick'],
emits: ['jobActionExecuted', 'miniGraphStageClick'],
data() {
return {
isDropdownOpen: false,
stageJobs: [],
isPolling: false,
};
},
apollo: {
@ -66,10 +60,10 @@ export default {
};
},
skip() {
return !this.isPolling;
return !this.isDropdownOpen;
},
result() {
this.$apollo.queries.stageJobs.startPolling(this.pollInterval);
pollInterval() {
return this.pollInterval;
},
update(data) {
return data?.ciPipelineStage?.jobs?.nodes || [];
@ -93,6 +87,9 @@ export default {
isLoading() {
return this.$apollo.queries.stageJobs.loading;
},
pollInterval() {
return this.isDropdownOpen ? PIPELINE_POLL_INTERVAL_DEFAULT : 0;
},
},
mounted() {
toggleQueryPollingByVisibility(this.$apollo.queries.stageJobs);
@ -100,11 +97,9 @@ export default {
methods: {
onHideDropdown() {
this.isDropdownOpen = false;
this.isPolling = false;
},
onShowDropdown() {
this.isDropdownOpen = true;
this.isPolling = true;
// used for tracking in the pipeline table
this.$emit('miniGraphStageClick');
@ -159,6 +154,7 @@ export default {
:key="job.id"
:dropdown-length="stageJobs.length"
:job="job"
@jobActionExecuted="$emit('jobActionExecuted')"
/>
</ul>

View File

@ -19,7 +19,7 @@ export default {
required: true,
},
},
emits: ['miniGraphStageClick'],
emits: ['jobActionExecuted', 'miniGraphStageClick'],
};
</script>
<template>
@ -32,6 +32,7 @@ export default {
<pipeline-stage
:stage="stage"
:is-merge-train="isMergeTrain"
@jobActionExecuted="$emit('jobActionExecuted')"
@miniGraphStageClick="$emit('miniGraphStageClick')"
/>
</div>

View File

@ -0,0 +1,7 @@
import { FOUR_MINUTES_IN_MS, PIPELINE_POLL_INTERVAL_BACKOFF } from '~/ci/constants';
export const getIncreasedPollInterval = (currentInterval) => {
const intervalIncreased = PIPELINE_POLL_INTERVAL_BACKOFF * currentInterval;
return intervalIncreased >= FOUR_MINUTES_IN_MS ? FOUR_MINUTES_IN_MS : intervalIncreased;
};

View File

@ -26,6 +26,8 @@ import HighlightWorker from '~/vue_shared/components/source_viewer/workers/highl
import initAmbiguousRefModal from '~/ref/init_ambiguous_ref_modal';
import { InternalEvents } from '~/tracking';
import { initFindFileShortcut } from '~/projects/behaviors';
import initHeaderApp from '~/repository/init_header_app';
import createRouter from '~/repository/router';
Vue.use(Vuex);
Vue.use(VueApollo);
@ -35,8 +37,6 @@ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
const router = new VueRouter({ mode: 'history' });
const viewBlobEl = document.querySelector('#js-view-blob-app');
const initRefSwitcher = () => {
@ -84,6 +84,9 @@ if (viewBlobEl) {
canDownloadCode,
...dataset
} = viewBlobEl.dataset;
const router = createRouter(projectPath, originalBranch);
initHeaderApp({ router, isBlobView: true });
// eslint-disable-next-line no-new
new Vue({
@ -215,7 +218,7 @@ if (treeHistoryLinkEl) {
// eslint-disable-next-line no-new
new Vue({
el: treeHistoryLinkEl,
router,
router: new VueRouter({ mode: 'history' }),
render(h) {
const url = generateHistoryUrl(
historyLink,

View File

@ -28,7 +28,7 @@ if (document.querySelector('.blob-viewer')) {
import(/* webpackChunkName: 'blobViewer' */ '~/blob/viewer')
.then(({ BlobViewer }) => {
new BlobViewer(); // eslint-disable-line no-new
initHeaderApp(true);
initHeaderApp({ isReadmeView: true });
})
.catch(() => {});
}

View File

@ -157,7 +157,7 @@ export default {
</script>
<template>
<div v-if="showBlobControls" class="gl-flex gl-items-baseline gl-gap-3">
<div v-if="showBlobControls" class="gl-flex gl-flex-wrap gl-items-baseline gl-gap-3">
<gl-button
v-gl-tooltip.html="findFileTooltip"
:aria-keyshortcuts="findFileShortcutKey"
@ -181,7 +181,7 @@ export default {
data-testid="history"
:href="blobInfo.historyPath"
:class="$options.buttonClassList"
class="gl-block sm:gl-hidden"
class="sm:gl-hidden"
>
{{ $options.i18n.history }}
</gl-button>

View File

@ -1,6 +1,6 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlDisclosureDropdown, GlModalDirective } from '@gitlab/ui';
import { GlDisclosureDropdown, GlModalDirective, GlLink } from '@gitlab/ui';
import permissionsQuery from 'shared_queries/repository/permissions.query.graphql';
import { joinPaths, escapeFileUrl, buildURLwithRefType } from '~/lib/utils/url_utility';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
@ -19,6 +19,7 @@ export default {
GlDisclosureDropdown,
UploadBlobModal,
NewDirectoryModal,
GlLink,
},
apollo: {
projectShortPath: {
@ -48,6 +49,9 @@ export default {
projectRootPath: {
default: '',
},
isBlobView: {
default: false,
},
},
props: {
currentPath: {
@ -161,15 +165,27 @@ export default {
return acc.concat({
name,
path,
to: buildURLwithRefType({ path: to, refType: this.refType }),
to: !this.isBlobView
? buildURLwithRefType({ path: to, refType: this.refType })
: null,
url: buildURLwithRefType({
path: joinPaths(this.projectPath, to),
refType: this.refType,
}),
});
},
[
{
name: this.projectShortPath,
path: '/',
to: buildURLwithRefType({
path: joinPaths('/-/tree', this.escapedRef),
to: !this.isBlobView
? buildURLwithRefType({
path: joinPaths('/-/tree', this.escapedRef),
refType: this.refType,
})
: null,
url: buildURLwithRefType({
path: joinPaths(this.projectPath, '/-/tree', this.escapedRef),
refType: this.refType,
}),
},
@ -293,9 +309,10 @@ export default {
>
<ol class="breadcrumb repo-breadcrumb">
<li v-for="(link, i) in pathLinks" :key="i" class="breadcrumb-item">
<router-link :to="link.to" :aria-current="isLast(i) ? 'page' : null">
{{ link.name }}
</router-link>
<gl-link :to="link.to" :href="link.url" :aria-current="isLast(i) ? 'page' : null">
<strong v-if="isLast(i)">{{ link.name }}</strong>
<span v-else>{{ link.name }}</span>
</gl-link>
</li>
<li v-if="renderAddToTreeDropdown" class="breadcrumb-item">
<gl-disclosure-dropdown

View File

@ -207,7 +207,7 @@ export default function setupVueRepositoryList() {
});
};
initHeaderApp();
initHeaderApp({ router });
initCodeDropdown();
initLastCommitApp();
initBlobControlsApp();

View File

@ -1,10 +1,35 @@
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import apolloProvider from './graphql';
import projectShortPathQuery from './queries/project_short_path.query.graphql';
import projectPathQuery from './queries/project_path.query.graphql';
import HeaderArea from './components/header_area.vue';
import createRouter from './router';
import refsQuery from './queries/ref.query.graphql';
export default function initHeaderApp(isReadmeView = false) {
const initClientQueries = ({ projectPath, projectShortPath, ref, escapedRef }) => {
// These queries are used in the breadcrumbs component as GraphQL client queries.
if (projectPath)
apolloProvider.clients.defaultClient.cache.writeQuery({
query: projectPathQuery,
data: { projectPath },
});
if (projectShortPath)
apolloProvider.clients.defaultClient.cache.writeQuery({
query: projectShortPathQuery,
data: { projectShortPath },
});
if (ref || escapedRef)
apolloProvider.clients.defaultClient.cache.writeQuery({
query: refsQuery,
data: { ref, escapedRef },
});
};
export default function initHeaderApp({ router, isReadmeView = false, isBlobView = false }) {
const headerEl = document.getElementById('js-repository-blob-header-app');
if (headerEl) {
const {
@ -27,8 +52,11 @@ export default function initHeaderApp(isReadmeView = false) {
projectRootPath,
comparePath,
projectPath,
projectShortPath,
} = headerEl.dataset;
initClientQueries({ projectPath, projectShortPath, ref, escapedRef });
// eslint-disable-next-line no-new
new Vue({
el: headerEl,
@ -47,11 +75,13 @@ export default function initHeaderApp(isReadmeView = false) {
uploadPath: breadcrumbsUploadPath,
newDirPath: breadcrumbsNewDirPath,
projectRootPath,
projectShortPath,
comparePath,
isReadmeView,
isBlobView,
},
apolloProvider,
router: createRouter(projectPath, escapedRef),
router: router || createRouter(projectPath, escapedRef),
render(h) {
return h(HeaderArea, {
props: {

View File

@ -4,9 +4,8 @@ import { n__, __ } from '~/locale';
import { createAlert } from '~/alert';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_CI_PIPELINE } from '~/graphql_shared/constants';
import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils';
import { getQueryHeaders } from '~/ci/pipeline_details/graph/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { PIPELINE_MINI_GRAPH_POLL_INTERVAL } from '~/ci/pipeline_details/constants';
import MergeRequestStore from '../stores/mr_widget_store';
import getMergePipeline from '../queries/get_merge_pipeline.query.graphql';
import ArtifactsApp from './artifacts_list_app.vue';
@ -64,7 +63,6 @@ export default {
id: convertToGraphQLId(TYPENAME_CI_PIPELINE, this.mr.mergePipeline.id),
};
},
pollInterval: PIPELINE_MINI_GRAPH_POLL_INTERVAL,
update({ project }) {
return project?.pipeline || {};
},
@ -123,11 +121,6 @@ export default {
return this.isPostMerge ? this.mr?.mergePipeline?.details?.status?.text : this.mr.ciStatus;
},
},
mounted() {
if (this.useMergePipelineQuery) {
toggleQueryPollingByVisibility(this.$apollo.queries.mergePipeline);
}
},
};
</script>
<template>

View File

@ -12,23 +12,25 @@ export default {
<template>
<div>
<div
class="gl-my-5 gl-flex gl-flex-wrap gl-items-center gl-justify-between gl-gap-x-5 gl-gap-y-2"
>
<h1 class="gl-heading-1 !gl-m-0" data-testid="page-heading">
<slot name="heading"></slot>
<template v-if="!$scopedSlots.heading">{{ heading }}</template>
</h1>
<div class="gl-my-5 gl-flex gl-flex-wrap gl-items-center gl-justify-between gl-gap-y-3">
<div
v-if="$scopedSlots.actions"
class="gl-flex gl-items-center gl-gap-3"
data-testid="page-heading-actions"
class="gl-flex gl-w-full gl-flex-wrap gl-justify-between gl-gap-x-5 gl-gap-y-3 md:gl-flex-nowrap"
>
<slot name="actions"></slot>
<h1 class="gl-heading-1 !gl-m-0" data-testid="page-heading">
<slot name="heading"></slot>
<template v-if="!$scopedSlots.heading">{{ heading }}</template>
</h1>
<div
v-if="$scopedSlots.actions"
class="page-heading-actions gl-flex gl-w-full gl-shrink-0 gl-flex-wrap gl-items-start gl-gap-3 sm:gl-w-auto md:gl-mt-1 lg:gl-mt-2"
data-testid="page-heading-actions"
>
<slot name="actions"></slot>
</div>
</div>
<div
v-if="$scopedSlots.description"
class="gl-mt-2 gl-w-full gl-text-subtle"
class="gl-w-full gl-text-subtle"
data-testid="page-heading-description"
>
<slot name="description"></slot>

View File

@ -6,7 +6,6 @@ $body-bg: $gray-10;
$input-bg: $white;
$input-focus-bg: $white;
$input-color: $gray-900;
$input-group-addon-bg: $gray-900;
$card-cap-bg: $gray-50;

View File

@ -144,11 +144,6 @@ label {
}
.input-group {
.input-group-prepend,
.input-group-append {
background-color: $input-group-addon-bg;
}
.input-group-prepend:not(:first-child):not(:last-child),
.input-group-append:not(:first-child):not(:last-child) {
border-left: 0;

View File

@ -40,3 +40,10 @@
}
}
}
.page-heading-actions {
> .gl-button,
> .gl-disclosure-dropdown {
@apply gl-w-full sm:gl-w-auto;
}
}

View File

@ -333,7 +333,6 @@ $logs-p-color: #333;
*/
$input-height: 32px;
$input-danger-bg: #f2dede;
$input-group-addon-bg: $gray-10;
$gl-field-focus-shadow: rgba(0, 0, 0, 0.075);
$gl-field-focus-shadow-error: rgba($red-500, 0.6);
$input-short-width: 200px;

View File

@ -1,11 +1,12 @@
.gl-flex.gl-flex-wrap.gl-items-center.gl-justify-between.gl-gap-y-2.gl-gap-x-5.gl-my-5{ @options }
%h1.gl-heading-1{ class: '!gl-m-0', data: { testid: 'page-heading' } }
= heading || @heading
.gl-flex.gl-flex-wrap.gl-items-center.gl-justify-between.gl-gap-y-3.gl-my-5{ @options }
.gl-flex.gl-flex-wrap.md:gl-flex-nowrap.gl-justify-between.gl-gap-x-5.gl-gap-y-3.gl-w-full
%h1.gl-heading-1{ class: '!gl-m-0', data: { testid: 'page-heading' } }
= heading || @heading
- if actions?
.gl-flex.gl-items-center.gl-gap-3{ data: { testid: 'page-heading-actions' } }
= actions
- if actions?
.page-heading-actions.gl-self-start.md:gl-mt-1.lg:gl-mt-2.gl-flex.gl-flex-wrap.gl-items-start.gl-gap-3.gl-w-full.sm:gl-w-auto.gl-shrink-0{ data: { testid: 'page-heading-actions' } }
= actions
- if description? || @description
.gl-w-full.gl-mt-2.gl-text-subtle{ data: { testid: 'page-heading-description' } }
.gl-w-full.gl-text-subtle{ data: { testid: 'page-heading-description' } }
= description || @description

View File

@ -302,6 +302,21 @@ module BlobHelper
can_download_code: can?(current_user, :download_code, project).to_s
}
end
def vue_blob_header_app_data(project, blob, ref)
{
blob_path: blob.path,
breadcrumbs: breadcrumb_data_attributes,
escaped_ref: ActionDispatch::Journey::Router::Utils.escape_path(ref),
history_link: project_commits_path(project, ref),
project_id: project.id,
project_root_path: project_path(project),
project_path: project.full_path,
project_short_path: project.path,
ref_type: @ref_type.to_s,
ref: ref
}
end
end
BlobHelper.prepend_mod_with('BlobHelper')

View File

@ -151,7 +151,7 @@ module PageLayoutHelper
end
def full_content_class
"#{container_class} #{@content_class}" # rubocop:disable Rails/HelperInstanceVariable
"#{container_class} #{@content_class}"
end
def page_itemtype(itemtype = nil)

View File

@ -49,7 +49,7 @@ class ApplicationSetting < ApplicationRecord
# We won't add a prefix here as this token is deprecated and being
# disabled in 17.0
# https://docs.gitlab.com/ee/ci/runners/new_creation_workflow.html
add_authentication_token_field :runners_registration_token, encrypted: :required # rubocop:disable Gitlab/TokenWithoutPrefix -- wontfix
add_authentication_token_field :runners_registration_token, encrypted: :required
add_authentication_token_field :health_check_access_token # rubocop:todo -- https://gitlab.com/gitlab-org/gitlab/-/issues/376751
add_authentication_token_field :static_objects_external_storage_auth_token, encrypted: :required # rubocop:todo -- https://gitlab.com/gitlab-org/gitlab/-/issues/439292
add_authentication_token_field :error_tracking_access_token, encrypted: :required # rubocop:todo -- https://gitlab.com/gitlab-org/gitlab/-/issues/439292
@ -1007,7 +1007,7 @@ class ApplicationSetting < ApplicationRecord
kroki_url, schemes: %w[http https],
enforce_sanitization: true,
deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed?,
outbound_local_requests_allowlist: Gitlab::CurrentSettings.outbound_local_requests_whitelist)[0] # rubocop:disable Naming/InclusiveLanguage -- existing setting
outbound_local_requests_allowlist: Gitlab::CurrentSettings.outbound_local_requests_whitelist)[0]
rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e
self.errors.add(
:kroki_url,

View File

@ -707,7 +707,7 @@ class MergeRequest < ApplicationRecord
end
def self.use_locked_set?
Feature.enabled?(:unstick_locked_merge_requests_redis) # rubocop: disable Gitlab/FeatureFlagWithoutActor -- no actor needed
Feature.enabled?(:unstick_locked_merge_requests_redis)
end
def committers(with_merge_commits: false, lazy: false, include_author_when_signed: false)

View File

@ -6,10 +6,9 @@ module Import
# Default API max page size
BATCH_SIZE = GitlabSchema.default_max_page_size
def initialize(bulk_import:, namespace:, options: {})
def initialize(bulk_import:, namespace:)
@bulk_import = bulk_import
@namespace = namespace
@minimum_batch_size = options[:minimum_batch_size] || BATCH_SIZE
end
def execute
@ -22,7 +21,7 @@ module Import
private
attr_reader :bulk_import, :namespace, :force, :minimum_batch_size
attr_reader :bulk_import, :namespace, :force
def graphql_client
@graphql_client ||= ::BulkImports::Clients::Graphql.new(
@ -61,18 +60,20 @@ module Import
has_next_page = true
next_page = nil
next if ids.size < minimum_batch_size
# Make subsequent API calls if the API is configured with a max
# page size smaller than the default value
while has_next_page
response = graphql_client.execute(parsed_query, ids: ids, after: next_page).original_hash
next_page = response.dig('page_info', 'next_page')
has_next_page = response.dig('page_info', 'has_next_page')
users = response.dig('data', 'users', 'nodes')
data = response.dig('data', 'users')
next_page = data.dig('pageInfo', 'next_page')
has_next_page = data.dig('pageInfo', 'has_next_page')
users = data['nodes']
next unless users.present?
unless users.present?
logger.error(message: 'No users present in response', response: response, user_ids: ids)
next
end
users.each do |user|
yielder << user
@ -84,13 +85,23 @@ module Import
def update_source_user(user_data)
source_user_identifier = GlobalID.parse(user_data['id'])&.model_id
return unless source_user_identifier
unless source_user_identifier
logger.error(message: 'Missing source user identifier', user_data: user_data)
return
end
source_user = find_source_user(source_user_identifier)
return unless source_user
params = { source_name: user_data['name'], source_username: user_data['username'] }.compact
return if params.blank?
if params.blank?
logger.error(message: 'Missing source user information', user_data: user_data, source_user_id: source_user.id,
bulk_import_id: bulk_import.id,
importer: Import::SOURCE_DIRECT_TRANSFER)
return
end
result = SourceUsers::UpdateService.new(source_user, params).execute

View File

@ -3,18 +3,23 @@
module Packages
module Npm
class DeprecatePackageService < BaseService
DeprecatedMetadatum = Struct.new(:package_id, :message, :attributes)
include Gitlab::Utils::StrongMemoize
BATCH_SIZE = 50
def initialize(project, params)
super(project, nil, params)
@enqueue_metadata_cache_worker = false
end
def execute
enqueue_metadata_cache_worker = false
packages.select(:id, :version, :package_type).each_batch(of: BATCH_SIZE) do |relation|
deprecated_metadata = handle_batch(relation)
update_or_create_metadata(deprecated_metadata)
attributes = relation.preload_npm_metadatum.filter_map { |package| metadatum_attributes(package) }
next if attributes.empty?
::Packages::Npm::Metadatum.upsert_all(attributes)
enqueue_metadata_cache_worker = true
end
if enqueue_metadata_cache_worker
@ -26,8 +31,6 @@ module Packages
private
attr_accessor :enqueue_metadata_cache_worker
def packages
::Packages::Npm::PackageFinder.new(
project: project,
@ -38,65 +41,50 @@ module Packages
).execute
end
def handle_batch(relation)
relation
.preload_npm_metadatum
.filter_map { |package| deprecate(package) }
end
def deprecate(package)
def metadatum_attributes(package)
package_json = params.dig('versions', package.version)
deprecation_message = package_json&.dig('deprecated')
return if deprecation_message.nil?
npm_metadatum = package.npm_metadatum || package.build_npm_metadatum(package_json: package_json)
return if npm_metadatum.persisted? && identical?(npm_metadatum.package_json['deprecated'], deprecation_message)
return if npm_metadatum.persisted? && identical?(npm_metadatum.package_json['deprecated'])
DeprecatedMetadatum.new.tap do |deprecated|
if npm_metadatum.persisted?
deprecated.package_id = package.id
deprecated.message = deprecation_message
elsif npm_metadatum.valid?
deprecated.attributes = npm_metadatum.attributes
else
Gitlab::ErrorTracking.track_exception(
ActiveRecord::RecordInvalid.new(npm_metadatum),
class: self.class.name,
package_id: package.id
)
break # to return nil instead of an empty struct
end
end
end
def identical?(package_json_deprecated, deprecation_message)
package_json_deprecated == deprecation_message ||
(package_json_deprecated.nil? && deprecation_message.empty?)
end
def update_or_create_metadata(deprecated_metadata)
return if deprecated_metadata.empty?
to_update, to_create = deprecated_metadata.partition(&:message)
if to_update.any?
::Packages::Npm::Metadatum
.package_id_in(to_update.map(&:package_id))
.update_all(update_clause(to_update.first.message))
end
::Packages::Npm::Metadatum.insert_all(to_create.map(&:attributes), returning: false) if to_create.any?
self.enqueue_metadata_cache_worker = true
end
def update_clause(deprecation_message)
if deprecation_message.empty?
"package_json = package_json - 'deprecated'"
if npm_metadatum.valid?
{ package_id: package.id, package_json: update_package_json(npm_metadatum.package_json) }
else
["package_json = jsonb_set(package_json, '{deprecated}', ?)", deprecation_message.to_json]
Gitlab::ErrorTracking.track_exception(
ActiveRecord::RecordInvalid.new(npm_metadatum),
class: self.class.name,
package_id: package.id
)
nil
end
end
def identical?(package_json_deprecated)
package_json_deprecated == deprecation_message ||
(package_json_deprecated.nil? && deprecation_message_empty?)
end
def update_package_json(package_json)
if deprecation_message_empty?
package_json.delete('deprecated')
else
package_json['deprecated'] = deprecation_message
end
package_json
end
def deprecation_message_empty?
deprecation_message.empty?
end
strong_memoize_attr :deprecation_message_empty?
def deprecation_message
_, metadatum = params['versions'].first
metadatum['deprecated']
end
strong_memoize_attr :deprecation_message
end
end
end

View File

@ -1,4 +1,3 @@
= render "projects/blob/breadcrumb", blob: blob
- project = @project.present(current_user: current_user)
- ref = local_assigns[:ref] || @ref
- expanded = params[:expanded].present?
@ -7,6 +6,11 @@
- if blob.rich_viewer && blob.extension != 'geojson'
- add_page_startup_api_call local_assigns.fetch(:viewer_url) { url_for(safe_params.merge(viewer: blob.rich_viewer.type, format: :json)) }
- if params[:common_repository_blob_header_app] == 'true'
#js-repository-blob-header-app{ data: vue_blob_header_app_data(project, blob, ref) }
- else
= render "projects/blob/breadcrumb", blob: blob
- if project.forked?
#js-fork-info{ data: vue_fork_divergence_data(project, ref) }

View File

@ -12,21 +12,16 @@ module Import
sidekiq_options max_retries_after_interruption: 20
worker_has_external_dependencies!
PERFORM_DELAY = 1.minute
PERFORM_DELAY = 5.minutes
def perform(bulk_import_id)
bulk_import = BulkImport.find_by_id(bulk_import_id)
return unless bulk_import
options = {}
# When bulk_import is in a completed status, uses a minimum batch size
# of 1 to ensure all source user are updated
options[:minimum_batch_size] = 1 if bulk_import.completed?
portables = bulk_import.entities.filter_map { |entity| entity.group || entity.project }
root_ancestors = portables.map(&:root_ancestor).uniq
root_ancestors.each do |root_ancestor|
UpdateSourceUsersService.new(namespace: root_ancestor, bulk_import: bulk_import, options: options).execute
UpdateSourceUsersService.new(namespace: root_ancestor, bulk_import: bulk_import).execute
end
# Stop re-enqueueing when the import is completed

View File

@ -1,3 +0,0 @@
# frozen_string_literal: true
internal_users.add_comment_for_internal_users_changes

View File

@ -1,9 +0,0 @@
# frozen_string_literal: true
require_relative '../../tooling/danger/internal_users'
module Danger
class InternalUsers < Plugin
include Tooling::Danger::InternalUsers
end
end

View File

@ -24528,7 +24528,7 @@ four standard [pagination arguments](#pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="groupapprovalpoliciesincludeunscoped"></a>`includeUnscoped` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in GitLab 17.3. **Status**: Experiment. Filter policies that are scoped to the project. |
| <a id="groupapprovalpoliciesincludeunscoped"></a>`includeUnscoped` | [`Boolean`](#boolean) | Filter policies that are scoped to the project. |
| <a id="groupapprovalpoliciesrelationship"></a>`relationship` | [`SecurityPolicyRelationType`](#securitypolicyrelationtype) | Filter policies by the given policy relationship. |
##### `Group.autocompleteUsers`
@ -25414,7 +25414,7 @@ four standard [pagination arguments](#pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="grouppipelineexecutionpoliciesincludeunscoped"></a>`includeUnscoped` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in GitLab 17.3. **Status**: Experiment. Filter policies that are scoped to the project. |
| <a id="grouppipelineexecutionpoliciesincludeunscoped"></a>`includeUnscoped` | [`Boolean`](#boolean) | Filter policies that are scoped to the project. |
| <a id="grouppipelineexecutionpoliciesrelationship"></a>`relationship` | [`SecurityPolicyRelationType`](#securitypolicyrelationtype) | Filter policies by the given policy relationship. |
##### `Group.projectComplianceStandardsAdherence`
@ -25588,7 +25588,7 @@ four standard [pagination arguments](#pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="groupscanexecutionpoliciesactionscantypes"></a>`actionScanTypes` | [`[SecurityReportTypeEnum!]`](#securityreporttypeenum) | Filters policies by the action scan type. Only these scan types are supported: `dast`, `secret_detection`, `cluster_image_scanning`, `container_scanning`, `sast`, `sast_iac`, `dependency_scanning`. |
| <a id="groupscanexecutionpoliciesincludeunscoped"></a>`includeUnscoped` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in GitLab 17.3. **Status**: Experiment. Filter policies that are scoped to the project. |
| <a id="groupscanexecutionpoliciesincludeunscoped"></a>`includeUnscoped` | [`Boolean`](#boolean) | Filter policies that are scoped to the project. |
| <a id="groupscanexecutionpoliciesrelationship"></a>`relationship` | [`SecurityPolicyRelationType`](#securitypolicyrelationtype) | Filter policies by the given policy relationship. |
##### `Group.scanResultPolicies`
@ -25609,7 +25609,7 @@ four standard [pagination arguments](#pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="groupscanresultpoliciesincludeunscoped"></a>`includeUnscoped` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in GitLab 17.3. **Status**: Experiment. Filter policies that are scoped to the project. |
| <a id="groupscanresultpoliciesincludeunscoped"></a>`includeUnscoped` | [`Boolean`](#boolean) | Filter policies that are scoped to the project. |
| <a id="groupscanresultpoliciesrelationship"></a>`relationship` | [`SecurityPolicyRelationType`](#securitypolicyrelationtype) | Filter policies by the given policy relationship. |
##### `Group.securityPolicyProjectSuggestions`
@ -29524,7 +29524,7 @@ four standard [pagination arguments](#pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="namespaceapprovalpoliciesincludeunscoped"></a>`includeUnscoped` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in GitLab 17.3. **Status**: Experiment. Filter policies that are scoped to the project. |
| <a id="namespaceapprovalpoliciesincludeunscoped"></a>`includeUnscoped` | [`Boolean`](#boolean) | Filter policies that are scoped to the project. |
| <a id="namespaceapprovalpoliciesrelationship"></a>`relationship` | [`SecurityPolicyRelationType`](#securitypolicyrelationtype) | Filter policies by the given policy relationship. |
##### `Namespace.complianceFrameworks`
@ -29599,7 +29599,7 @@ four standard [pagination arguments](#pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="namespacepipelineexecutionpoliciesincludeunscoped"></a>`includeUnscoped` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in GitLab 17.3. **Status**: Experiment. Filter policies that are scoped to the project. |
| <a id="namespacepipelineexecutionpoliciesincludeunscoped"></a>`includeUnscoped` | [`Boolean`](#boolean) | Filter policies that are scoped to the project. |
| <a id="namespacepipelineexecutionpoliciesrelationship"></a>`relationship` | [`SecurityPolicyRelationType`](#securitypolicyrelationtype) | Filter policies by the given policy relationship. |
##### `Namespace.projects`
@ -29661,7 +29661,7 @@ four standard [pagination arguments](#pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="namespacescanexecutionpoliciesactionscantypes"></a>`actionScanTypes` | [`[SecurityReportTypeEnum!]`](#securityreporttypeenum) | Filters policies by the action scan type. Only these scan types are supported: `dast`, `secret_detection`, `cluster_image_scanning`, `container_scanning`, `sast`, `sast_iac`, `dependency_scanning`. |
| <a id="namespacescanexecutionpoliciesincludeunscoped"></a>`includeUnscoped` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in GitLab 17.3. **Status**: Experiment. Filter policies that are scoped to the project. |
| <a id="namespacescanexecutionpoliciesincludeunscoped"></a>`includeUnscoped` | [`Boolean`](#boolean) | Filter policies that are scoped to the project. |
| <a id="namespacescanexecutionpoliciesrelationship"></a>`relationship` | [`SecurityPolicyRelationType`](#securitypolicyrelationtype) | Filter policies by the given policy relationship. |
##### `Namespace.scanResultPolicies`
@ -29682,7 +29682,7 @@ four standard [pagination arguments](#pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="namespacescanresultpoliciesincludeunscoped"></a>`includeUnscoped` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in GitLab 17.3. **Status**: Experiment. Filter policies that are scoped to the project. |
| <a id="namespacescanresultpoliciesincludeunscoped"></a>`includeUnscoped` | [`Boolean`](#boolean) | Filter policies that are scoped to the project. |
| <a id="namespacescanresultpoliciesrelationship"></a>`relationship` | [`SecurityPolicyRelationType`](#securitypolicyrelationtype) | Filter policies by the given policy relationship. |
##### `Namespace.vulnerabilityManagementPolicies`
@ -31403,7 +31403,7 @@ four standard [pagination arguments](#pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="projectapprovalpoliciesincludeunscoped"></a>`includeUnscoped` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in GitLab 17.3. **Status**: Experiment. Filter policies that are scoped to the project. |
| <a id="projectapprovalpoliciesincludeunscoped"></a>`includeUnscoped` | [`Boolean`](#boolean) | Filter policies that are scoped to the project. |
| <a id="projectapprovalpoliciesrelationship"></a>`relationship` | [`SecurityPolicyRelationType`](#securitypolicyrelationtype) | Filter policies by the given policy relationship. |
##### `Project.autocompleteUsers`
@ -32546,7 +32546,7 @@ four standard [pagination arguments](#pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="projectpipelineexecutionpoliciesincludeunscoped"></a>`includeUnscoped` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in GitLab 17.3. **Status**: Experiment. Filter policies that are scoped to the project. |
| <a id="projectpipelineexecutionpoliciesincludeunscoped"></a>`includeUnscoped` | [`Boolean`](#boolean) | Filter policies that are scoped to the project. |
| <a id="projectpipelineexecutionpoliciesrelationship"></a>`relationship` | [`SecurityPolicyRelationType`](#securitypolicyrelationtype) | Filter policies by the given policy relationship. |
##### `Project.pipelineSchedules`
@ -32770,7 +32770,7 @@ four standard [pagination arguments](#pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="projectscanexecutionpoliciesactionscantypes"></a>`actionScanTypes` | [`[SecurityReportTypeEnum!]`](#securityreporttypeenum) | Filters policies by the action scan type. Only these scan types are supported: `dast`, `secret_detection`, `cluster_image_scanning`, `container_scanning`, `sast`, `sast_iac`, `dependency_scanning`. |
| <a id="projectscanexecutionpoliciesincludeunscoped"></a>`includeUnscoped` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in GitLab 17.3. **Status**: Experiment. Filter policies that are scoped to the project. |
| <a id="projectscanexecutionpoliciesincludeunscoped"></a>`includeUnscoped` | [`Boolean`](#boolean) | Filter policies that are scoped to the project. |
| <a id="projectscanexecutionpoliciesrelationship"></a>`relationship` | [`SecurityPolicyRelationType`](#securitypolicyrelationtype) | Filter policies by the given policy relationship. |
##### `Project.scanResultPolicies`
@ -32791,7 +32791,7 @@ four standard [pagination arguments](#pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="projectscanresultpoliciesincludeunscoped"></a>`includeUnscoped` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in GitLab 17.3. **Status**: Experiment. Filter policies that are scoped to the project. |
| <a id="projectscanresultpoliciesincludeunscoped"></a>`includeUnscoped` | [`Boolean`](#boolean) | Filter policies that are scoped to the project. |
| <a id="projectscanresultpoliciesrelationship"></a>`relationship` | [`SecurityPolicyRelationType`](#securitypolicyrelationtype) | Filter policies by the given policy relationship. |
##### `Project.securityExclusion`

View File

@ -341,6 +341,11 @@ In the following example workflow, a developer creates an MR that touches Observ
In addition, the developer can manually add `pipeline:run-observability-e2e-tests-main-branch` to force the MR to run the `e2e:observability-backend-main-branch` job. This could be useful in case of changes to files that are not being tracked as related to observability.
There might be situations where the developer would need to skip those tests. To skip tests:
- For an MR, apply the label `pipeline:skip-observability-e2e-tests label`.
- For a whole project, set the CI variable `SKIP_GITLAB_OBSERVABILITY_BACKEND_TRIGGER`.
### Review app jobs
The [`start-review-app-pipeline`](../testing_guide/review_apps.md) child pipeline deploys a Review App and runs

View File

@ -9,21 +9,29 @@ info: To determine the technical writer assigned to the Stage/Group associated w
DETAILS:
**Tier:** Ultimate
**Offering:** GitLab.com, Self-managed, GitLab Dedicated
**Status:** Experiment
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/14878) as an [experiment](../../../policy/development_stages_support.md) in GitLab 17.5 [with a flag](../../feature_flags.md) named `secret_detection_project_level_exclusions`. Enabled by default.
> - `secret_detection_project_level_exclusions` feature flag [removed](https://gitlab.com/gitlab-org/gitlab/-/issues/499059) in GitLab 17.7.
> - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/499059) in GitLab 17.7. Feature flag `secret_detection_project_level_exclusions` removed.
Secret detection may detect something that's not actually a secret. For example, if you use
a fake value as a placeholder in your code, it might be detected and possibly blocked.
To avoid false positives, define a secret detection exclusion. A secret detection exclusion defines a path, a raw value or a rule from the [default ruleset](https://gitlab.com/gitlab-org/gitlab/-/blob/master/gems/gitlab-secret_detection/lib/gitleaks.toml) to exclude from secret detection. You can define multiples of each type of
exclusion for a project.
To avoid false positives you can exclude from secret detection:
In the [first iteration](https://gitlab.com/groups/gitlab-org/-/epics/14878) of this feature:
- A path.
- A raw value.
- A rule from the [default ruleset](https://gitlab.com/gitlab-org/gitlab/-/blob/master/gems/gitlab-secret_detection/lib/gitleaks.toml)
You can define multiple exclusions for a project.
## Restrictions
The following restrictions apply:
- Exclusions can only be defined for each project.
- Exclusions apply only to [secret push protection](secret_push_protection/index.md).
- The maximum number of path-based exclusions per project is 10.
- The maximum depth for path-based exclusions is 20.
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
For an overview, see [Secret Detection Exclusions - Demonstration](https://www.youtube.com/watch?v=vh_Uh4_4aoc).
@ -33,13 +41,10 @@ For an overview, see [Secret Detection Exclusions - Demonstration](https://www.y
Define an exclusion to avoid false positives from secret detection.
Note the following before defining an exclusion:
- The maximum number of path-based exclusions per project is 10.
- The maximum depth for path-based exclusions is 20.
- Glob patterns are interpreted with Ruby's [`File.fnmatch`](https://docs.ruby-lang.org/en/master/File.html#method-c-fnmatch)
with the [flags](https://docs.ruby-lang.org/en/master/File/Constants.html#module-File::Constants-label-Filename+Globbing+Constants+-28File-3A-3AFNM_-2A-29)
`File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB`.
Path exclusions support glob patterns which are supported and interpreted with the Ruby method
[`File.fnmatch`](https://docs.ruby-lang.org/en/master/File.html#method-c-fnmatch)
with the [flags](https://docs.ruby-lang.org/en/master/File/Constants.html#module-File::Constants-label-Filename+Globbing+Constants+-28File-3A-3AFNM_-2A-29)
`File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB`.
Prerequisites:
@ -47,10 +52,10 @@ Prerequisites:
To define an exclusion:
1. In the left sidebar, select **Search or go to** and navigate to your project or group.
1. In the left sidebar, select **Search or go to** and go to your project or group.
1. Select **Secure > Security configuration**.
1. Scroll down to **Secret push protection**.
1. Turn on the **Secret push protection** toggle.
1. Select **Configure Secret Detection** (**{settings}**).
1. Select **Add exclusion** to open the exclusion form.
1. Enter the details of the exclusion, then select **Add Exclusion**.
1. Enter the details of the exclusion, then select **Add exclusion**.

View File

@ -168,12 +168,10 @@ spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js
spec/frontend/ci/pipeline_mini_graph/pipeline_mini_graph_spec.js
spec/frontend/ci/pipelines_page/components/pipeline_multi_actions_spec.js
spec/frontend/ci/pipelines_page/components/pipelines_artifacts_spec.js
spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js
spec/frontend/ci/runner/components/runner_details_spec.js
spec/frontend/ci/runner/components/runner_form_fields_spec.js
spec/frontend/ci/runner/components/runner_managers_table_spec.js
spec/frontend/ci/runner/components/runner_platforms_radio_spec.js
spec/frontend/ci/runner/components/runner_tags_spec.js
spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js
spec/frontend/ci_settings_pipeline_triggers/components/edit_trigger_modal_spec.js
spec/frontend/clusters/agents/components/activity_history_item_spec.js

View File

@ -28,7 +28,7 @@ RSpec.describe Layouts::PageHeadingComponent, type: :component, feature_category
c.with_description { description }
end
expect(page).to have_css('.gl-w-full.gl-mt-2.gl-text-subtle', text: description)
expect(page).to have_css('.gl-w-full.gl-text-subtle', text: description)
end
end
end

View File

@ -574,7 +574,7 @@ FactoryBot.define do
trait :pages_published do
after(:create) do |project|
project.mark_pages_onboarding_complete
create(:pages_deployment, project: project) # rubocop: disable RSpec/FactoryBot/StrategyInCallback
create(:pages_deployment, project: project)
end
end

View File

@ -6,6 +6,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import PipelineStatus, { i18n } from '~/ci/pipeline_editor/components/header/pipeline_status.vue';
import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.query.graphql';
import getPipelineStatusQuery from '~/ci/pipeline_editor/graphql/queries/get_pipeline_status.query.graphql';
import PipelineMiniGraph from '~/ci/pipeline_mini_graph/pipeline_mini_graph.vue';
import PipelineEditorMiniGraph from '~/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue';
import getPipelineEtag from '~/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql';
@ -19,7 +20,10 @@ describe('Pipeline Status', () => {
let mockPipelineQuery;
const createComponentWithApollo = ({ ciGraphqlPipelineMiniGraph = false } = {}) => {
const handlers = [[getPipelineQuery, mockPipelineQuery]];
const handlers = [
[getPipelineQuery, mockPipelineQuery],
[getPipelineStatusQuery, mockPipelineQuery],
];
mockApollo = createMockApollo(handlers);
mockApollo.clients.defaultClient.cache.writeQuery({

View File

@ -118,6 +118,7 @@ describe('JobActionButton', () => {
await clickActionButton();
expect(handler).toHaveBeenCalledWith({ id: defaultProps.jobId });
expect(wrapper.emitted('jobActionExecuted')).toHaveLength(1);
});
it('displays the appropriate error message if mutation is not successful', async () => {

View File

@ -1,5 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import JobActionButton from '~/ci/pipeline_mini_graph/job_action_button.vue';
import JobItem from '~/ci/pipeline_mini_graph/job_item.vue';
import JobNameComponent from '~/ci/common/private/job_name_component.vue';
@ -22,27 +23,51 @@ describe('JobItem', () => {
};
const findJobNameComponent = () => wrapper.findComponent(JobNameComponent);
const findJobActionButton = () => wrapper.findComponent(JobActionButton);
describe('when mounted', () => {
beforeEach(() => {
createComponent();
});
it('renders the job name component', () => {
expect(findJobNameComponent().exists()).toBe(true);
});
describe('job name', () => {
it('renders the job name component', () => {
expect(findJobNameComponent().exists()).toBe(true);
});
it('sends the necessary props to the job name component', () => {
expect(findJobNameComponent().props()).toMatchObject({
name: mockPipelineJob.name,
status: mockPipelineJob.detailedStatus,
it('sends the necessary props to the job name component', () => {
expect(findJobNameComponent().props()).toMatchObject({
name: mockPipelineJob.name,
status: mockPipelineJob.detailedStatus,
});
});
it('sets the correct tooltip for the job item', () => {
const tooltip = capitalizeFirstCharacter(mockPipelineJob.detailedStatus.tooltip);
expect(findJobNameComponent().attributes('title')).toBe(tooltip);
});
});
it('sets the correct tooltip for the job item', () => {
const tooltip = capitalizeFirstCharacter(mockPipelineJob.detailedStatus.tooltip);
describe('job action button', () => {
describe('with a job action', () => {
it('renders the job action button component', () => {
expect(findJobActionButton().exists()).toBe(true);
});
expect(findJobNameComponent().attributes('title')).toBe(tooltip);
it('sends the necessary props to the job action button', () => {
expect(findJobActionButton().props()).toMatchObject({
jobId: mockPipelineJob.id,
jobAction: mockPipelineJob.detailedStatus.action,
jobName: mockPipelineJob.name,
});
});
it('emits jobActionExecuted', () => {
findJobActionButton().vm.$emit('jobActionExecuted');
expect(wrapper.emitted('jobActionExecuted')).toHaveLength(1);
});
});
});
});
});

View File

@ -1,5 +1,6 @@
import Vue from 'vue';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import Visibility from 'visibilityjs';
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
@ -7,12 +8,12 @@ import { createAlert } from '~/alert';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
import { PIPELINE_POLL_INTERVAL_DEFAULT } from '~/ci/constants';
import getPipelineMiniGraphQuery from '~/ci/pipeline_mini_graph/graphql/queries/get_pipeline_mini_graph.query.graphql';
import PipelineMiniGraph from '~/ci/pipeline_mini_graph/pipeline_mini_graph.vue';
import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import DownstreamPipelines from '~/ci/pipeline_mini_graph/downstream_pipelines.vue';
import PipelineStages from '~/ci/pipeline_mini_graph/pipeline_stages.vue';
import * as sharedGraphQlUtils from '~/graphql_shared/utils';
import {
pipelineMiniGraphFetchError,
@ -23,6 +24,7 @@ import {
Vue.use(VueApollo);
jest.mock('~/alert');
jest.mock('visibilityjs');
describe('PipelineMiniGraph', () => {
let wrapper;
@ -54,9 +56,10 @@ describe('PipelineMiniGraph', () => {
const findDownstream = () => wrapper.findComponent(DownstreamPipelines);
const findStages = () => wrapper.findComponent(PipelineStages);
const getPollInterval = () => wrapper.vm.$apollo.queries.pipeline.pollInterval;
beforeEach(() => {
pipelineMiniGraphResponse = jest.fn();
pipelineMiniGraphResponse.mockResolvedValue(mockPipelineMiniGraphQueryResponse);
});
describe('when initial query is loading', () => {
@ -75,6 +78,7 @@ describe('PipelineMiniGraph', () => {
describe('when query has loaded', () => {
beforeEach(async () => {
pipelineMiniGraphResponse.mockResolvedValue(mockPipelineMiniGraphQueryResponse);
await createComponent();
});
@ -91,65 +95,71 @@ describe('PipelineMiniGraph', () => {
expect(pipelineMiniGraphResponse).toHaveBeenCalledWith({ iid, fullPath });
});
});
describe('stages', () => {
it('renders stages', () => {
expect(findStages().exists()).toBe(true);
});
describe('stages', () => {
beforeEach(async () => {
pipelineMiniGraphResponse.mockResolvedValue(mockPipelineMiniGraphQueryResponse);
await createComponent();
});
it('sends the necessary props', () => {
expect(findStages().props()).toMatchObject({
isMergeTrain: expect.any(Boolean),
stages: expect.any(Array),
});
it('renders stages', () => {
expect(findStages().exists()).toBe(true);
});
it('sends the necessary props', () => {
expect(findStages().props()).toMatchObject({
isMergeTrain: expect.any(Boolean),
stages: expect.any(Array),
});
});
describe('upstream', () => {
it('renders upstream if available', () => {
expect(findUpstream().exists()).toBe(true);
});
it('does not render upstream if not available', () => {
pipelineMiniGraphResponse.mockResolvedValue(mockPMGQueryNoUpstreamResponse);
createComponent();
expect(findUpstream().exists()).toBe(false);
});
});
describe('downstream', () => {
it('renders downstream if available', () => {
expect(findDownstream().exists()).toBe(true);
});
it('sends the necessary props', () => {
expect(findDownstream().props()).toMatchObject({
pipelines: expect.any(Array),
pipelinePath: expect.any(String),
});
});
it('keeps the latest downstream pipelines', () => {
expect(findDownstream().props('pipelines')).toHaveLength(2);
});
it('does not render downstream if not available', () => {
pipelineMiniGraphResponse.mockResolvedValue(mockPMGQueryNoDownstreamResponse);
createComponent();
expect(findDownstream().exists()).toBe(false);
});
it('emits miniGraphStageClick', () => {
findStages().vm.$emit('miniGraphStageClick');
expect(wrapper.emitted('miniGraphStageClick')).toHaveLength(1);
});
});
describe('polling', () => {
it('toggles query polling with visibility check', async () => {
jest.spyOn(sharedGraphQlUtils, 'toggleQueryPollingByVisibility');
describe('upstream', () => {
it('renders upstream if available', async () => {
pipelineMiniGraphResponse.mockResolvedValue(mockPipelineMiniGraphQueryResponse);
await createComponent();
expect(findUpstream().exists()).toBe(true);
});
it('does not render upstream if not available', () => {
pipelineMiniGraphResponse.mockResolvedValue(mockPMGQueryNoUpstreamResponse);
createComponent();
expect(findUpstream().exists()).toBe(false);
});
});
await waitForPromises();
describe('downstream', () => {
it('renders downstream if available', async () => {
pipelineMiniGraphResponse.mockResolvedValue(mockPipelineMiniGraphQueryResponse);
await createComponent();
expect(findDownstream().exists()).toBe(true);
});
expect(sharedGraphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledTimes(1);
it('sends the necessary props', async () => {
pipelineMiniGraphResponse.mockResolvedValue(mockPipelineMiniGraphQueryResponse);
await createComponent();
expect(findDownstream().props()).toMatchObject({
pipelines: expect.any(Array),
pipelinePath: expect.any(String),
});
});
it('keeps the latest downstream pipelines', async () => {
pipelineMiniGraphResponse.mockResolvedValue(mockPipelineMiniGraphQueryResponse);
await createComponent();
expect(findDownstream().props('pipelines')).toHaveLength(2);
});
it('does not render downstream if not available', () => {
pipelineMiniGraphResponse.mockResolvedValue(mockPMGQueryNoDownstreamResponse);
createComponent();
expect(findDownstream().exists()).toBe(false);
});
});
@ -164,4 +174,36 @@ describe('PipelineMiniGraph', () => {
expect(createAlert).toHaveBeenCalledWith({ message: pipelineMiniGraphFetchError });
});
});
describe('polling', () => {
beforeEach(async () => {
Visibility.hidden.mockReturnValue(true);
pipelineMiniGraphResponse.mockResolvedValue(mockPipelineMiniGraphQueryResponse);
await createComponent();
});
it('increases the poll interval after each query call', () => {
expect(pipelineMiniGraphResponse).toHaveBeenCalled();
expect(getPollInterval()).toBeGreaterThan(PIPELINE_POLL_INTERVAL_DEFAULT);
});
it('handles visibility change for polling correctly', async () => {
expect(getPollInterval()).toBeGreaterThan(PIPELINE_POLL_INTERVAL_DEFAULT);
Visibility.hidden.mockReturnValue(false);
wrapper.vm.handlePolling();
await nextTick();
expect(getPollInterval()).toBe(PIPELINE_POLL_INTERVAL_DEFAULT);
});
it('resets poll interval on job action executed', async () => {
expect(getPollInterval()).toBeGreaterThan(PIPELINE_POLL_INTERVAL_DEFAULT);
findStages().vm.$emit('jobActionExecuted');
await nextTick();
expect(getPollInterval()).toBe(PIPELINE_POLL_INTERVAL_DEFAULT);
});
});
});

View File

@ -103,6 +103,12 @@ describe('PipelineStage', () => {
expect(findDropdownHeader().text()).toBe('Stage: build');
});
it('emits miniGraphStageClick', async () => {
await openStageDropdown();
expect(wrapper.emitted('miniGraphStageClick')).toHaveLength(1);
});
it('has fired the stage query', async () => {
await openStageDropdown();
const { stage } = defaultProps;
@ -135,7 +141,13 @@ describe('PipelineStage', () => {
expect(findJobItems().exists()).toBe(true);
expect(findJobItems()).toHaveLength(2);
});
it('emits jobActionExecuted', () => {
findJobItems().at(0).vm.$emit('jobActionExecuted');
expect(wrapper.emitted('jobActionExecuted')).toHaveLength(1);
});
});
describe('and query is not successful', () => {
const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));

View File

@ -0,0 +1,48 @@
import { shallowMount } from '@vue/test-utils';
import PipelineStages from '~/ci/pipeline_mini_graph/pipeline_stages.vue';
import PipelineStage from '~/ci/pipeline_mini_graph/pipeline_stage.vue';
import { pipelineStage } from './mock_data';
describe('PipelineStages', () => {
let wrapper;
const defaultProps = {
stages: [pipelineStage],
isMergeTrain: false,
};
const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMount(PipelineStages, {
propsData: {
...defaultProps,
...props,
},
});
};
const findStages = () => wrapper.findAllComponents(PipelineStage);
describe('when mounted', () => {
beforeEach(() => {
createComponent();
});
it('sends the necessary props to the stage', () => {
expect(findStages().at(0).props()).toMatchObject({
stage: defaultProps.stages[0],
isMergeTrain: defaultProps.isMergeTrain,
});
});
it('emits jobActionExecuted', () => {
findStages().at(0).vm.$emit('jobActionExecuted');
expect(wrapper.emitted('jobActionExecuted')).toHaveLength(1);
});
it('emits miniGraphStageClick', () => {
findStages().at(0).vm.$emit('miniGraphStageClick');
expect(wrapper.emitted('miniGraphStageClick')).toHaveLength(1);
});
});
});

View File

@ -1,4 +1,7 @@
import { normalizeDownstreamPipelines, normalizeStages } from '~/ci/pipeline_mini_graph/utils';
import {
normalizeDownstreamPipelines,
normalizeStages,
} from '~/ci/pipeline_mini_graph/utils/data_utils';
const graphqlDownstream = [
{
@ -60,7 +63,7 @@ const restStage = [
},
];
describe('Utils', () => {
describe('Data utils', () => {
describe('stages', () => {
it('Does not normalize GraphQL stages', () => {
expect(normalizeStages(graphqlStage)).toEqual(graphqlStage);

View File

@ -32,6 +32,6 @@ describe('RunnerTags', () => {
props: { tagList: null },
});
expect(wrapper.html()).toEqual('');
expect(wrapper.text()).toEqual('');
});
});

View File

@ -0,0 +1,22 @@
import { FOUR_MINUTES_IN_MS } from '~/ci/constants';
import { getIncreasedPollInterval } from '~/ci/utils/polling_utils';
describe('Polling utils', () => {
describe('interval under max limit', () => {
it('increases the interval', () => {
expect(getIncreasedPollInterval(1000)).toBe(1000 * 1.2);
expect(getIncreasedPollInterval(2000)).toBe(2000 * 1.2);
expect(getIncreasedPollInterval(10000)).toBe(10000 * 1.2);
expect(getIncreasedPollInterval(200000)).toBe(200000 * 1.2);
});
});
describe('interval over max limit', () => {
it('returns max interval value', () => {
expect(getIncreasedPollInterval(300000)).toBe(FOUR_MINUTES_IN_MS);
expect(getIncreasedPollInterval(400000)).toBe(FOUR_MINUTES_IN_MS);
expect(getIncreasedPollInterval(500000)).toBe(FOUR_MINUTES_IN_MS);
expect(getIncreasedPollInterval(600000)).toBe(FOUR_MINUTES_IN_MS);
});
});
});

View File

@ -1,6 +1,6 @@
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui';
import { GlDisclosureDropdown, GlDisclosureDropdownGroup, GlLink } from '@gitlab/ui';
import { shallowMount, RouterLinkStub } from '@vue/test-utils';
import Breadcrumbs from '~/repository/components/header_area/breadcrumbs.vue';
import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
@ -57,6 +57,7 @@ describe('Repository breadcrumbs component', () => {
apolloProvider,
provide: {
projectRootPath: TEST_PROJECT_PATH,
isBlobView: extraProps.isBlobView,
},
propsData: {
currentPath,
@ -79,7 +80,7 @@ describe('Repository breadcrumbs component', () => {
const findDropdownGroup = () => wrapper.findComponent(GlDisclosureDropdownGroup);
const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal);
const findNewDirectoryModal = () => wrapper.findComponent(NewDirectoryModal);
const findRouterLink = () => wrapper.findAllComponents(RouterLinkStub);
const findRouterLinks = () => wrapper.findAllComponents(GlLink);
beforeEach(() => {
permissionsQuerySpy = jest.fn().mockResolvedValue(createPermissionsQueryResponse());
@ -105,7 +106,7 @@ describe('Repository breadcrumbs component', () => {
`('renders $linkCount links for path $path', ({ path, linkCount }) => {
factory(path);
expect(findRouterLink().length).toEqual(linkCount);
expect(findRouterLinks().length).toEqual(linkCount);
});
it.each`
@ -118,20 +119,20 @@ describe('Repository breadcrumbs component', () => {
'links to the correct router path when routeName is $routeName',
({ routeName, path, linkTo }) => {
factory(path, {}, { name: routeName });
expect(findRouterLink().at(3).props('to')).toEqual(linkTo);
expect(findRouterLinks().at(3).attributes('to')).toEqual(linkTo);
},
);
it('escapes hash in directory path', () => {
factory('app/assets/javascripts#');
expect(findRouterLink().at(3).props('to')).toEqual('/-/tree/app/assets/javascripts%23');
expect(findRouterLinks().at(3).attributes('to')).toEqual('/-/tree/app/assets/javascripts%23');
});
it('renders last link as active', () => {
factory('app/assets');
expect(findRouterLink().at(2).attributes('aria-current')).toEqual('page');
expect(findRouterLinks().at(2).attributes('aria-current')).toEqual('page');
});
it('does not render add to tree dropdown when permissions are false', async () => {
@ -244,4 +245,18 @@ describe('Repository breadcrumbs component', () => {
expect(findDropdownGroup().exists()).toBe(false);
});
});
describe('link rendering', () => {
it('passes `href` to GlLink when isBlobView is true', () => {
factory('/', { isBlobView: true });
expect(findRouterLinks().at(0).attributes('href')).toBe('/test-project/path/-/tree');
});
it('passes `to` to GlLink when isBlobView is false', () => {
factory('/', { isBlobView: false });
expect(findRouterLinks().at(0).attributes('to')).toBe('/-/tree');
});
});
});

View File

@ -15,7 +15,6 @@ import MrWidgetPipeline from '~/vue_merge_request_widget/components/mr_widget_pi
import MrWidgetPipelineContainer from '~/vue_merge_request_widget/components/mr_widget_pipeline_container.vue';
import getMergePipeline from '~/vue_merge_request_widget/queries/get_merge_pipeline.query.graphql';
import * as sharedGraphQlUtils from '~/graphql_shared/utils';
import { mockStore, mockMergePipelineQueryResponse } from '../mock_data';
Vue.use(VueApollo);
@ -149,16 +148,6 @@ describe('MrWidgetPipelineContainer', () => {
expect(mergePipelineResponse).toHaveBeenCalledWith(queryVariables);
});
describe('polling', () => {
it('toggles query polling with visibility check', async () => {
jest.spyOn(sharedGraphQlUtils, 'toggleQueryPollingByVisibility');
await createComponent();
expect(sharedGraphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledTimes(1);
});
});
describe('when the merge pipeline query is unsuccessful', () => {
const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));

View File

@ -58,7 +58,7 @@ describe('Pagination links component', () => {
it('renders its description slot content', () => {
expect(description().text()).toBe('Description go here');
expect(description().classes()).toEqual(
expect.arrayContaining(['gl-w-full', 'gl-mt-2', 'gl-text-subtle']),
expect.arrayContaining(['gl-w-full', 'gl-text-subtle']),
);
});

View File

@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe BlobHelper do
include TreeHelper
include FakeBlobHelpers
include Devise::Test::ControllerHelpers
describe "#sanitize_svg_data" do
let(:input_svg_path) { File.join(Rails.root, 'spec', 'fixtures', 'unsanitized.svg') }
@ -477,4 +478,34 @@ RSpec.describe BlobHelper do
expect(rendered_button).to have_selector('button[data-action="edit"]')
end
end
describe '#vue_blob_header_app_data' do
let_it_be(:project) { create(:project) }
let_it_be(:blob) { fake_blob(path: 'README.md') }
let(:ref) { 'main' }
let(:ref_type) { :branch }
let(:breadcrumb_data) { { title: 'README.md', 'is-last': true } }
before do
assign(:project, project)
assign(:ref, ref)
assign(:ref_type, ref_type)
allow(helper).to receive(:breadcrumb_data_attributes).and_return(breadcrumb_data)
end
it 'returns data related to blob header' do
expect(helper.vue_blob_header_app_data(project, blob, ref)).to include({
blob_path: blob.path,
breadcrumbs: breadcrumb_data,
escaped_ref: ref,
history_link: project_commits_path(project, ref),
project_id: project.id,
project_root_path: project_path(project),
project_path: project.full_path,
project_short_path: project.path,
ref_type: ref_type.to_s,
ref: ref
})
end
end
end

View File

@ -60,9 +60,8 @@ RSpec.describe Import::BulkImports::UpdateSourceUsersService, :clean_gitlab_redi
let_it_be(:node_1) { graphql_response_node(gid: source_user_identifiers[1]) }
let_it_be(:node_2) { graphql_response_node(gid: source_user_identifiers[2]) }
let(:minimum_batch_size) { 1 }
let(:service_args) do
{ bulk_import: bulk_import, namespace: portable, options: { minimum_batch_size: minimum_batch_size } }
{ bulk_import: bulk_import, namespace: portable }
end
def graphql_response_node(gid:, name: 'Name', username: 'username')
@ -77,12 +76,12 @@ RSpec.describe Import::BulkImports::UpdateSourceUsersService, :clean_gitlab_redi
instance_double(
GraphQL::Client::Response,
original_hash: {
'page_info' => {
'next_page' => next_page,
'has_next_page' => has_next_page
},
'data' => {
'users' => {
'pageInfo' => {
'next_page' => next_page,
'has_next_page' => has_next_page
},
'nodes' => nodes
}
}
@ -154,6 +153,13 @@ RSpec.describe Import::BulkImports::UpdateSourceUsersService, :clean_gitlab_redi
expect(client).to receive(:execute).and_return(graphql_response([]))
end
expect_next_instance_of(BulkImports::Logger) do |logger|
expect(logger).to receive(:error).with(
message: 'No users present in response',
response: anything,
user_ids: source_user_identifiers)
end
expect(fetch_users_data.each.to_a).to eq([])
end
end
@ -179,7 +185,7 @@ RSpec.describe Import::BulkImports::UpdateSourceUsersService, :clean_gitlab_redi
end
context 'when the number of source users is higher than the batch size' do
it 'makes a request for each batch in decending order' do
it 'makes a request for each batch in descending order' do
stub_const("#{described_class.name}::BATCH_SIZE", 2)
expect_next_instance_of(BulkImports::Clients::Graphql) do |client|
@ -199,24 +205,6 @@ RSpec.describe Import::BulkImports::UpdateSourceUsersService, :clean_gitlab_redi
expect(fetch_users_data.each.to_a).to match_array([node_0, node_1, node_2])
end
end
context 'when current batch size is smaller than minimum batch size' do
let(:minimum_batch_size) { 2 }
it 'does not request fewer user details than the minimum batch size' do
stub_const("#{described_class.name}::BATCH_SIZE", 2)
expect_next_instance_of(BulkImports::Clients::Graphql) do |client|
expect(client).to receive(:parse).and_return('query').ordered
expect(client).to receive(:execute)
.with('query', ids: [source_user_identifiers[1], source_user_identifiers[2]], after: nil)
.and_return(graphql_response([node_1, node_2]))
end
expect(fetch_users_data.each.to_a).to match_array([node_1, node_2])
end
end
end
describe '#update_source_user' do
@ -259,6 +247,12 @@ RSpec.describe Import::BulkImports::UpdateSourceUsersService, :clean_gitlab_redi
it 'does not update the source user attributes' do
expect(Import::SourceUsers::UpdateService).not_to receive(:new)
expect_next_instance_of(BulkImports::Logger) do |logger|
expect(logger).to receive(:error).with(
hash_including(message: 'Missing source user identifier')
)
end
update_source_user
end
end
@ -274,6 +268,12 @@ RSpec.describe Import::BulkImports::UpdateSourceUsersService, :clean_gitlab_redi
it 'does not update the source user attributes' do
expect(Import::SourceUsers::UpdateService).not_to receive(:new)
expect_next_instance_of(BulkImports::Logger) do |logger|
expect(logger).to receive(:error).with(
hash_including(message: 'Missing source user information')
)
end
update_source_user
end
end

View File

@ -1,256 +0,0 @@
# frozen_string_literal: true
require 'fast_spec_helper'
require 'gitlab/dangerfiles/spec_helper'
require_relative '../../../tooling/danger/internal_users'
require_relative '../../../tooling/danger/project_helper'
RSpec.describe Tooling::Danger::InternalUsers, feature_category: :tooling do
include_context "with dangerfile"
let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
let(:fake_project_helper) { instance_double(Tooling::Danger::ProjectHelper) }
let(:file_path) { 'app/models/user.rb' }
let(:changed_lines) { [] }
let(:file_lines) { [] }
subject(:helper) { fake_danger.new(helper: fake_helper) }
before do
allow(helper).to receive(:project_helper).and_return(fake_project_helper)
allow(fake_helper).to receive(:all_changed_files).and_return([file_path])
allow(fake_helper).to receive(:changed_lines).with(file_path).and_return(changed_lines)
allow(fake_project_helper).to receive(:file_lines).with(file_path).and_return(file_lines)
end
describe '#add_comment_for_internal_users_changes' do
shared_examples 'no violations' do
it 'adds no comments' do
expect(helper).not_to receive(:markdown)
expect(helper).not_to receive(:warn)
helper.add_comment_for_internal_users_changes
end
end
shared_examples 'has violations' do
it 'adds file and MR level comments' do
expect(helper).to receive(:markdown).once
expect(helper).to receive(:warn).once
helper.add_comment_for_internal_users_changes
end
end
context 'when documentation is changed' do
before do
allow(fake_helper).to receive(:all_changed_files).and_return([described_class::DOCS_PATH])
end
include_examples 'no violations'
end
context 'when method name is changed' do
let(:changed_lines) do
[
'- def security_bot',
'+ def security_bot_v2'
]
end
let(:file_lines) { [' def security_bot_v2'] }
include_examples 'has violations'
end
context 'when method body is changed' do
let(:changed_lines) do
[
' def security_bot',
'+ super',
' end'
]
end
let(:file_lines) do
[
'def security_bot',
' super',
'end'
]
end
include_examples 'has violations'
end
context 'when bot module is referenced' do
let(:changed_lines) do
[
'+ Users::Internal.security_bot'
]
end
let(:file_lines) { [' Users::Internal.security_bot'] }
include_examples 'has violations'
end
context 'when bot symbol is used' do
let(:changed_lines) do
[
'+ user_type: :security_bot'
]
end
let(:file_lines) { [' user_type: :security_bot'] }
include_examples 'has violations'
end
context 'when changes are unrelated' do
let(:changed_lines) do
[
'+ user.name = "Regular User"'
]
end
let(:file_lines) { [' user.name = "Regular User"'] }
include_examples 'no violations'
end
context 'when changing lines inside a bot method definition' do
let(:changed_lines) do
[
' user.name = "Bot Name"'
]
end
let(:file_lines) do
[
'def security_bot',
' user.name = "Bot Name"',
'end'
]
end
include_examples 'has violations'
end
context 'when changing lines outside any bot method definition' do
let(:changed_lines) do
[
' user.name = "Regular Name"'
]
end
let(:file_lines) do
[
'def regular_method',
' user.name = "Regular Name"',
'end'
]
end
include_examples 'no violations'
end
context 'when bot method spans multiple ends' do
let(:changed_lines) do
[
' user.tap do |u|',
' u.name = "Bot Name"',
' end'
]
end
let(:file_lines) do
[
'def security_bot',
' user.tap do |u|',
' u.name = "Bot Name"',
' end',
'end'
]
end
include_examples 'has violations'
end
context 'when modifying code between bot methods' do
let(:changed_lines) do
[
' user.name = "Regular Name"'
]
end
let(:file_lines) do
[
'def security_bot',
' # bot code',
'end',
' user.name = "Regular Name"',
'def alert_bot',
' # bot code',
'end'
]
end
include_examples 'no violations'
end
end
describe '#file_has_violations?' do
context 'when tracking bot method boundaries' do
let(:changed_lines) { [' some_code'] }
it 'correctly tracks entering and exiting bot methods' do
allow(fake_project_helper).to receive(:file_lines).and_return([
'def security_bot',
' some_code',
'end'
])
expect(helper.send(:file_has_violations?, file_path)).to be true
allow(fake_project_helper).to receive(:file_lines).and_return([
'def security_bot',
' other_code',
'end',
' some_code'
])
expect(helper.send(:file_has_violations?, file_path)).to be false
end
it 'handles nested end keywords' do
allow(fake_project_helper).to receive(:file_lines).and_return([
'def security_bot',
' some_code',
' if true',
' some_code',
' end',
'end'
])
expect(helper.send(:file_has_violations?, file_path)).to be true
end
it 'processes all lines in method body' do
lines = [
'def security_bot',
' line1',
' line2',
' line3',
'end'
]
allow(fake_project_helper).to receive(:file_lines).and_return(lines)
allow(fake_helper).to receive(:changed_lines).and_return([' line2'])
expect(helper.send(:file_has_violations?, file_path)).to be true
end
end
end
end

View File

@ -37,14 +37,12 @@ RSpec.describe Import::BulkImports::SourceUsersAttributesWorker, feature_categor
expect(Import::BulkImports::UpdateSourceUsersService).to receive(:new).with(
namespace: project.root_ancestor,
bulk_import: bulk_import,
options: {}
bulk_import: bulk_import
).and_return(service_double).ordered
expect(Import::BulkImports::UpdateSourceUsersService).to receive(:new).with(
namespace: group,
bulk_import: bulk_import,
options: {}
bulk_import: bulk_import
).and_return(service_double).ordered
perform
@ -66,14 +64,13 @@ RSpec.describe Import::BulkImports::SourceUsersAttributesWorker, feature_categor
allow(bulk_import).to receive(:completed?).and_return(true)
end
it 'calls the service with a minimum_batch_size set' do
it 'executes UpdateSourceUsersService' do
service_double = instance_double(Import::BulkImports::UpdateSourceUsersService)
allow(service_double).to receive(:execute)
expect(Import::BulkImports::UpdateSourceUsersService).to receive(:new).with(
namespace: anything,
bulk_import: bulk_import,
options: { minimum_batch_size: 1 }
bulk_import: bulk_import
).twice.and_return(service_double)
perform

View File

@ -1,110 +0,0 @@
# frozen_string_literal: true
require_relative 'suggestor'
module Tooling
module Danger
module InternalUsers
include ::Tooling::Danger::Suggestor
DOCS_PATH = 'doc/administration/internal_users.md'
INLINE_COMMENT =
<<~MESSAGE.freeze
This line modifies internal user behavior or references an internal bot user.
If this introduces new capabilities or changes existing behavior, please update the [internal users documentation](#{DOCS_PATH}).
This comment will only appear once per file.
MESSAGE
MR_LEVEL_COMMENT =
<<~SUGGEST_COMMENT.freeze
This MR changes internal user behavior or usage.
Please ensure [internal users documentation](#{DOCS_PATH}) is up to date.
If this is not applicable, please ignore this message.
Consider documenting:
- Any changes to internal user behavior or capabilities
- New use cases or integrations
- Changes in how bots may interact with GitLab and its features
SUGGEST_COMMENT
BOT_METHODS = %w[
support_bot
alert_bot
visual_review_bot
ghost
migration_bot
security_bot
automation_bot
security_policy_bot
admin_bot
suggested_reviewers_bot
llm_bot
placeholder
duo_code_review_bot
import_user
].freeze
BOT_NAMES = BOT_METHODS.join('|')
BOT_METHOD_DEF_REGEX = /^\s*def\s+(?:self\.)?\s*(#{BOT_NAMES})(?!\w)/
BOT_MODULE_CALL_REGEX = /^\s*Users::Internal\.(?:#{BOT_NAMES})(?!\w)/
BOT_SYMBOL_TYPE_REGEX = /^\s*(?!#|")[^#"]*:(?:#{BOT_NAMES})(?!\w)/
def add_comment_for_internal_users_changes
return if helper.all_changed_files.include?(DOCS_PATH)
violations_found = false
helper.all_changed_files.each do |filename|
next unless file_has_violations?(filename)
violations_found = true
markdown(INLINE_COMMENT, file: filename)
end
warn(MR_LEVEL_COMMENT) if violations_found
end
private
def file_has_violations?(filename)
changed_lines = helper.changed_lines(filename)
file_lines = project_helper.file_lines(filename)
in_bot_method = false
has_violation = false
changed_lines.each do |line|
clean_line = line.delete_prefix('+').delete_prefix('-').strip
next unless clean_line.match?(BOT_METHOD_DEF_REGEX) ||
clean_line.match?(BOT_MODULE_CALL_REGEX) ||
clean_line.match?(BOT_SYMBOL_TYPE_REGEX)
has_violation = true
break
end
return has_violation if has_violation
file_lines.each_with_index do |line, _|
if line.match?(BOT_METHOD_DEF_REGEX)
in_bot_method = true
elsif line.strip == 'end'
in_bot_method = false
end
next unless in_bot_method
next unless changed_lines.any? { |cl| cl.delete_prefix('+').delete_prefix('-').strip == line.strip }
has_violation = true
break
end
has_violation
end
end
end
end