Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-11-05 00:32:10 +00:00
parent 6b38cdf61a
commit db79e99417
51 changed files with 977 additions and 466 deletions

View File

@ -1512,6 +1512,8 @@ lib/gitlab/checks/**
/**/lib/**/ci/reports/**/ @gitlab-org/maintainers/rails-backend
/**/lib/**/ci/parsers/**/ @gitlab-org/maintainers/rails-backend
/**/app/views/ci/**/ @gitlab-org/maintainers/rails-backend
/ee/**/models/ai/ai_resource/ci/ @gitlab-org/maintainers/rails-backend
/ee/**/serializers/ci/*_ai_*.rb @gitlab-org/maintainers/rails-backend
/ee/lib/gitlab/ci/parsers/license_compliance/ @gitlab-org/secure/composition-analysis-be
/ee/lib/gitlab/ci/parsers/security/ @gitlab-org/govern/threat-insights-backend-team
/ee/lib/gitlab/ci/reports/coverage_fuzzing/ @gitlab-org/secure/fuzzing-be

View File

@ -22,14 +22,12 @@ Gitlab/DocumentationLinks/Link:
- 'ee/lib/gitlab/checks/secrets_check.rb'
- 'ee/lib/gitlab/llm/chain/tools/tool.rb'
- 'ee/lib/gitlab/llm/embeddings/utils/base_content_parser.rb'
- 'ee/lib/system_check/geo/current_node_check.rb'
- 'ee/spec/helpers/projects/learn_gitlab_helper_spec.rb'
- 'ee/spec/lib/gitlab/llm/embeddings/utils/base_content_parser_spec.rb'
- 'ee/spec/presenters/gitlab_subscriptions/trials/duo_enterprise_status_widget_presenter_spec.rb'
- 'ee/spec/support/shared_contexts/secrets_check_shared_contexts.rb'
- 'lib/backup/tasks/database.rb'
- 'lib/gitlab/security/features.rb'
- 'lib/gitlab/usage_data_counters/hll_redis_counter.rb'
- 'lib/slack/block_kit/app_home_opened.rb'
- 'lib/system_check/helpers.rb'
- 'spec/helpers/ide_helper_spec.rb'

View File

@ -284,7 +284,6 @@ export default {
<template #row-details="{ item }">
<pipeline-failed-jobs-widget
v-if="displayFailedJobsWidget(item)"
:failed-jobs-count="failedJobsCount(item)"
:is-pipeline-active="item.active"
:pipeline-iid="item.iid"
:pipeline-path="item.path"

View File

@ -1,6 +1,3 @@
import { s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
export const EDITOR_APP_DRAWER_HELP = 'HELP';
export const EDITOR_APP_DRAWER_JOB_ASSISTANT = 'JOB_ASSISTANT';
export const EDITOR_APP_DRAWER_AI_ASSISTANT = 'AI_ASSISTANT';
@ -93,36 +90,3 @@ export const pipelineEditorTrackingOptions = {
export const VALIDATE_TAB_FEEDBACK_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/346687';
export const COMMIT_SHA_POLL_INTERVAL = 1000;
export const MIGRATION_PLAN_HELP_PATH = helpPagePath('ci/migration/plan_a_migration');
export const MIGRATE_FROM_JENKINS_TRACKING_LABEL = 'migrate_from_jenkins_prompt';
export const I18N = {
title: s__('Pipelines|Get started with GitLab CI/CD'),
learnBasics: {
title: s__('Pipelines|Learn the basics of pipelines and .yml files'),
subtitle: s__(
'Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works.',
),
gettingStarted: {
title: s__('Pipelines|"Hello world" with GitLab CI'),
description: s__(
'Pipelines|Get familiar with GitLab CI syntax by setting up a simple pipeline running a "Hello world" script to see how it runs, explore how CI/CD works.',
),
cta: s__('Pipelines|Try test template'),
},
migrateFromJenkins: {
title: s__('Pipelines|Migrate to GitLab CI/CD from Jenkins'),
description: s__(
'Pipelines|Take advantage of simple, scalable pipelines and CI/CD-enabled features. You can view integration results, security scans, tests, code coverage and more directly in merge requests!',
),
cta: s__('Pipelines|Start with a migration plan'),
},
},
templates: {
title: s__('Pipelines|Ready to set up CI/CD for your project?'),
subtitle: s__(
"Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.",
),
},
};

View File

@ -0,0 +1,144 @@
<script>
import { GlButton, GlCard } from '@gitlab/ui';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import {
CATALOG_TRACKING_LABEL,
GITLAB_UNIVERSITY_TRACKING_LABEL,
MIGRATE_FROM_JENKINS_TRACKING_LABEL,
} from '~/ci/pipelines_page/constants';
import { STARTER_TEMPLATE_NAME } from '~/ci/pipeline_editor/constants';
import { helpPagePath } from '~/helpers/help_page_helper';
const cards = {
jenkins_migration_card: {
id: 'jenkins_migration',
buttonLink: helpPagePath('ci/migration/plan_a_migration'),
buttonText: s__('Pipelines|Start with a migration plan'),
description: s__(
'Pipelines|Take advantage of simple, scalable pipelines and CI/CD-enabled features. You can view integration results, security scans, tests, code coverage and more directly in merge requests!',
),
emoji: 'rocket',
isVisible: false,
styles: 'gl-bg-blue-50',
title: s__('Pipelines|Migrate to GitLab CI/CD from Jenkins'),
trackingLabel: MIGRATE_FROM_JENKINS_TRACKING_LABEL,
},
getting_started_card: {
id: 'getting_started',
buttonLink: '',
buttonText: s__('Pipelines|Try test template'),
description: s__(
'Pipelines|Get familiar with GitLab CI syntax by setting up a simple pipeline running a "Hello world" script to see how it runs, explore how CI/CD works.',
),
emoji: 'wave',
isVisible: true,
title: s__('Pipelines|"Hello world" with GitLab CI'),
trackingLabel: STARTER_TEMPLATE_NAME,
},
gitlab_university_card: {
id: 'gitlab_university',
buttonLink: 'https://university.gitlab.com/pages/ci-cd-content',
buttonText: s__('Pipelines|Access GitLab University'),
description: s__(
'Pipelines|Learn how to set up and use GitLab CI/CD with guided tutorials, videos, and best practices.',
),
emoji: 'mortar_board',
isVisible: true,
title: s__('Pipelines|Learn CI/CD with GitLab University'),
trackingLabel: GITLAB_UNIVERSITY_TRACKING_LABEL,
},
ci_cd_catalog_card: {
id: 'ci_cd_catalog',
buttonLink: '/explore/catalog',
buttonText: s__('Pipelines|Explore CI/CD Catalog'),
description: s__(
'Pipelines|Explore CI components in the CI/CD Catalog to see if they suit your requirements.',
),
emoji: 'bulb',
isVisible: true,
title: s__('Pipelines|Easy configuration with CI/CD Catalog'),
trackingLabel: CATALOG_TRACKING_LABEL,
},
};
export default {
name: 'CiCards',
components: {
GlButton,
GlCard,
},
mixins: [Tracking.mixin()],
inject: ['pipelineEditorPath', 'showJenkinsCiPrompt'],
computed: {
cards() {
return [
{
...cards.jenkins_migration_card,
isVisible: this.showJenkinsCiPrompt,
},
{
...cards.getting_started_card,
buttonLink: this.gettingStartedTemplateUrl,
},
cards.gitlab_university_card,
cards.ci_cd_catalog_card,
];
},
gettingStartedTemplateUrl() {
return mergeUrlParams({ template: STARTER_TEMPLATE_NAME }, this.pipelineEditorPath);
},
},
mounted() {
if (this.showJenkinsCiPrompt) {
this.trackEvent('render', MIGRATE_FROM_JENKINS_TRACKING_LABEL);
}
},
methods: {
trackEvent(label) {
this.track('template_clicked', { label });
},
},
};
</script>
<template>
<div class="gl-grid gl-gap-5 md:gl-grid-cols-2 lg:gl-grid-cols-4">
<template v-for="card in cards">
<gl-card
v-if="card.isVisible"
:key="card.id"
:body-class="['gl-h-full']"
:class="card.styles"
>
<div class="gl-flex !gl-h-full gl-flex-col gl-justify-between gl-gap-4">
<div>
<gl-emoji
class="gl-pb-5 gl-text-size-h2-xl"
:data-name="card.emoji"
data-testid="ci-card-emoji"
/>
<p class="gl-mb-3">
<strong class="gl-mb-2 gl-text-gray-800" data-testid="ci-card-title">{{
card.title
}}</strong>
</p>
<p class="gl-flex-grow gl-text-sm" data-testid="ci-card-description">
{{ card.description }}
</p>
</div>
<gl-button
category="primary"
variant="confirm"
:href="card.buttonLink"
target="_blank"
@click="trackEvent(card.trackingLabel)"
>
{{ card.buttonText }}
</gl-button>
</div>
</gl-card>
</template>
</div>
</template>

View File

@ -1,124 +1,47 @@
<script>
import { GlButton, GlCard, GlSprintf } from '@gitlab/ui';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import {
STARTER_TEMPLATE_NAME,
I18N,
MIGRATION_PLAN_HELP_PATH,
MIGRATE_FROM_JENKINS_TRACKING_LABEL,
} from '~/ci/pipeline_editor/constants';
import Tracking from '~/tracking';
import { s__ } from '~/locale';
import CiCards from './ci_cards.vue';
import CiTemplates from './ci_templates.vue';
export default {
components: {
GlButton,
GlCard,
GlSprintf,
CiTemplates,
},
mixins: [Tracking.mixin()],
STARTER_TEMPLATE_NAME,
I18N,
inject: ['pipelineEditorPath', 'showJenkinsCiPrompt'],
data() {
return {
gettingStartedTemplateUrl: mergeUrlParams(
{ template: STARTER_TEMPLATE_NAME },
this.pipelineEditorPath,
name: 'PipelinesCiTemplates',
i18n: {
title: s__('Pipelines|Get started with GitLab CI/CD'),
learnBasics: {
title: s__('Pipelines|Learn the basics of pipelines and .yml files'),
subtitle: s__(
'Pipelines|Use CI/CD pipelines to automatically build, test, and deploy your code.',
),
tracker: null,
migrationPlanUrl: MIGRATION_PLAN_HELP_PATH,
migrationPromptTrackingLabel: MIGRATE_FROM_JENKINS_TRACKING_LABEL,
};
},
mounted() {
if (this.showJenkinsCiPrompt) {
this.trackEvent('render', this.migrationPromptTrackingLabel);
}
},
methods: {
trackEvent(action, label) {
this.track(action, { label });
},
templates: {
title: s__('Pipelines|Ready to set up CI/CD for your project?'),
subtitle: s__(
"Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.",
),
},
},
components: {
CiCards,
CiTemplates,
},
};
</script>
<template>
<div>
<h2 class="gl-text-size-h2 gl-text-gray-900">{{ $options.I18N.title }}</h2>
<h2 class="gl-text-size-h2">{{ $options.i18n.title }}</h2>
<h2 class="gl-text-lg gl-text-gray-900">{{ $options.I18N.learnBasics.title }}</h2>
<p class="gl-mb-6 gl-text-gray-800">
<gl-sprintf :message="$options.I18N.learnBasics.subtitle">
<template #code="{ content }">
<code>{{ content }}</code>
</template>
</gl-sprintf>
</p>
<div class="gl-flex gl-flex-row gl-flex-wrap">
<div
v-if="showJenkinsCiPrompt"
class="gl-w-full gl-pb-8 md:gl-w-1/2 md:gl-pr-5 lg:gl-w-1/4"
data-testid="migrate-from-jenkins-prompt"
>
<gl-card class="gl-bg-blue-50">
<div class="gl-flex-row">
<div class="gl-py-5"><gl-emoji class="gl-text-size-h2-xl" data-name="rocket" /></div>
<div class="gl-mb-3">
<strong class="gl-mb-2 gl-text-gray-800">{{
$options.I18N.learnBasics.migrateFromJenkins.title
}}</strong>
</div>
<p class="gl-h-13 gl-text-sm">
{{ $options.I18N.learnBasics.migrateFromJenkins.description }}
</p>
</div>
<gl-button
category="primary"
variant="confirm"
:href="migrationPlanUrl"
target="_blank"
@click="trackEvent('template_clicked', migrationPromptTrackingLabel)"
>
{{ $options.I18N.learnBasics.migrateFromJenkins.cta }}
</gl-button>
</gl-card>
</div>
<div class="gl-w-full gl-pb-8 md:gl-w-1/2 lg:gl-w-1/4">
<gl-card>
<div class="gl-flex-row">
<div class="gl-py-5"><gl-emoji class="gl-text-size-h2-xl" data-name="wave" /></div>
<div class="gl-mb-3">
<strong class="gl-mb-2 gl-text-gray-800">
{{ $options.I18N.learnBasics.gettingStarted.title }}
</strong>
</div>
<p class="gl-h-13 gl-text-sm">
{{ $options.I18N.learnBasics.gettingStarted.description }}
</p>
</div>
<gl-button
category="primary"
variant="confirm"
:href="gettingStartedTemplateUrl"
data-testid="test-template-link"
@click="trackEvent('template_clicked', $options.STARTER_TEMPLATE_NAME)"
>
{{ $options.I18N.learnBasics.gettingStarted.cta }}
</gl-button>
</gl-card>
</div>
<div class="gl-mb-8">
<h3 class="gl-text-lg">{{ $options.i18n.learnBasics.title }}</h3>
<p class="gl-mb-6 gl-text-gray-800">{{ $options.i18n.learnBasics.subtitle }}</p>
<ci-cards />
</div>
<h2 class="gl-text-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2>
<p class="gl-mb-6 gl-text-gray-800">{{ $options.I18N.templates.subtitle }}</p>
<ci-templates />
<div>
<h3 class="gl-text-lg">{{ $options.i18n.templates.title }}</h3>
<p class="gl-mb-6 gl-text-gray-800">{{ $options.i18n.templates.subtitle }}</p>
<ci-templates />
</div>
</div>
</template>

View File

@ -0,0 +1 @@
export const POLL_INTERVAL = 10000;

View File

@ -7,8 +7,7 @@ import { graphqlEtagPipelinePath } from '~/ci/pipeline_details/utils';
import getPipelineFailedJobs from '~/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql';
import { sortJobsByStatus } from './utils';
import FailedJobDetails from './failed_job_details.vue';
const POLL_INTERVAL = 10000;
import { POLL_INTERVAL } from './constants';
const JOB_ID_HEADER = __('ID');
const JOB_NAME_HEADER = __('Name');
@ -24,18 +23,10 @@ export default {
},
inject: ['graphqlPath'],
props: {
failedJobsCount: {
required: true,
type: Number,
},
isMaximumJobLimitReached: {
required: true,
type: Boolean,
},
isPipelineActive: {
required: true,
type: Boolean,
},
pipelineIid: {
type: Number,
required: true,
@ -52,7 +43,6 @@ export default {
data() {
return {
failedJobs: [],
isActive: false,
isLoadingMore: false,
canTroubleshootJob: false,
};
@ -77,11 +67,6 @@ export default {
result({ data }) {
const pipeline = data?.project?.pipeline;
this.canTroubleshootJob = pipeline?.troubleshootJobWithAi || false;
if (pipeline?.jobs?.count) {
this.$emit('failed-jobs-count', pipeline.jobs.count);
this.isActive = pipeline.active;
}
},
error(e) {
createAlert({ message: e?.message || this.$options.i18n.fetchError, variant: 'danger' });
@ -99,43 +84,12 @@ export default {
return this.$apollo.queries.failedJobs.loading;
},
},
watch: {
isPipelineActive(flag) {
// Turn polling on and off based on REST actions
// By refetching jobs, we will get the graphql `active`
// field to update properly and cascade the polling changes
this.refetchJobs();
this.handlePolling(flag);
},
isActive(flag) {
this.handlePolling(flag);
},
failedJobsCount(count) {
// If the REST data is updated first, we force a refetch
// to keep them in sync
if (this.failedJobs.length !== count) {
this.$apollo.queries.failedJobs.refetch();
}
},
},
mounted() {
if (!this.isActive && !this.isPipelineActive) {
this.handlePolling(false);
}
},
methods: {
handlePolling(isActive) {
// If the pipeline status has changed and the widget is not expanded,
// We start polling.
if (isActive) {
this.$apollo.queries.failedJobs.startPolling(POLL_INTERVAL);
} else {
this.$apollo.queries.failedJobs.stopPolling();
}
},
async retryJob(jobName) {
await this.refetchJobs();
this.$emit('job-retried');
this.$toast.show(sprintf(this.$options.i18n.retriedJobsSuccess, { jobName }));
},
async refetchJobs() {
@ -144,7 +98,7 @@ export default {
try {
await this.$apollo.queries.failedJobs.refetch();
} catch {
createAlert(this.$options.i18n.fetchError);
createAlert({ message: this.$options.i18n.fetchError });
} finally {
this.isLoadingMore = false;
}

View File

@ -1,10 +1,16 @@
<script>
import { GlBadge, GlButton, GlIcon } from '@gitlab/ui';
import { createAlert } from '~/alert';
import CrudComponent from '~/vue_shared/components/crud_component.vue';
import { __ } from '~/locale';
import { getQueryHeaders } from '~/ci/pipeline_details/graph/utils';
import { graphqlEtagPipelinePath } from '~/ci/pipeline_details/utils';
import getPipelineFailedJobsCount from '../../graphql/queries/get_pipeline_failed_jobs_count.query.graphql';
import FailedJobsList from './failed_jobs_list.vue';
import { POLL_INTERVAL } from './constants';
export default {
fetchError: __('An error occured fetching failed jobs count'),
components: {
GlBadge,
GlButton,
@ -12,12 +18,8 @@ export default {
FailedJobsList,
CrudComponent,
},
inject: ['fullPath'],
inject: ['fullPath', 'graphqlPath'],
props: {
failedJobsCount: {
required: true,
type: Number,
},
isPipelineActive: {
required: true,
type: Boolean,
@ -35,42 +37,64 @@ export default {
type: String,
},
},
apollo: {
failedJobsCount: {
context() {
return getQueryHeaders(this.graphqlResourceEtag);
},
query: getPipelineFailedJobsCount,
// Only poll if the pipeline is active
pollInterval() {
return this.isPipelineActive ? POLL_INTERVAL : 0;
},
variables() {
return {
fullPath: this.projectPath,
pipelineIid: this.pipelineIid,
};
},
update({ project }) {
return project?.pipeline?.jobs?.count || 0;
},
error() {
createAlert({ message: this.$options.i18n.fetchError });
},
},
},
data() {
return {
currentFailedJobsCount: this.failedJobsCount,
isActive: false,
failedJobsCount: 0,
isExpanded: false,
};
},
computed: {
graphqlResourceEtag() {
return graphqlEtagPipelinePath(this.graphqlPath, this.pipelineIid);
},
bodyClasses() {
return this.isExpanded ? '' : 'gl-hidden';
},
failedJobsCountLabel() {
return `${this.isMaximumJobLimitReached ? '100+' : this.currentFailedJobsCount}`;
failedJobsCountBadge() {
return `${this.isMaximumJobLimitReached ? '100+' : this.failedJobsCount}`;
},
iconName() {
return this.isExpanded ? 'chevron-down' : 'chevron-right';
},
isMaximumJobLimitReached() {
return this.currentFailedJobsCount > 100;
},
},
watch: {
failedJobsCount(val) {
this.currentFailedJobsCount = val;
return this.failedJobsCount > 100;
},
},
methods: {
setFailedJobsCount(count) {
this.currentFailedJobsCount = count;
},
toggleWidget() {
this.isExpanded = !this.isExpanded;
},
},
i18n: {
failedJobsLabel: __('Failed jobs'),
async refetchCount() {
try {
await this.$apollo.queries.failedJobsCount.refetch();
} catch {
createAlert({ message: this.$options.i18n.fetchError });
}
},
},
ariaControlsId: 'pipeline-failed-jobs-widget',
};
@ -93,24 +117,22 @@ export default {
>
<gl-icon :name="iconName" class="gl-mr-2" />
<span class="gl-font-bold gl-text-subtle">
{{ $options.i18n.failedJobsLabel }}
{{ __('Failed jobs') }}
</span>
</gl-button>
</template>
<template #count>
<gl-badge>
{{ failedJobsCountLabel }}
{{ failedJobsCountBadge }}
</gl-badge>
</template>
<failed-jobs-list
v-if="isExpanded"
:failed-jobs-count="failedJobsCount"
:is-maximum-job-limit-reached="isMaximumJobLimitReached"
:is-pipeline-active="isPipelineActive"
:pipeline-iid="pipelineIid"
:pipeline-path="pipelinePath"
:project-path="projectPath"
@failed-jobs-count="setFailedJobsCount"
@job-retried="refetchCount"
/>
</crud-component>
</template>

View File

@ -3,3 +3,7 @@ export const FILTER_PIPELINES_SEARCH_DELAY = 200;
export const SCHEDULE_ORIGIN = 'schedule';
export const API_ORIGIN = 'api';
export const TRIGGER_ORIGIN = 'trigger';
export const CATALOG_TRACKING_LABEL = 'ci_cd_catalog_prompt';
export const GITLAB_UNIVERSITY_TRACKING_LABEL = 'gitlab_university_prompt';
export const MIGRATE_FROM_JENKINS_TRACKING_LABEL = 'migrate_from_jenkins_prompt';

View File

@ -3,7 +3,6 @@ query getPipelineFailedJobsCount($fullPath: ID!, $pipelineIid: ID!) {
id
pipeline(iid: $pipelineIid) {
id
active
jobs(statuses: [FAILED], retried: false, jobKind: BUILD) {
count
}

View File

@ -303,7 +303,7 @@ export default {
v-if="config.showContainerRegistrySettings"
v-gl-tooltip="$options.i18n.SETTINGS_TEXT"
icon="settings"
:href="config.cleanupPoliciesSettingsPath"
:href="config.settingsPath"
:aria-label="$options.i18n.SETTINGS_TEXT"
/>
</template>

View File

@ -539,7 +539,7 @@
mask-repeat: no-repeat;
mask-size: cover;
mask-position: center center;
background: $black-normal;
background: var(--gl-dropdown-option-indicator-color-selected-default);
}
}
}

View File

@ -1,7 +1,5 @@
@import '@gitlab/ui/src/tokens/build/scss/tokens.dark';
$black-normal: $gray-900;
$border-color: #4f4f4f;
$gl-text-color-secondary: $gray-700;

View File

@ -18,8 +18,9 @@ module ContainerRegistry
gid_prefix: container_repository_gid_prefix,
is_admin: current_user&.admin.to_s,
show_cleanup_policy_link: show_cleanup_policy_link(project).to_s,
show_container_registry_settings: show_container_registry_settings(project).to_s,
cleanup_policies_settings_path: cleanup_image_tags_project_settings_packages_and_registries_path(project),
show_container_registry_settings: show_container_registry_settings(project).to_s,
settings_path: project_settings_packages_and_registries_path(project),
connection_error: (!!connection_error).to_s,
invalid_path_error: (!!invalid_path_error).to_s,
user_callouts_path: callouts_path,

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
module Projects
module DeployTokens
class RevokeService < BaseProjectService
attr_accessor :token, :source
def execute
return ServiceResponse.error(message: 'Unauthorized to revoke project deploy token') unless can_revoke_token?
@token = project.deploy_tokens.find(params[:id])
token.revoke!
ServiceResponse.success(message: 'Token was revoked')
rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved
ServiceResponse.error(message: 'Token was not revoked')
end
private
def can_revoke_token?
current_user.can_admin_all_resources?
end
end
end
end

View File

@ -1,5 +1,6 @@
- add_to_breadcrumbs _("Messages"), admin_broadcast_messages_path
- breadcrumb_title @broadcast_message.id
- add_to_breadcrumbs @broadcast_message.id, edit_admin_broadcast_message_path(@broadcast_message)
- breadcrumb_title _("Edit")
- page_title _("Broadcast Messages")
#js-broadcast-message{ data: broadcast_message_data(@broadcast_message) }

View File

@ -1,7 +1,8 @@
- @gfm_form = true
- breadcrumb_title _('Edit')
- add_to_breadcrumbs s_('FeatureFlags|Feature flags'), project_feature_flags_path(@project)
- breadcrumb_title @feature_flag.name
- add_to_breadcrumbs @feature_flag.name, edit_project_feature_flag_path(@project, @feature_flag)
- page_title s_('FeatureFlags|Edit Feature flag'), @feature_flag.name
#js-edit-feature-flag{ data: edit_feature_flag_data }

View File

@ -1,5 +1,6 @@
- page_title _('Edit Release')
- breadcrumb_title _('Edit')
- add_to_breadcrumbs _('Releases'), project_releases_path(@project)
- add_to_breadcrumbs @release.name, project_release_path(@project, @release)
- page_title _('Edit Release')
#js-edit-release-page{ data: data_for_edit_release_page }

View File

@ -70,7 +70,7 @@ module MergeRequests
AutoMergeProcessWorker.bulk_perform_in_with_contexts(
[1, index * DELAY].max,
merge_requests,
arguments_proc: ->(merge_request) { [merge_request.id] },
arguments_proc: ->(merge_request) { [{ 'merge_request_id' => merge_request.id }] },
context_proc: ->(merge_request) { { project: merge_request.project, user: merge_request.merge_user } }
)
end

View File

@ -3,7 +3,7 @@
class UpdateMergeRequestsWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
data_consistency :always
data_consistency :sticky, feature_flag: :update_merge_request_worker_sticky
sidekiq_options retry: 3

View File

@ -0,0 +1,9 @@
---
name: update_merge_request_worker_sticky
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/403184
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/171352
rollout_issue_url:
milestone: '17.6'
group: group::code review
type: worker
default_enabled: false

View File

@ -379,6 +379,8 @@
- 1
- - gitlab_subscriptions_add_on_purchases_cleanup_user_add_on_assignment
- 1
- - gitlab_subscriptions_add_on_purchases_email_on_duo_bulk_user_assignments
- 1
- - gitlab_subscriptions_add_on_purchases_refresh_user_assignments
- 1
- - gitlab_subscriptions_member_management_apply_pending_member_approvals
@ -779,6 +781,8 @@
- 1
- - search_zoekt_repo_marked_as_to_delete_event
- 1
- - search_zoekt_repo_to_index_event
- 1
- - search_zoekt_task_failed_event
- 1
- - search_zoekt_update_index_used_bytes

View File

@ -81,6 +81,28 @@ Try to split all your migrations into two steps:
1. Refactor just the Vuex API: Don't change the store structure, make sure it works in Pinia ([example](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/149489)).
1. Refactor the structure: Split your store into multiple smaller, single purpose stores.
### Automated migration using codemods
You can use [ast-grep](https://ast-grep.github.io/) codemods to simplify migration from Vuex to Pinia.
1. [Install ast-grep](https://ast-grep.github.io/guide/quick-start.html#installation) on your system before proceeding.
1. Run `scripts/frontend/codemods/vuex-to-pinia/migrate.sh path/to/your/store`
The codemods will migrate `actions.js`, `mutations.js` and `getters.js` located in your store folder.
Manually scan these files after running the codemods to ensure they are properly migrated.
Vuex specs can not be automatically migrated, migrate them by hand.
Vuex module calls are replaced using Pinia conventions:
1. `dispatch('anotherModule/action', ...args, { root: true })``useAnotherModule().action(...args)`
1. `dispatch('action', ...args, { root: true })``useRootStore().action(...args)`
1. `rootGetters['anotherModule/getter']``useAnotherModule().getter`
1. `rootGetters.getter``useRootStore().getter`
1. `rootState.anotherModule.state``useAnotherModule().state`
If you have not yet migrated a dependent module (`useAnotherModule` and `useRootStore` in the examples above) you can create a temporary dummy store.
Use the guidance below to migrate Vuex modules.
### Migrating stores with nested modules
It is not trivial to iteratively migrate stores with nested modules that have dependencies between them.

View File

@ -40,7 +40,9 @@ upgrade plan, document and share it with the answers to the following questions:
It's possible that something may go wrong during an upgrade, so it's critical
that a rollback plan be present for that scenario. A proper rollback plan
creates a clear path to bring the instance back to its last working state. It is
comprised of a way to back up the instance and a way to restore it.
comprised of a way to back up the instance and a way to restore it. You should
test the rollback plan before you need it. For an overview of the steps required
for rolling back, see [Downgrade](package/downgrade.md).
### Back up GitLab

View File

@ -295,6 +295,26 @@ A vulnerability's status can be:
vulnerability is reintroduced and detected again, its record is reinstated and its status set to
detected.
A vulnerability typically goes through the following lifecycle:
```mermaid
%%{init: { "fontFamily": "GitLab Sans" }}%%
stateDiagram
accTitle: Vulnerability lifecycle
accDescr: Typical lifecycle of a vulnerability
direction LR
Needs_triage: Needs triage
[*] --> Needs_triage
Needs_triage --> Confirmed
Needs_triage --> Dismissed
Dismissed --> [*]
Confirmed --> Resolved
Resolved --> Needs_triage: If reintroduced and detected again
Resolved --> [*]
```
## Vulnerability dismissal reasons
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/4942) in GitLab 15.11 [with a flag](../../../administration/feature_flags.md) named `dismissal_reason`.

View File

@ -150,7 +150,7 @@ module Gitlab
return if used_in_aggregate_metric
if property_name && legacy_events.include?(event_name)
link = Rails.application.routes.url_helpers.help_page_url('ee/development/internal_analytics/internal_event_instrumentation/migration.md#backend-1')
link = Rails.application.routes.url_helpers.help_page_url('development/internal_analytics/internal_event_instrumentation/migration.md', anchor: 'backend-1')
message = "Event #{event_name} has been invoked with property_name.\n" \
"When an event gets migrated to Internal Events, its name needs to be removed " \
"from hll_redis_legacy_events.yml and added to hll_redis_key_overrides.yml: #{link}"

View File

@ -5843,6 +5843,9 @@ msgstr ""
msgid "An error in reporting in which a test result incorrectly indicates the presence of a vulnerability in a system when the vulnerability is not present."
msgstr ""
msgid "An error occured fetching failed jobs count"
msgstr ""
msgid "An error occurred creating the new branch."
msgstr ""
@ -40488,6 +40491,9 @@ msgstr ""
msgid "Pipelines|%{counterLabel} more downstream pipelines"
msgstr ""
msgid "Pipelines|Access GitLab University"
msgstr ""
msgid "Pipelines|Active"
msgstr ""
@ -40539,12 +40545,21 @@ msgstr ""
msgid "Pipelines|Description"
msgstr ""
msgid "Pipelines|Easy configuration with CI/CD Catalog"
msgstr ""
msgid "Pipelines|Edit"
msgstr ""
msgid "Pipelines|Editor"
msgstr ""
msgid "Pipelines|Explore CI components in the CI/CD Catalog to see if they suit your requirements."
msgstr ""
msgid "Pipelines|Explore CI/CD Catalog"
msgstr ""
msgid "Pipelines|Failed to update. Please reload page to update the list of artifacts."
msgstr ""
@ -40557,7 +40572,7 @@ msgstr ""
msgid "Pipelines|Full configuration is view only"
msgstr ""
msgid "Pipelines|Get familiar with GitLab CI syntax by setting up a simple pipeline running a \"Hello world\" script to see how it runs, explore how CI/CD works."
msgid "Pipelines|Get familiar with GitLab CI syntax by setting up a simple pipeline running a \"Hello world\" script to see how it runs, explore how CI/CD works."
msgstr ""
msgid "Pipelines|Get started with GitLab CI/CD"
@ -40581,6 +40596,12 @@ msgstr ""
msgid "Pipelines|Last Used"
msgstr ""
msgid "Pipelines|Learn CI/CD with GitLab University"
msgstr ""
msgid "Pipelines|Learn how to set up and use GitLab CI/CD with guided tutorials, videos, and best practices."
msgstr ""
msgid "Pipelines|Learn the basics of pipelines and .yml files"
msgstr ""
@ -40743,7 +40764,7 @@ msgstr ""
msgid "Pipelines|Update Trigger"
msgstr ""
msgid "Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works."
msgid "Pipelines|Use CI/CD pipelines to automatically build, test, and deploy your code."
msgstr ""
msgid "Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD."
@ -48424,6 +48445,9 @@ msgstr ""
msgid "ScanResultPolicy|Prevent branch modification"
msgstr ""
msgid "ScanResultPolicy|Prevent group branch modification"
msgstr ""
msgid "ScanResultPolicy|Prevent pushing and force pushing"
msgstr ""
@ -49901,7 +49925,7 @@ msgstr ""
msgid "SecurityOrchestration|Only owners can update Security Policy Project"
msgstr ""
msgid "SecurityOrchestration|Override the following project settings"
msgid "SecurityOrchestration|Override the following project settings:"
msgstr ""
msgid "SecurityOrchestration|Pipeline execution"
@ -50363,6 +50387,9 @@ msgstr ""
msgid "SecurityOrchestration|except projects"
msgstr ""
msgid "SecurityOrchestration|exceptions: %{exceptions}"
msgstr ""
msgid "SecurityOrchestration|group level branch input"
msgstr ""

View File

@ -75,7 +75,7 @@
"@gitlab/fonts": "^1.3.0",
"@gitlab/query-language": "^0.0.5-a-20241017",
"@gitlab/svgs": "3.120.0",
"@gitlab/ui": "101.6.1",
"@gitlab/ui": "101.6.2",
"@gitlab/web-ide": "^0.0.1-dev-20240909013227",
"@mattiasbuelens/web-streams-adapter": "^0.1.0",
"@rails/actioncable": "7.0.8-4",

View File

@ -0,0 +1 @@
Refer to `doc/development/fe_guide/pinia.md` to learn how to use these codemods.

View File

@ -0,0 +1,67 @@
id: arrow-statement-to-function
language: JavaScript
severity: error
rule:
any:
- pattern: export const $ACTION = ({$$$}, $$$ARGS) => $BODY
- pattern: export const $ACTION = ({$$$}) => $BODY
- pattern: export const $ACTION = () => $BODY
constraints: { BODY: { kind: statement_block } }
fix: export function $ACTION($$$ARGS) $BODY
---
id: async-arrow-statement-to-function
language: JavaScript
severity: error
rule:
any:
- pattern: export const $ACTION = async ({$$$}, $$$ARGS) => $BODY
- pattern: export const $ACTION = async ({$$$}) => $BODY
- pattern: export const $ACTION = async () => $BODY
constraints: { BODY: { kind: statement_block } }
fix: export async function $ACTION($$$ARGS) $BODY
---
id: arrow-call-expression-to-function
language: JavaScript
severity: error
rule:
any:
- pattern: export const $ACTION = ({$$$}, $$$ARGS) => $BODY
- pattern: export const $ACTION = ({$$$}) => $BODY
- pattern: export const $ACTION = () => $BODY
constraints: { BODY: { kind: call_expression } }
fix: |-
export function $ACTION($$$ARGS) {
return $BODY
}
---
id: strip-first-argument
language: JavaScript
severity: error
rule:
any:
- pattern: export function $ACTION ({$$$}, $$$ARGS) { $$$BODY }
- pattern: export function $ACTION ({$$$}) { $$$BODY }
fix: |-
export function $ACTION($$$ARGS) {
$$$BODY
}
---
id: strip-first-argument-async
language: JavaScript
severity: error
rule:
any:
- pattern: export async function $ACTION ({$$$}, $$$ARGS) { $$$BODY }
- pattern: export async function $ACTION ({$$$}) { $$$BODY }
fix: |-
export async function $ACTION($$$ARGS) {
$$$BODY
}

View File

@ -0,0 +1,161 @@
id: state
language: JavaScript
severity: error
rule:
pattern: state.$$$REST
inside:
kind: export_statement
stopBy: end
fix: this.$$$REST
---
id: state-id
language: JavaScript
severity: error
rule:
pattern: state
kind: identifier
inside:
any:
- kind: call_expression
inside:
kind: export_statement
stopBy: end
- kind: variable_declarator
inside:
kind: export_statement
stopBy: end
stopBy:
kind: formal_parameters
fix: this
---
id: root-named-state
language: JavaScript
severity: error
rule:
pattern: rootState.$STORE_NAME.$$$REST
transform:
PRE:
replace:
source: $STORE_NAME
replace: (?<STORE>.+)
by: use-$STORE
STORE:
convert:
source: $PRE
toCase: camelCase
fix: $STORE().$$$REST
---
id: commit
language: JavaScript
severity: error
rule:
any:
- pattern: commit($KEY, $$$ARGS)
- pattern: commit($KEY)
fix: this[$KEY]($$$ARGS)
---
id: getters
language: JavaScript
severity: error
rule:
pattern: getters.$$$REST
fix: this.$$$REST
---
id: root-getters
language: JavaScript
severity: error
rule:
pattern: rootGetters.$$$REST
fix: useRootStore().$$$REST
---
id: root-named-getters
language: JavaScript
severity: error
rule:
pattern: "rootGetters['$NAME']"
constraints: { NAME: { regex: / } }
transform:
PRE:
replace:
source: $NAME
replace: ^(?<STORE>.+)/.*
by: use-$STORE
STORE:
convert:
source: $PRE
toCase: camelCase
GETTER_NAME:
replace:
source: $NAME
replace: ^.*\/(?<GETTER_NAME>.+)
by: $GETTER_NAME
fix: $STORE().$GETTER_NAME
---
id: getters-destruct
language: JavaScript
severity: error
rule:
pattern: const { $$$REST } = getters
fix: const { $$$REST } = this
---
id: actions
language: JavaScript
severity: error
rule:
any:
- pattern: dispatch('$ACTION', $$$ARGS)
not:
regex: 'root:'
- pattern: dispatch('$ACTION')
fix: this.$ACTION($$$ARGS)
---
id: root-named-actions
language: JavaScript
severity: error
rule:
pattern: "dispatch('$ACTION', $$$ARGS, { root: true })"
constraints: { ACTION: { regex: / } }
transform:
STORE_PRE:
replace:
source: $ACTION
replace: ^(?<STORE>.+)/.*
by: use-$STORE
STORE:
convert:
source: $STORE_PRE
toCase: camelCase
ACTION_NAME:
replace:
source: $ACTION
replace: ^.*\/(?<ACTION_NAME>.+)
by: $ACTION_NAME
fix: $STORE().$ACTION_NAME($$$ARGS)
---
id: root-unnamed-actions
language: JavaScript
severity: error
rule:
pattern: "dispatch('$ACTION', $$$ARGS, { root: true })"
constraints: { ACTION: { not: { regex: / } } }
fix: useRootStore().$ACTION($$$ARGS)

View File

@ -0,0 +1,41 @@
id: arrow-statement-to-function
language: JavaScript
severity: error
rule:
pattern: export const $GETTER = ($$$) => $BODY
constraints: { BODY: { kind: statement_block } }
fix: export function $GETTER() $BODY
---
id: arrow-call-expression-to-function
language: JavaScript
severity: error
rule:
pattern: export const $GETTER = ($$$) => $BODY
constraints:
BODY:
any:
- kind: call_expression
- kind: binary_expression
- kind: member_expression
- kind: arrow_function
- kind: parenthesized_expression
- kind: subscript_expression
fix: |-
export function $GETTER() {
return $BODY
}
---
id: strip-first-argument
language: JavaScript
severity: error
rule:
any:
- pattern: export function $GETTER ($$$) { $$$BODY }
fix: |-
export function $GETTER() {
$$$BODY
}

View File

@ -0,0 +1,35 @@
#!/bin/bash
if ! command -v sg &> /dev/null; then
printf "Command not found: sg\nPlease install ast-grep first: https://ast-grep.github.io/guide/quick-start.html\n"
exit 1
fi
# Check if the path to the folder is provided
if [ -z "$1" ]; then
echo "Missing folder to the Vuex store. Usage: $0 <path_to_folder>"
exit 1
fi
SCRIPT_PATH=$(realpath $0)
CODEMODS_PATH=$(dirname $SCRIPT_PATH)
FOLDER_PATH=$1
PARTS=("actions" "getters" "mutations")
for item in "${PARTS[@]}"; do
FULL_PATH="${FOLDER_PATH}/${item}.js"
if [[ -f "$FULL_PATH" ]]; then
while ! sg scan "$FULL_PATH" --rule "$CODEMODS_PATH/body.yml" --report-style short; do
sg scan "$FULL_PATH" --rule "$CODEMODS_PATH/body.yml" -U
done
sg scan "$FULL_PATH" --rule "$CODEMODS_PATH/$item.yml" -U
echo "$FULL_PATH migrated successfully."
else
echo "$FULL_PATH doesn't exist, skipping."
fi
done
echo "Migration completed. Please verify each file manually."

View File

@ -0,0 +1,15 @@
id: method-definition
language: JavaScript
severity: error
rule:
any:
- pattern:
selector: method_definition
context: export default { [$$$FUNC]($STATE, $$$ARGS) { $$$BODY } }
- pattern:
selector: method_definition
context: export default { [$$$FUNC]($STATE) { $$$BODY } }
fix: |-
[$$$FUNC]($$$ARGS) {
$$$BODY
}

View File

@ -616,6 +616,10 @@ FactoryBot.define do
empty_repo
end
factory :project_with_repo, parent: :project do
repository
end
factory :forked_project_with_submodules, parent: :project do
path { 'forked-gitlabhq' }

View File

@ -61,7 +61,7 @@ RSpec.describe 'Container Registry', :js, feature_category: :container_registry
it 'does not have link to settings' do
visit_container_registry
expect(page).not_to have_link _('Configure in settings')
expect(page).not_to have_link(_('Configure in settings'), href: project_settings_packages_and_registries_path(project))
end
it 'has link to settings when user is maintainer' do
@ -69,7 +69,7 @@ RSpec.describe 'Container Registry', :js, feature_category: :container_registry
visit_container_registry
expect(page).to have_link _('Configure in settings')
expect(page).to have_link(_('Configure in settings'), href: project_settings_packages_and_registries_path(project))
end
context 'when there are no image repositories' do

View File

@ -43,7 +43,7 @@ RSpec.describe 'User edits Release', :js, feature_category: :continuous_delivery
{ text: project.name, href: project_path(project) },
{ text: 'Releases', href: project_releases_path(project) },
{ text: release.name, href: project_release_path(project, release) },
{ text: 'Edit Release', href: edit_project_release_path(project, release) }
{ text: 'Edit', href: edit_project_release_path(project, release) }
)
end
end

View File

@ -1,4 +1,5 @@
import { GlTableLite, GlSkeletonLoader } from '@gitlab/ui';
// fixture located in spec/frontend/fixtures/pipelines.rb
import fixture from 'test_fixtures/pipelines/pipelines.json';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
@ -29,6 +30,7 @@ describe('Pipelines Table', () => {
const provideWithFailedJobsWidget = {
useFailedJobsWidget: true,
graphqlPath: 'api/graphql',
};
const { pipelines } = fixture;
@ -198,7 +200,6 @@ describe('Pipelines Table', () => {
it('passes the expected props', () => {
expect(findPipelineFailureWidget().props()).toStrictEqual({
failedJobsCount: firstPipeline.failed_builds_count,
isPipelineActive: firstPipeline.active,
pipelineIid: firstPipeline.iid,
pipelinePath: firstPipeline.path,

View File

@ -0,0 +1,86 @@
import { GlButton, GlCard } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import CiCards from '~/ci/pipelines_page/components/empty_state/ci_cards.vue';
describe('CI Cards', () => {
let wrapper;
let trackingSpy;
let firstCardData;
const createComponent = ({ showJenkinsCiPrompt = true } = {}) => {
wrapper = shallowMountExtended(CiCards, {
provide: {
pipelineEditorPath: '/-/ci/editor',
showJenkinsCiPrompt,
},
stubs: {
GlEmoji: { template: '<div/>' },
},
});
};
const findAllCards = () => wrapper.findAllComponents(GlCard);
const findCardButton = () => wrapper.findComponent(GlButton);
const findDescription = () => wrapper.findByTestId('ci-card-description');
const findEmoji = () => wrapper.findByTestId('ci-card-emoji');
const findTitle = () => wrapper.findByTestId('ci-card-title');
describe('structure', () => {
beforeEach(() => {
createComponent();
[firstCardData] = wrapper.vm.cards;
});
it('renders emoji', () => {
expect(findEmoji().exists()).toBe(true);
expect(findEmoji().attributes('data-name')).toBe(firstCardData.emoji);
});
it('renders title', () => {
expect(findTitle().exists()).toBe(true);
expect(findTitle().text()).toBe(firstCardData.title);
});
it('renders description', () => {
expect(findDescription().exists()).toBe(true);
expect(findDescription().text()).toBe(firstCardData.description);
});
it('renders button', () => {
expect(findCardButton().exists()).toBe(true);
expect(findCardButton().text()).toBe(firstCardData.buttonText);
});
});
describe('visibility', () => {
it('renders all cards if `showJenkinsCiPrompt` is true', () => {
createComponent();
expect(findAllCards()).toHaveLength(wrapper.vm.cards.length);
});
it('hides jenkins prompt if `showJenkinsCiPrompt` is false', () => {
createComponent({ showJenkinsCiPrompt: false });
expect(findAllCards()).toHaveLength(wrapper.vm.cards.length - 1);
});
});
describe('tracking', () => {
beforeEach(() => {
createComponent();
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
afterEach(() => {
unmockTracking();
});
it('sends an event when template is clicked', () => {
findCardButton().vm.$emit('click');
expect(trackingSpy).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -1,108 +1,46 @@
import '~/commons';
import { GlButton } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue';
import CiCards from '~/ci/pipelines_page/components/empty_state/ci_cards.vue';
import CiTemplates from '~/ci/pipelines_page/components/empty_state/ci_templates.vue';
const pipelineEditorPath = '/-/ci/editor';
describe('Pipelines CI Templates', () => {
let wrapper;
let trackingSpy;
const createWrapper = (propsData = {}, stubs = {}) => {
return shallowMountExtended(PipelinesCiTemplates, {
provide: {
pipelineEditorPath,
showJenkinsCiPrompt: false,
...propsData,
},
components: {
GlEmoji: { template: '<img/>' },
},
stubs,
});
const createWrapper = () => {
return shallowMountExtended(PipelinesCiTemplates, {});
};
const findMigrateFromJenkinsPrompt = () => wrapper.findByTestId('migrate-from-jenkins-prompt');
const findMigrationPlanBtn = () => findMigrateFromJenkinsPrompt().findComponent(GlButton);
const findTestTemplateLink = () => wrapper.findByTestId('test-template-link');
const findCiCards = () => wrapper.findComponent(CiCards);
const findCiTemplates = () => wrapper.findComponent(CiTemplates);
const findPageTitle = () => wrapper.findByText('Get started with GitLab CI/CD');
describe('on mount', () => {
beforeEach(() => {
wrapper = createWrapper();
});
it('renders title', () => {
expect(findPageTitle().exists()).toBe(true);
});
});
describe('cards', () => {
beforeEach(() => {
wrapper = createWrapper();
});
it('renders CiCards component', () => {
expect(findCiCards().exists()).toBe(true);
});
});
describe('templates', () => {
beforeEach(() => {
wrapper = createWrapper();
});
it('renders test template and Ci templates', () => {
expect(findTestTemplateLink().attributes('href')).toBe(
pipelineEditorPath.concat('?template=Getting-Started'),
);
it('renders CiTemplates component', () => {
expect(findCiTemplates().exists()).toBe(true);
});
it('does not show migrate from jenkins prompt', () => {
expect(findMigrateFromJenkinsPrompt().exists()).toBe(false);
});
describe('when Jenkinsfile is detected', () => {
beforeEach(() => {
wrapper = createWrapper({ showJenkinsCiPrompt: true });
});
it('shows migrate from jenkins prompt', () => {
expect(findMigrateFromJenkinsPrompt().exists()).toBe(true);
});
it('opens correct link in new tab after clicking migration plan CTA', () => {
expect(findMigrationPlanBtn().attributes('href')).toBe(
'/help/ci/migration/plan_a_migration',
);
expect(findMigrationPlanBtn().attributes('target')).toBe('_blank');
});
});
});
describe('tracking', () => {
beforeEach(() => {
wrapper = createWrapper();
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
afterEach(() => {
unmockTracking();
});
it('sends an event when Getting-Started template is clicked', () => {
findTestTemplateLink().vm.$emit('click');
expect(trackingSpy).toHaveBeenCalledTimes(1);
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', {
label: 'Getting-Started',
});
});
describe('when Jenkinsfile detected', () => {
beforeEach(() => {
wrapper = createWrapper({ showJenkinsCiPrompt: true });
});
it('creates render event on page load', () => {
expect(trackingSpy).toHaveBeenCalledTimes(1);
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'render', {
label: 'migrate_from_jenkins_prompt',
});
});
it('sends an event when migration plan is clicked', () => {
findMigrationPlanBtn().vm.$emit('click');
expect(trackingSpy).toHaveBeenCalledTimes(2);
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', {
label: 'migrate_from_jenkins_prompt',
});
});
});
});
});

View File

@ -10,7 +10,7 @@ import FailedJobsList from '~/ci/pipelines_page/components/failure_widget/failed
import FailedJobDetails from '~/ci/pipelines_page/components/failure_widget/failed_job_details.vue';
import * as utils from '~/ci/pipelines_page/components/failure_widget/utils';
import getPipelineFailedJobs from '~/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql';
import { failedJobsMock, failedJobsMock2, failedJobsMockEmpty, activeFailedJobsMock } from './mock';
import { failedJobsMock, failedJobsMock2 } from './mock';
Vue.use(VueApollo);
Vue.use(GlToast);
@ -23,10 +23,8 @@ describe('FailedJobsList component', () => {
const showToast = jest.fn();
const defaultProps = {
failedJobsCount: 0,
graphqlResourceEtag: 'api/graphql',
isMaximumJobLimitReached: false,
isPipelineActive: false,
pipelineIid: 1,
pipelinePath: 'namespace/project/pipeline',
projectPath: 'namespace/project/',
@ -103,7 +101,7 @@ describe('FailedJobsList component', () => {
await waitForPromises();
});
it('does not renders a loading icon', () => {
it('does not render a loading icon', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
@ -136,88 +134,23 @@ describe('FailedJobsList component', () => {
});
describe('polling', () => {
it.each`
isGraphqlActive | text
${true} | ${'polls'}
${false} | ${'does not poll'}
`(`$text when isGraphqlActive: $isGraphqlActive`, async ({ isGraphqlActive }) => {
const defaultCount = 2;
const newCount = 1;
const expectedCount = isGraphqlActive ? newCount : defaultCount;
const expectedCallCount = isGraphqlActive ? 2 : 1;
const mockResponse = isGraphqlActive ? activeFailedJobsMock : failedJobsMock;
// Second result is to simulate polling with a different response
mockFailedJobsResponse.mockResolvedValueOnce(mockResponse);
it('polls for failed jobs', async () => {
mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock);
mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2);
createComponent();
await waitForPromises();
// Initially, we get the first response which is always the default
expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1);
expect(findFailedJobRows()).toHaveLength(defaultCount);
expect(findFailedJobRows()).toHaveLength(2);
jest.advanceTimersByTime(10000);
await waitForPromises();
expect(mockFailedJobsResponse).toHaveBeenCalledTimes(expectedCallCount);
expect(findFailedJobRows()).toHaveLength(expectedCount);
});
});
describe('when a REST action occurs', () => {
beforeEach(() => {
// Second result is to simulate polling with a different response
mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock);
mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2);
});
it.each([true, false])('triggers a refetch of the jobs count', async (isPipelineActive) => {
const defaultCount = 2;
const newCount = 1;
createComponent({ props: { isPipelineActive } });
await waitForPromises();
// Initially, we get the first response which is always the default
expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1);
expect(findFailedJobRows()).toHaveLength(defaultCount);
wrapper.setProps({ isPipelineActive: !isPipelineActive });
await waitForPromises();
expect(mockFailedJobsResponse).toHaveBeenCalledTimes(2);
expect(findFailedJobRows()).toHaveLength(newCount);
});
});
describe('When the job count changes from REST', () => {
beforeEach(() => {
mockFailedJobsResponse.mockResolvedValue(failedJobsMockEmpty);
createComponent();
});
describe('and the count is the same', () => {
it('does not re-fetch the query', async () => {
expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1);
await wrapper.setProps({ failedJobsCount: 0 });
expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1);
});
});
describe('and the count is different', () => {
it('re-fetches the query', async () => {
expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1);
await wrapper.setProps({ failedJobsCount: 10 });
expect(mockFailedJobsResponse).toHaveBeenCalledTimes(2);
});
expect(findFailedJobRows()).toHaveLength(1);
});
});
@ -250,7 +183,7 @@ describe('FailedJobsList component', () => {
await waitForPromises();
});
it('refetches all failed jobs', async () => {
it('refetches all failed jobs and emits event', async () => {
expect(findFailedJobRows()).not.toHaveLength(
failedJobsMock2.data.project.pipeline.jobs.nodes.length,
);
@ -261,6 +194,7 @@ describe('FailedJobsList component', () => {
expect(findFailedJobRows()).toHaveLength(
failedJobsMock2.data.project.pipeline.jobs.nodes.length,
);
expect(wrapper.emitted()).toEqual({ 'job-retried': [[]] });
});
it('shows a toast message', async () => {

View File

@ -32,14 +32,13 @@ export const allowedToFailJob = {
allowFailure: true,
};
export const createFailedJobsMockCount = ({ count = 4, active = false } = {}) => {
export const createFailedJobsMockCount = (count = 4) => {
return {
data: {
project: {
id: 'gid://gitlab/Project/20',
pipeline: {
id: 'gid://gitlab/Pipeline/20',
active,
jobs: {
count,
},
@ -74,3 +73,5 @@ export const failedJobsMockEmpty = createFailedJobsMock([]);
export const activeFailedJobsMock = createFailedJobsMock([allowedToFailJob, job], true);
export const failedJobsMock2 = createFailedJobsMock([job]);
export const failedJobsCountMock = createFailedJobsMockCount();

View File

@ -1,16 +1,22 @@
import { GlBadge, GlButton } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import CrudComponent from '~/vue_shared/components/crud_component.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import PipelineFailedJobsWidget from '~/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue';
import FailedJobsList from '~/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue';
import getPipelineFailedJobsCount from '~/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs_count.query.graphql';
import { failedJobsCountMock } from './mock';
Vue.use(VueApollo);
jest.mock('~/alert');
describe('PipelineFailedJobsWidget component', () => {
let wrapper;
const defaultProps = {
failedJobsCount: 4,
isPipelineActive: false,
pipelineIid: 1,
pipelinePath: '/pipelines/1',
@ -19,9 +25,18 @@ describe('PipelineFailedJobsWidget component', () => {
const defaultProvide = {
fullPath: 'namespace/project/',
graphqlPath: 'api/graphql',
};
const createComponent = ({ props = {}, provide = {} } = {}) => {
const defaultHandler = jest.fn().mockResolvedValue(failedJobsCountMock);
const createMockApolloProvider = (handler) => {
const requestHandlers = [[getPipelineFailedJobsCount, handler]];
return createMockApollo(requestHandlers);
};
const createComponent = ({ props = {}, provide = {}, handler = defaultHandler } = {}) => {
wrapper = shallowMountExtended(PipelineFailedJobsWidget, {
propsData: {
...defaultProps,
@ -32,22 +47,25 @@ describe('PipelineFailedJobsWidget component', () => {
...provide,
},
stubs: { CrudComponent },
apolloProvider: createMockApolloProvider(handler),
});
};
const findFailedJobsButton = () => wrapper.findComponent(GlButton);
const findFailedJobsList = () => wrapper.findAllComponents(FailedJobsList);
const findFailedJobsList = () => wrapper.findComponent(FailedJobsList);
const findCrudComponent = () => wrapper.findComponent(CrudComponent);
const findCount = () => wrapper.findComponent(GlBadge);
describe('when there are failed jobs', () => {
beforeEach(() => {
beforeEach(async () => {
createComponent();
await waitForPromises();
});
it('renders the show failed jobs button with correct count', () => {
expect(findFailedJobsButton().exists()).toBe(true);
expect(findCount().text()).toBe(String(defaultProps.failedJobsCount));
expect(findCount().text()).toBe('4');
});
it('does not render the failed jobs widget', () => {
@ -60,6 +78,7 @@ describe('PipelineFailedJobsWidget component', () => {
describe('when the job button is clicked', () => {
beforeEach(async () => {
createComponent();
await findFailedJobsButton().vm.$emit('click');
});
@ -90,40 +109,6 @@ describe('PipelineFailedJobsWidget component', () => {
});
});
describe('when the job count changes', () => {
beforeEach(() => {
createComponent();
});
describe('from the prop', () => {
it('updates the job count', async () => {
const newJobCount = 12;
expect(findCount().text()).toBe(String(defaultProps.failedJobsCount));
await wrapper.setProps({ failedJobsCount: newJobCount });
expect(findCount().text()).toBe(String(newJobCount));
});
});
describe('from the event', () => {
beforeEach(async () => {
await findFailedJobsButton().vm.$emit('click');
});
it('updates the job count', async () => {
const newJobCount = 12;
expect(findCount().text()).toBe(String(defaultProps.failedJobsCount));
await findFailedJobsList().at(0).vm.$emit('failed-jobs-count', newJobCount);
expect(findCount().text()).toBe(String(newJobCount));
});
});
});
describe('"aria-controls" attribute', () => {
it('is set and identifies the correct element', () => {
createComponent();
@ -134,4 +119,50 @@ describe('PipelineFailedJobsWidget component', () => {
expect(findCrudComponent().attributes('id')).toBe('pipeline-failed-jobs-widget');
});
});
describe('polling', () => {
it('does not poll for failed jobs count when pipeline is inactive', async () => {
createComponent();
await waitForPromises();
expect(defaultHandler).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(10000);
await waitForPromises();
expect(defaultHandler).toHaveBeenCalledTimes(1);
});
it('polls for failed jobs count when pipeline is active', async () => {
createComponent({ props: { isPipelineActive: true } });
await waitForPromises();
expect(defaultHandler).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(10000);
await waitForPromises();
expect(defaultHandler).toHaveBeenCalledTimes(2);
});
});
describe('job retry', () => {
it('refetches failed jobs count', async () => {
createComponent();
await waitForPromises();
expect(defaultHandler).toHaveBeenCalledTimes(1);
await findFailedJobsButton().vm.$emit('click');
findFailedJobsList().vm.$emit('job-retried');
expect(defaultHandler).toHaveBeenCalledTimes(2);
});
});
});

View File

@ -167,7 +167,7 @@ describe('List Page', () => {
beforeEach(() => {
const config = {
showContainerRegistrySettings: true,
cleanupPoliciesSettingsPath: 'bar',
settingsPath: 'bar',
};
mountComponent({ config });
});

View File

@ -37,9 +37,10 @@ RSpec.describe ContainerRegistry::ContainerRegistryHelper, feature_category: :co
gid_prefix: helper.container_repository_gid_prefix,
is_admin: user.admin.to_s,
show_cleanup_policy_link: helper.show_cleanup_policy_link(project).to_s,
show_container_registry_settings: helper.show_container_registry_settings(project).to_s,
cleanup_policies_settings_path:
helper.cleanup_image_tags_project_settings_packages_and_registries_path(project),
show_container_registry_settings: helper.show_container_registry_settings(project).to_s,
settings_path: helper.project_settings_packages_and_registries_path(project),
connection_error: (!!connection_error).to_s,
invalid_path_error: (!!invalid_path_error).to_s,
user_callouts_path: callouts_path,

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::DeployTokens::RevokeService, feature_category: :deployment_management do
let_it_be(:entity) { create(:project) }
let_it_be(:deploy_token) { create(:deploy_token, projects: [entity]) }
let_it_be(:deploy_token_params) { { id: deploy_token.id } }
describe '#execute' do
subject(:revoke_service) do
described_class.new(project: entity, current_user: user, params: deploy_token_params).execute
end
context 'as admin' do
let(:user) { create(:admin) }
context 'when admin mode enabled', :enable_admin_mode do
it 'revokes a project deploy token' do
expect(deploy_token.revoked).to be_falsey
expect { revoke_service }.to change { deploy_token.reload.revoked }.to be_truthy
end
context 'when the token id is invalid' do
let(:deploy_token_params) { { token_id: non_existing_record_id } }
it 'returns an error' do
expect(revoke_service.status).to eq(:error)
end
end
end
context 'when admin mode disabled' do
it 'returns an error' do
expect(revoke_service.status).to eq(:error)
end
it 'does not revoke the token' do
revoke_service
expect(deploy_token.reload.revoked).to be_falsey
end
end
end
context 'as a user' do
let(:user) { create(:user) }
it 'does not revoke a project deploy token' do
expect(revoke_service.status).to eq(:error)
end
it 'does not revoke the token' do
revoke_service
expect(deploy_token.reload.revoked).to be_falsey
end
end
end
end

View File

@ -136,15 +136,24 @@ RSpec.describe WorkItems::DataSync::CloneService, feature_category: :team_planni
end
end
context 'when cloning work item with success' do
context 'when cloning work item with success', :freeze_time do
let(:expected_original_work_item_state) { Issue.available_states[:opened] }
let!(:original_work_item_attrs) do
{
iid: original_work_item.iid,
project: target_namespace.try(:project),
namespace: target_namespace,
work_item_type: original_work_item.work_item_type,
author: current_user,
title: original_work_item.title,
description: original_work_item.description,
author: current_user,
work_item_type: original_work_item.work_item_type,
state_id: Issue.available_states[:opened],
created_at: Time.current,
updated_at: Time.current,
confidential: original_work_item.confidential,
cached_markdown_version: original_work_item.cached_markdown_version,
lock_version: original_work_item.lock_version,
imported_from: "none",
updated_by: current_user,
last_edited_at: nil,
last_edited_by: nil,
@ -156,8 +165,7 @@ RSpec.describe WorkItems::DataSync::CloneService, feature_category: :team_planni
external_key: nil,
upvotes_count: 0,
blocking_issues_count: 0,
project: target_namespace.try(:project),
namespace: target_namespace
service_desk_reply_to: target_namespace.service_desk_alias_address
}
end

View File

@ -136,18 +136,25 @@ RSpec.describe WorkItems::DataSync::MoveService, feature_category: :team_plannin
end
end
context 'when moving work item with success' do
context 'when moving work item with success', :freeze_time do
let(:expected_original_work_item_state) { Issue.available_states[:closed] }
let!(:original_work_item_attrs) do
{
iid: original_work_item.iid,
project: target_namespace.try(:project),
namespace: target_namespace,
work_item_type: original_work_item.work_item_type,
author: original_work_item.author,
title: original_work_item.title,
description: original_work_item.description,
author: original_work_item.author,
work_item_type: original_work_item.work_item_type,
state_id: original_work_item.state_id,
created_at: original_work_item.reload.created_at,
updated_by: original_work_item.updated_by,
updated_at: original_work_item.reload.updated_at,
confidential: original_work_item.confidential,
cached_markdown_version: original_work_item.cached_markdown_version,
lock_version: original_work_item.lock_version,
imported_from: "none",
last_edited_at: original_work_item.last_edited_at,
last_edited_by: original_work_item.last_edited_by,
closed_at: original_work_item.closed_at,
@ -158,8 +165,7 @@ RSpec.describe WorkItems::DataSync::MoveService, feature_category: :team_plannin
external_key: original_work_item.external_key,
upvotes_count: original_work_item.upvotes_count,
blocking_issues_count: original_work_item.blocking_issues_count,
project: target_namespace.try(:project),
namespace: target_namespace
service_desk_reply_to: target_namespace.service_desk_alias_address
}
end

View File

@ -35,15 +35,15 @@ RSpec.describe MergeRequests::ProcessScheduledMergeWorker, :sidekiq_inline, feat
end
end
it 'schedules AutoMergeProcessWorker for each batch with increasing delay' do
it 'schedules AutoMergeProcessWorker for each batch with increasing delay', :aggregate_failures do
expect(AutoMergeProcessWorker)
.to receive(:bulk_perform_in)
.with(1.second, [[scheduled_mr.id]])
.with(1.second, [[{ 'merge_request_id' => scheduled_mr.id }]])
.and_call_original
expect(AutoMergeProcessWorker)
.to receive(:bulk_perform_in)
.with(7.seconds, [[other_scheduled_mr.id]])
.with(7.seconds, [[{ 'merge_request_id' => other_scheduled_mr.id }]])
.and_call_original
perform

View File

@ -1407,10 +1407,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.120.0.tgz#0f26908d4ded2e3f63b6e5028afbe8b59b7fe6dc"
integrity sha512-lsMKCgg0o3FLbNzvNPahkDtKGOH4U99bHff2aGlyqYFIpPvGFes/DfGr0IXVtWdfavZyoT42zE0/rquG64bANw==
"@gitlab/ui@101.6.1":
version "101.6.1"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-101.6.1.tgz#cf5046f3460fa3d181d6cff5bbb4bb036b95a489"
integrity sha512-2FkvNWdt4a/KrRkMoFGMiKDVHkk6xRUx7ZFJJb3NTvZpLsn/kpbRAd+7vddwcywDpUkxSiD3TJJecwfVqrGDGQ==
"@gitlab/ui@101.6.2":
version "101.6.2"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-101.6.2.tgz#59de2626304814bd841257a3313a5ed6ee24ce73"
integrity sha512-ecEW9weQcsZH+nDcewoJF0PK7EOR/Eju7xhHXh6v8gZqBwIQH9F+AW2ye6qTJkkLXN0E4kLJ57/D2anc6gpStg==
dependencies:
"@floating-ui/dom" "1.4.3"
echarts "^5.3.2"