Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-06-15 18:08:44 +00:00
parent 9440c17f55
commit 36b47b4bd3
71 changed files with 1361 additions and 266 deletions

View File

@ -82,6 +82,9 @@
.if-merge-request-labels-group-global-search: &if-merge-request-labels-group-global-search
if: '$CI_MERGE_REQUEST_LABELS =~ /group::global search/'
.if-merge-request-labels-pipeline-revert: &if-merge-request-labels-pipeline-revert
if: '$CI_MERGE_REQUEST_LABELS =~ /pipeline:revert/'
.if-security-merge-request: &if-security-merge-request
if: '$CI_PROJECT_NAMESPACE == "gitlab-org/security" && $CI_MERGE_REQUEST_IID'
@ -916,6 +919,8 @@
rules:
- <<: *if-not-ee
when: never
- <<: *if-merge-request-labels-pipeline-revert
when: never
- <<: *if-merge-request-targeting-stable-branch
allow_failure: true
- <<: *if-dot-com-gitlab-org-and-security-merge-request
@ -941,6 +946,8 @@
rules:
- <<: *if-not-ee
when: never
- <<: *if-merge-request-labels-pipeline-revert
when: never
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-qa
changes: *feature-flag-development-config-patterns
when: manual
@ -1368,6 +1375,8 @@
rules:
- <<: *if-not-ee
when: never
- <<: *if-merge-request-labels-pipeline-revert
when: never
- <<: *if-merge-request-labels-skip-undercoverage
when: never
- <<: *if-merge-request-labels-run-all-rspec
@ -1572,6 +1581,8 @@
rules:
- <<: *if-not-ee
when: never
- <<: *if-merge-request-labels-pipeline-revert
when: never
- <<: *if-merge-request-labels-run-review-app
- <<: *if-dot-com-gitlab-org-merge-request
changes: *ci-review-patterns

View File

@ -1 +1 @@
13086e25394b65e4c17eca8484890f62bb2f0b92
5caf724a8305ea04370dc49f0d9a7d5f3bc8dd4a

View File

@ -7,8 +7,9 @@ const extractFootnoteIdentifier = (idAttribute) => /^fn-(\w+)-\d+$/.exec(idAttri
export default Node.create({
name: 'footnoteDefinition',
content: 'inline*',
content: 'paragraph',
group: 'block',
isolating: true,
addAttributes() {
return {
identifier: {

View File

@ -4,6 +4,8 @@ import Bold from './bold';
import BulletList from './bullet_list';
import Code from './code';
import CodeBlockHighlight from './code_block_highlight';
import FootnoteReference from './footnote_reference';
import FootnoteDefinition from './footnote_definition';
import Heading from './heading';
import HardBreak from './hard_break';
import HorizontalRule from './horizontal_rule';
@ -31,6 +33,8 @@ export default Extension.create({
BulletList.name,
Code.name,
CodeBlockHighlight.name,
FootnoteReference.name,
FootnoteDefinition.name,
HardBreak.name,
Heading.name,
HorizontalRule.name,

View File

@ -113,6 +113,11 @@ const factorySpecs = {
type: 'ignore',
selector: (hastNode) => ['thead', 'tbody', 'tfoot'].includes(hastNode.tagName),
},
footnoteDefinition: {
type: 'block',
selector: 'footnotedefinition',
getAttrs: (hastNode) => hastNode.properties,
},
image: {
type: 'inline',
selector: 'img',
@ -126,6 +131,11 @@ const factorySpecs = {
type: 'inline',
selector: 'br',
},
footnoteReference: {
type: 'inline',
selector: 'footnotereference',
getAttrs: (hastNode) => hastNode.properties,
},
code: {
type: 'mark',
selector: 'code',

View File

@ -1,14 +1,32 @@
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import remarkRehype, { all } from 'remark-rehype';
import rehypeRaw from 'rehype-raw';
const createParser = () => {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(remarkRehype, {
allowDangerousHtml: true,
handlers: {
footnoteReference: (h, node) =>
h(
node.position,
'footnoteReference',
{ identifier: node.identifier, label: node.label },
[],
),
footnoteDefinition: (h, node) =>
h(
node.position,
'footnoteDefinition',
{ identifier: node.identifier, label: node.label },
all(h, node),
),
},
})
.use(rehypeRaw);
};

View File

@ -15,13 +15,15 @@ import {
MERGED_TAB,
TAB_QUERY_PARAM,
TABS_INDEX,
VALIDATE_TAB,
VISUALIZE_TAB,
} from '../constants';
import getAppStatus from '../graphql/queries/client/app_status.query.graphql';
import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue';
import CiEditorHeader from './editor/ci_editor_header.vue';
import TextEditor from './editor/text_editor.vue';
import CiLint from './lint/ci_lint.vue';
import CiValidate from './validate/ci_validate.vue';
import TextEditor from './editor/text_editor.vue';
import EditorTab from './ui/editor_tab.vue';
import WalkthroughPopover from './popovers/walkthrough_popover.vue';
@ -31,6 +33,7 @@ export default {
tabGraph: s__('Pipelines|Visualize'),
tabLint: s__('Pipelines|Lint'),
tabMergedYaml: s__('Pipelines|View merged YAML'),
tabValidate: s__('Pipelines|Validate'),
empty: {
visualization: s__(
'PipelineEditor|The pipeline visualization is displayed when the CI/CD configuration file has valid syntax.',
@ -53,12 +56,14 @@ export default {
CREATE_TAB,
LINT_TAB,
MERGED_TAB,
VALIDATE_TAB,
VISUALIZE_TAB,
},
components: {
CiConfigMergedPreview,
CiEditorHeader,
CiLint,
CiValidate,
EditorTab,
GlAlert,
GlLoadingIcon,
@ -181,6 +186,17 @@ export default {
<pipeline-graph v-else :pipeline-data="ciConfigData" />
</editor-tab>
<editor-tab
v-if="glFeatures.simulatePipeline"
class="gl-mb-3"
data-testid="validate-tab"
:title="$options.i18n.tabValidate"
@click="setCurrentTab($options.tabConstants.VALIDATE_TAB)"
>
<gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
<ci-validate v-else />
</editor-tab>
<editor-tab
v-else
class="gl-mb-3"
:empty-message="$options.i18n.empty.lint"
:is-empty="isEmpty"

View File

@ -0,0 +1,65 @@
<script>
import { GlButton, GlDropdown, GlTooltipDirective, GlSprintf } from '@gitlab/ui';
import { s__, __ } from '~/locale';
export const i18n = {
help: __('Help'),
pipelineSource: s__('PipelineEditor|Pipeline Source'),
pipelineSourceDefault: s__('PipelineEditor|Git push event to the default branch'),
pipelineSourceTooltip: s__('PipelineEditor|Other pipeline sources are not available yet.'),
title: s__('PipelineEditor|Validate pipeline under selected conditions'),
contentNote: s__(
'PipelineEditor|Current content in the Edit tab will be used for the simulation.',
),
simulationNote: s__(
'PipelineEditor|Pipeline behavior will be simulated including the %{codeStart}rules%{codeEnd} %{codeStart}only%{codeEnd} %{codeStart}except%{codeEnd} and %{codeStart}needs%{codeEnd} job dependencies.',
),
cta: s__('PipelineEditor|Validate pipeline'),
};
export default {
name: 'CiValidateTab',
components: {
GlButton,
GlDropdown,
GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: ['validateTabIllustrationPath'],
i18n,
};
</script>
<template>
<div>
<div class="gl-mt-3">
<label>{{ $options.i18n.pipelineSource }}</label>
<gl-dropdown
v-gl-tooltip.hover
:title="$options.i18n.pipelineSourceTooltip"
:text="$options.i18n.pipelineSourceDefault"
disabled
data-testid="pipeline-source"
/>
</div>
<div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11">
<img :src="validateTabIllustrationPath" />
<h1 class="gl-font-size-h1 gl-mb-6">{{ $options.i18n.title }}</h1>
<ul>
<li class="gl-mb-3">{{ $options.i18n.contentNote }}</li>
<li class="gl-mb-3">
<gl-sprintf :message="$options.i18n.simulationNote">
<template #code="{ content }">
<code>{{ content }}</code>
</template>
</gl-sprintf>
</li>
</ul>
<gl-button variant="confirm" class="gl-mt-3" data-qa-selector="simulate_pipeline">
{{ $options.i18n.cta }}
</gl-button>
</div>
</div>
</template>

View File

@ -32,13 +32,15 @@ export const PIPELINE_FAILURE = 'PIPELINE_FAILURE';
export const CREATE_TAB = 'CREATE_TAB';
export const LINT_TAB = 'LINT_TAB';
export const MERGED_TAB = 'MERGED_TAB';
export const VALIDATE_TAB = 'VALIDATE_TAB';
export const VISUALIZE_TAB = 'VISUALIZE_TAB';
export const TABS_INDEX = {
[CREATE_TAB]: '0',
[VISUALIZE_TAB]: '1',
[LINT_TAB]: '2',
[MERGED_TAB]: '3',
[VALIDATE_TAB]: '3',
[MERGED_TAB]: '4',
};
export const TAB_QUERY_PARAM = 'tab';

View File

@ -41,6 +41,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectNamespace,
runnerHelpPagePath,
totalBranches,
validateTabIllustrationPath,
ymlHelpPagePath,
} = el.dataset;
@ -130,6 +131,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectNamespace,
runnerHelpPagePath,
totalBranches: parseInt(totalBranches, 10),
validateTabIllustrationPath,
ymlHelpPagePath,
},
render(h) {

View File

@ -13,7 +13,6 @@ import { __, sprintf } from '~/locale';
import CancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql';
import RetryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { PIPELINE_GRAPHQL_TYPE } from '../../constants';
import { reportToSentry } from '../../utils';
import { ACTION_FAILURE, DOWNSTREAM, UPSTREAM } from './constants';
@ -35,7 +34,6 @@ export default {
flatLeftBorder: ['gl-rounded-bottom-left-none!', 'gl-rounded-top-left-none!'],
flatRightBorder: ['gl-rounded-bottom-right-none!', 'gl-rounded-top-right-none!'],
},
mixins: [glFeatureFlagMixin()],
props: {
columnTitle: {
type: String,
@ -67,7 +65,7 @@ export default {
},
computed: {
action() {
if (this.glFeatures?.downstreamRetryAction && this.isDownstream) {
if (this.isDownstream) {
if (this.isCancelable) {
return {
icon: 'cancel',

View File

@ -0,0 +1,110 @@
<script>
import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { __, sprintf } from '~/locale';
import branchesQuery from '../queries/branches.query.graphql';
export const i18n = {
fetchBranchesError: __('An error occurred while fetching branches.'),
noMatch: __('No matching results'),
};
export default {
i18n,
name: 'BranchDropdown',
components: {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlSearchBoxByType,
},
apollo: {
branchNames: {
query: branchesQuery,
variables() {
return {
projectPath: this.projectPath,
searchPattern: `*${this.searchTerm}*`,
};
},
update({ project: { repository = {} } } = {}) {
return repository.branchNames || [];
},
error(e) {
createAlert({
message: this.$options.i18n.fetchBranchesError,
captureError: true,
error: e,
});
},
},
},
props: {
projectPath: {
type: String,
required: true,
},
value: {
type: String,
required: false,
default: null,
},
},
data() {
return {
searchTerm: '',
branchNames: [],
};
},
computed: {
createButtonLabel() {
return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm });
},
shouldRenderCreateButton() {
return this.searchTerm && !this.branchNames.includes(this.searchTerm);
},
isLoading() {
return this.$apollo.queries.branchNames.loading;
},
},
methods: {
selectBranch(selected) {
this.$emit('input', selected);
},
createWildcard() {
this.$emit('createWildcard', this.searchTerm);
},
isSelected(branch) {
return this.value === branch;
},
},
};
</script>
<template>
<gl-dropdown :text="value || branchNames[0]">
<gl-search-box-by-type
v-model.trim="searchTerm"
data-testid="branch-search"
debounce="250"
:is-loading="isLoading"
/>
<gl-dropdown-item
v-for="branch in branchNames"
:key="branch"
:is-checked="isSelected(branch)"
is-check-item
@click="selectBranch(branch)"
>
{{ branch }}
</gl-dropdown-item>
<gl-dropdown-item v-if="!branchNames.length && !isLoading" data-testid="no-data">{{
$options.i18n.noMatch
}}</gl-dropdown-item>
<template v-if="shouldRenderCreateButton">
<gl-dropdown-divider />
<gl-dropdown-item data-testid="create-wildcard-button" @click="createWildcard">
{{ createButtonLabel }}
</gl-dropdown-item>
</template>
</gl-dropdown>
</template>

View File

@ -1,11 +1,38 @@
<script>
import { GlFormGroup } from '@gitlab/ui';
import { __ } from '~/locale';
import { getParameterByName } from '~/lib/utils/url_utility';
import BranchDropdown from './branch_dropdown.vue';
export default {
name: 'RuleEdit',
i18n: {
branch: __('Branch'),
},
components: { BranchDropdown, GlFormGroup },
props: {
projectPath: {
type: String,
required: true,
},
},
data() {
return {
branch: getParameterByName('branch'),
};
},
};
</script>
<template>
<div>
<!-- TODO - Add branch protections (https://gitlab.com/gitlab-org/gitlab/-/issues/362212) -->
</div>
<gl-form-group :label="$options.i18n.branch">
<branch-dropdown
id="branches"
v-model="branch"
class="gl-w-half"
:project-path="projectPath"
@createWildcard="branch = $event"
/>
</gl-form-group>
<!-- TODO - Add branch protections (https://gitlab.com/gitlab-org/gitlab/-/issues/362212) -->
</template>

View File

@ -1,4 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import RuleEdit from './components/rule_edit.vue';
export default function mountBranchRules(el) {
@ -6,10 +8,19 @@ export default function mountBranchRules(el) {
return null;
}
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
const { projectPath } = el.dataset;
return new Vue({
el,
apolloProvider,
render(h) {
return h(RuleEdit);
return h(RuleEdit, { props: { projectPath } });
},
});
}

View File

@ -0,0 +1,8 @@
query getBranches($projectPath: ID!, $searchPattern: String!) {
project(fullPath: $projectPath) {
id
repository {
branchNames(searchPattern: $searchPattern, limit: 100, offset: 0)
}
}
}

View File

@ -4,6 +4,7 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
before_action :check_can_collaborate!
before_action do
push_frontend_feature_flag(:schema_linting, @project)
push_frontend_feature_flag(:simulate_pipeline, @project)
end
feature_category :pipeline_authoring

View File

@ -25,7 +25,6 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:pipeline_tabs_vue, @project)
push_frontend_feature_flag(:downstream_retry_action, @project)
end
# Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
module Mutations
module WorkItems
class UpdateWidgets < BaseMutation
graphql_name 'WorkItemUpdateWidgets'
description "Updates the attributes of a work item's widgets by global ID." \
" Available only when feature flag `work_items` is enabled."
include Mutations::SpamProtection
authorize :update_work_item
argument :id, ::Types::GlobalIDType[::WorkItem],
required: true,
description: 'Global ID of the work item.'
argument :description_widget, ::Types::WorkItems::Widgets::DescriptionInputType,
required: false,
description: 'Input for description widget.'
field :work_item, Types::WorkItemType,
null: true,
description: 'Updated work item.'
def resolve(id:, **widget_attributes)
work_item = authorized_find!(id: id)
unless work_item.project.work_items_feature_flag_enabled?
return { errors: ['`work_items` feature flag disabled for this project'] }
end
spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
::WorkItems::UpdateService.new(
project: work_item.project,
current_user: current_user,
# Cannot use prepare to use `.to_h` on each input due to
# https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87472#note_945199865
widget_params: widget_attributes.transform_values { |values| values.to_h },
spam_params: spam_params
).execute(work_item)
check_spam_action_response!(work_item)
{
work_item: work_item.valid? ? work_item : nil,
errors: errors_on_object(work_item)
}
end
private
def find_object(id:)
GitlabSchema.find_by_gid(id)
end
end
end
end

View File

@ -143,6 +143,7 @@ module Types
mount_mutation Mutations::WorkItems::Delete, deprecated: { milestone: '15.1', reason: :alpha }
mount_mutation Mutations::WorkItems::DeleteTask, deprecated: { milestone: '15.1', reason: :alpha }
mount_mutation Mutations::WorkItems::Update, deprecated: { milestone: '15.1', reason: :alpha }
mount_mutation Mutations::WorkItems::UpdateWidgets, deprecated: { milestone: '15.1', reason: :alpha }
mount_mutation Mutations::WorkItems::UpdateTask, deprecated: { milestone: '15.1', reason: :alpha }
mount_mutation Mutations::SavedReplies::Create
mount_mutation Mutations::SavedReplies::Update

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
module Types
module WorkItems
module Widgets
class DescriptionInputType < BaseInputObject
graphql_name 'WorkItemWidgetDescriptionInput'
argument :description, GraphQL::Types::String,
required: true,
description: copy_field_description(Types::WorkItemType, :description)
end
end
end
end

View File

@ -33,6 +33,7 @@ module Ci
"project-namespace" => project.namespace.full_path,
"runner-help-page-path" => help_page_path('ci/runners/index'),
"total-branches" => total_branches,
"validate-tab-illustration-path" => image_path('illustrations/project-run-CICD-pipelines-sm.svg'),
"yml-help-page-path" => help_page_path('ci/yaml/index')
}
end

View File

@ -7,6 +7,10 @@ module WorkItems
name.demodulize.underscore.to_sym
end
def self.api_symbol
"#{type}_widget".to_sym
end
def type
self.class.type
end

View File

@ -4,6 +4,10 @@ module WorkItems
module Widgets
class Description < Base
delegate :description, to: :work_item
def update(params:)
work_item.description = params[:description] if params&.key?(:description)
end
end
end
end

View File

@ -2,12 +2,30 @@
module WorkItems
class UpdateService < ::Issues::UpdateService
def initialize(project:, current_user: nil, params: {}, spam_params: nil, widget_params: {})
super(project: project, current_user: current_user, params: params, spam_params: nil)
@widget_params = widget_params
end
private
def after_update(issuable)
def update(work_item)
execute_widgets(work_item: work_item, callback: :update)
super
end
def after_update(work_item)
super
GraphqlTriggers.issuable_title_updated(issuable) if issuable.previous_changes.key?(:title)
GraphqlTriggers.issuable_title_updated(work_item) if work_item.previous_changes.key?(:title)
end
def execute_widgets(work_item:, callback:)
work_item.widgets.each do |widget|
widget.try(callback, params: @widget_params[widget.class.api_symbol])
end
end
end
end

View File

@ -1,5 +1,5 @@
= form_for @application_setting, url: reporting_admin_application_settings_path(anchor: 'js-abuse-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group

View File

@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
- fallback_branch_name = "<code>#{Gitlab::DefaultBranch.value}</code>"

View File

@ -1,5 +1,5 @@
= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-merge-request-settings'), html: { class: 'fieldset-form', id: 'merge-request-settings' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group

View File

@ -10,7 +10,7 @@
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-eks-settings'), html: { class: 'fieldset-form', id: 'eks-settings' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group

View File

@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-email-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group

View File

@ -10,7 +10,7 @@
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-external-auth-settings'), html: { class: 'fieldset-form', id: 'external-auth-settings' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group

View File

@ -1,5 +1,5 @@
= form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-gitaly-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group

View File

@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-help-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
= render_if_exists 'admin/application_settings/help_text_setting', form: f

View File

@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-localization-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group

View File

@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-pages-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group

View File

@ -1,5 +1,5 @@
= form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-realtime-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group

View File

@ -1,5 +1,5 @@
= form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-sidekiq-job-limits-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group

View File

@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-whats-new-settings'), html: { class: 'fieldset-form whats-new-settings' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
- whats_new_variants.keys.each do |variant|
.gl-mb-4

View File

@ -3,4 +3,4 @@
%h3= _('Branch rules')
#js-branch-rules
#js-branch-rules{ data: { project_path: @project.full_path } }

View File

@ -11,10 +11,11 @@
.input-group
= search_field_tag :search, params[:search], { placeholder: _('Filter'), id: 'label-search', class: 'form-control search-text-input input-short', spellcheck: false, autofocus: true }
%span.input-group-append
%button.btn.gl-button.btn-default{ type: "submit", "aria-label" => _('Submit search') }
= sprite_icon('search')
= render Pajamas::ButtonComponent.new(icon: 'search', button_options: { type: "submit", "aria-label" => _('Submit search') })
= render 'shared/labels/sort_dropdown'
- if labels_or_filters && can_admin_label && @project
= link_to _('New label'), new_project_label_path(@project), class: "btn gl-button btn-confirm qa-label-create-new"
= render Pajamas::ButtonComponent.new(variant: :confirm, href: new_project_label_path(@project), button_options: { class: 'qa-label-create-new' }) do
= _('New label')
- if labels_or_filters && can_admin_label && @group
= link_to _('New label'), new_group_label_path(@group), class: "btn gl-button btn-confirm qa-label-create-new"
= render Pajamas::ButtonComponent.new(variant: :confirm, href: new_group_label_path(@group), button_options: { class: 'qa-label-create-new' }) do
= _('New label')

View File

@ -0,0 +1,8 @@
---
name: enable_vulnerability_remediations_from_records
introduced_by_url:
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/362283
milestone: '15.1'
type: development
group: group::threat insights
default_enabled: false

View File

@ -1,8 +1,8 @@
---
name: downstream_retry_action
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/83751
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/357406
milestone: '15.0'
name: simulate_pipeline
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88630
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364257
milestone: '15.1'
type: development
group: group::pipeline authoring
default_enabled: false

View File

@ -5569,6 +5569,32 @@ Input type: `WorkItemUpdateTaskInput`
| <a id="mutationworkitemupdatetasktask"></a>`task` | [`WorkItem`](#workitem) | Updated task. |
| <a id="mutationworkitemupdatetaskworkitem"></a>`workItem` | [`WorkItem`](#workitem) | Updated work item. |
### `Mutation.workItemUpdateWidgets`
Updates the attributes of a work item's widgets by global ID. Available only when feature flag `work_items` is enabled.
WARNING:
**Deprecated** in 15.1.
This feature is in Alpha, and can be removed or changed at any point.
Input type: `WorkItemUpdateWidgetsInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationworkitemupdatewidgetsclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemupdatewidgetsdescriptionwidget"></a>`descriptionWidget` | [`WorkItemWidgetDescriptionInput`](#workitemwidgetdescriptioninput) | Input for description widget. |
| <a id="mutationworkitemupdatewidgetsid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationworkitemupdatewidgetsclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemupdatewidgetserrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationworkitemupdatewidgetsworkitem"></a>`workItem` | [`WorkItem`](#workitem) | Updated work item. |
## Connections
Some types in our schema are `Connection` types - they represent a paginated
@ -21708,3 +21734,11 @@ A time-frame defined as a closed inclusive range of two dates.
| <a id="workitemupdatedtaskinputid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. |
| <a id="workitemupdatedtaskinputstateevent"></a>`stateEvent` | [`WorkItemStateEvent`](#workitemstateevent) | Close or reopen a work item. |
| <a id="workitemupdatedtaskinputtitle"></a>`title` | [`String`](#string) | Title of the work item. |
### `WorkItemWidgetDescriptionInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="workitemwidgetdescriptioninputdescription"></a>`description` | [`String!`](#string) | Description of the work item. |

View File

@ -457,12 +457,15 @@ For information on adding pipeline badges to projects, see [Pipeline badges](set
### Downstream pipelines
> Cancel or retry downstream pipelines from the graph view [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/354974) in GitLab 15.0 [with a flag](../../administration/feature_flags.md) named `downstream_retry_action`. Disabled by default.
In the pipeline graph view, downstream pipelines ([Multi-project pipelines](multi_project_pipelines.md)
and [Parent-child pipelines](parent_child_pipelines.md)) display as a list of cards
on the right of the graph.
#### Cancel or retry downstream pipelines from the graph view
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/354974) in GitLab 15.0 [with a flag](../../administration/feature_flags.md) named `downstream_retry_action`. Disabled by default.
> - [Generally available and feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/357406) in GitLab 15.1.
To cancel a downstream pipeline that is still running, select **Cancel** (**{cancel}**)
on the pipeline's card.

View File

@ -192,7 +192,7 @@ To see what polyfills are being used:
1. Select the [`compile-production-assets`](https://gitlab.com/gitlab-org/gitlab/-/jobs/641770154) job.
1. In the right-hand sidebar, scroll to **Job Artifacts**, and select **Browse**.
1. Select the **webpack-report** folder to open it, and select **index.html**.
1. In the upper left corner of the page, select the right arrow **{angle-right}**
1. In the upper left corner of the page, select the right arrow **{chevron-lg-right}**
to display the explorer.
1. In the **Search modules** field, enter `gitlab/node_modules/core-js` to see
which polyfills are being loaded and where:

View File

@ -97,6 +97,18 @@ label is set on the MR. The goal is to reduce the CI/CD minutes consumed by fork
See the [experiment issue](https://gitlab.com/gitlab-org/quality/team-tasks/-/issues/1170).
## Faster feedback when reverting merge requests
When you need to revert a merge request, to get accelerated feedback, you can add the `~pipeline:revert` label to your merge request.
When this label is assigned, the following steps of the CI/CD pipeline are skipped:
- The `package-and-qa` job.
- The `rspec:undercoverage` job.
- The entire [Review Apps process](testing_guide/review_apps.md).
Apply the label to the merge request, and run a new pipeline for the MR.
## Fail-fast job in merge request pipelines
To provide faster feedback when a merge request breaks existing tests, we are experimenting with a

View File

@ -16,7 +16,7 @@ module Gitlab
numbers: 'Numbers'
}.freeze
ALLOWED_DATABASE_OPERATIONS = %w(count distinct_count estimate_batch_distinct_count sum).freeze
ALLOWED_DATABASE_OPERATIONS = %w(count distinct_count estimate_batch_distinct_count sum average).freeze
ALLOWED_NUMBERS_OPERATIONS = %w(add).freeze
ALLOWED_OPERATIONS = ALLOWED_DATABASE_OPERATIONS | ALLOWED_NUMBERS_OPERATIONS

View File

@ -1,7 +1,11 @@
# frozen_string_literal: true
# For large tables, PostgreSQL can take a long time to count rows due to MVCC.
# Implements a distinct and ordinary batch counter
# Implements:
# - distinct batch counter
# - ordinary batch counter
# - sum batch counter
# - average batch counter
# Needs indexes on the column below to calculate max, min and range queries
# For larger tables just set use higher batch_size with index optimization
#
@ -22,6 +26,8 @@
# batch_distinct_count(Project.group(:visibility_level), :creator_id)
# batch_sum(User, :sign_in_count)
# batch_sum(Issue.group(:state_id), :weight))
# batch_average(Ci::Pipeline, :duration)
# batch_average(MergeTrain.group(:status), :duration)
module Gitlab
module Database
module BatchCount
@ -37,6 +43,10 @@ module Gitlab
BatchCounter.new(relation, column: nil, operation: :sum, operation_args: [column]).count(batch_size: batch_size, start: start, finish: finish)
end
def batch_average(relation, column, batch_size: nil, start: nil, finish: nil)
BatchCounter.new(relation, column: nil, operation: :average, operation_args: [column]).count(batch_size: batch_size, start: start, finish: finish)
end
class << self
include BatchCount
end

View File

@ -6,6 +6,7 @@ module Gitlab
FALLBACK = -1
MIN_REQUIRED_BATCH_SIZE = 1_250
DEFAULT_SUM_BATCH_SIZE = 1_000
DEFAULT_AVERAGE_BATCH_SIZE = 1_000
MAX_ALLOWED_LOOPS = 10_000
SLEEP_TIME_IN_SECONDS = 0.01 # 10 msec sleep
ALLOWED_MODES = [:itself, :distinct].freeze
@ -26,6 +27,7 @@ module Gitlab
def unwanted_configuration?(finish, batch_size, start)
(@operation == :count && batch_size <= MIN_REQUIRED_BATCH_SIZE) ||
(@operation == :sum && batch_size < DEFAULT_SUM_BATCH_SIZE) ||
(@operation == :average && batch_size < DEFAULT_AVERAGE_BATCH_SIZE) ||
(finish - start) / batch_size >= MAX_ALLOWED_LOOPS ||
start >= finish
end
@ -92,6 +94,7 @@ module Gitlab
def batch_size_for_mode_and_operation(mode, operation)
return DEFAULT_SUM_BATCH_SIZE if operation == :sum
return DEFAULT_AVERAGE_BATCH_SIZE if operation == :average
mode == :distinct ? DEFAULT_DISTINCT_BATCH_SIZE : DEFAULT_BATCH_SIZE
end

View File

@ -18,7 +18,7 @@ module Gitlab
UnimplementedOperationError = Class.new(StandardError) # rubocop:disable UsageData/InstrumentationSuperclass
class << self
IMPLEMENTED_OPERATIONS = %i(count distinct_count estimate_batch_distinct_count sum).freeze
IMPLEMENTED_OPERATIONS = %i(count distinct_count estimate_batch_distinct_count sum average).freeze
private_constant :IMPLEMENTED_OPERATIONS

View File

@ -19,6 +19,8 @@ module Gitlab
name_suggestion(column: column, relation: relation, prefix: 'estimate_distinct_count')
when :sum
name_suggestion(column: column, relation: relation, prefix: 'sum')
when :average
name_suggestion(column: column, relation: relation, prefix: 'average')
when :redis
REDIS_EVENT_METRIC_NAME
when :alt

View File

@ -13,6 +13,8 @@ module Gitlab
distinct_count(relation, column)
when :sum
sum(relation, column)
when :average
average(relation, column)
when :estimate_batch_distinct_count
estimate_batch_distinct_count(relation, column)
when :histogram
@ -36,6 +38,10 @@ module Gitlab
raw_sum_sql(relation, column)
end
def average(relation, column)
raw_average_sql(relation, column)
end
def estimate_batch_distinct_count(relation, column = nil)
raw_count_sql(relation, column, true)
end
@ -78,6 +84,14 @@ module Gitlab
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def raw_average_sql(relation, column)
node = node_to_operate(relation, column)
relation.unscope(:order).select(node.average).to_sql
end
# rubocop: enable CodeReuse/ActiveRecord
def node_to_operate(relation, column)
if join_relation?(relation) && joined_column?(column)
table_name, column_name = column.split(".")

View File

@ -104,6 +104,15 @@ module Gitlab
end
end
def average(relation, column, batch_size: nil, start: nil, finish: nil)
with_duration do
Gitlab::Database::BatchCount.batch_average(relation, column, batch_size: batch_size, start: start, finish: finish)
rescue ActiveRecord::StatementInvalid => error
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(error)
FALLBACK
end
end
# We don't support batching with histograms.
# Please avoid using this method on large tables.
# See https://gitlab.com/gitlab-org/gitlab/-/issues/323949.

View File

@ -4008,6 +4008,9 @@ msgstr ""
msgid "An error occurred while fetching ancestors"
msgstr ""
msgid "An error occurred while fetching branches."
msgstr ""
msgid "An error occurred while fetching branches. Retry the search."
msgstr ""
@ -27860,6 +27863,21 @@ msgstr ""
msgid "PipelineEditorTutorial|🚀 Run your first pipeline"
msgstr ""
msgid "PipelineEditor|Current content in the Edit tab will be used for the simulation."
msgstr ""
msgid "PipelineEditor|Git push event to the default branch"
msgstr ""
msgid "PipelineEditor|Other pipeline sources are not available yet."
msgstr ""
msgid "PipelineEditor|Pipeline Source"
msgstr ""
msgid "PipelineEditor|Pipeline behavior will be simulated including the %{codeStart}rules%{codeEnd} %{codeStart}only%{codeEnd} %{codeStart}except%{codeEnd} and %{codeStart}needs%{codeEnd} job dependencies."
msgstr ""
msgid "PipelineEditor|The CI/CD configuration is continuously validated. Errors and warnings are displayed when the CI/CD configuration file is not empty."
msgstr ""
@ -27872,6 +27890,12 @@ msgstr ""
msgid "PipelineEditor|This tab will be usable when the CI/CD configuration file is populated with valid syntax."
msgstr ""
msgid "PipelineEditor|Validate pipeline"
msgstr ""
msgid "PipelineEditor|Validate pipeline under selected conditions"
msgstr ""
msgid "PipelineScheduleIntervalPattern|Custom (%{linkStart}Cron syntax%{linkEnd})"
msgstr ""
@ -28238,6 +28262,9 @@ msgstr ""
msgid "Pipelines|Use template"
msgstr ""
msgid "Pipelines|Validate"
msgstr ""
msgid "Pipelines|Validating GitLab CI configuration…"
msgstr ""

View File

@ -414,16 +414,6 @@ RSpec.describe 'Pipeline', :js do
expect(page).to have_selector('button[aria-label="Retry downstream pipeline"]')
end
context 'and the FF downstream_retry_action is disabled' do
before do
stub_feature_flags(downstream_retry_action: false)
end
it 'does not show the retry action' do
expect(page).not_to have_selector('button[aria-label="Retry downstream pipeline"]')
end
end
context 'when retrying' do
before do
find('button[aria-label="Retry downstream pipeline"]').click

View File

@ -478,6 +478,7 @@
This reference tag is a mix of letters and numbers. [^footnote]
[^1]: This is the text inside a footnote.
[^footnote]: This is another footnote.
html: |-
<p data-sourcepos="1:1-1:46" dir="auto">A footnote reference tag looks like this: <sup class="footnote-ref"><a href="#fn-1-2717" id="fnref-1-2717" data-footnote-ref="">1</a></sup></p>

View File

@ -0,0 +1,7 @@
import FootnoteDefinition from '~/content_editor/extensions/footnote_definition';
describe('content_editor/extensions/footnote_definition', () => {
it('sets the isolation option to true', () => {
expect(FootnoteDefinition.config.isolating).toBe(true);
});
});

View File

@ -3,6 +3,8 @@ import Blockquote from '~/content_editor/extensions/blockquote';
import BulletList from '~/content_editor/extensions/bullet_list';
import Code from '~/content_editor/extensions/code';
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
import FootnoteDefinition from '~/content_editor/extensions/footnote_definition';
import FootnoteReference from '~/content_editor/extensions/footnote_reference';
import HardBreak from '~/content_editor/extensions/hard_break';
import Heading from '~/content_editor/extensions/heading';
import HorizontalRule from '~/content_editor/extensions/horizontal_rule';
@ -32,6 +34,8 @@ const tiptapEditor = createTestEditor({
BulletList,
Code,
CodeBlockHighlight,
FootnoteDefinition,
FootnoteReference,
HardBreak,
Heading,
HorizontalRule,
@ -60,6 +64,8 @@ const {
bulletList,
code,
codeBlock,
footnoteDefinition,
footnoteReference,
hardBreak,
heading,
horizontalRule,
@ -84,6 +90,8 @@ const {
bulletList: { nodeType: BulletList.name },
code: { markType: Code.name },
codeBlock: { nodeType: CodeBlockHighlight.name },
footnoteDefinition: { nodeType: FootnoteDefinition.name },
footnoteReference: { nodeType: FootnoteReference.name },
hardBreak: { nodeType: HardBreak.name },
heading: { nodeType: Heading.name },
horizontalRule: { nodeType: HorizontalRule.name },
@ -362,7 +370,6 @@ describe('Client side Markdown processing', () => {
),
},
{
only: true,
markdown: '[https://gitlab.com>',
expectedDoc: doc(
paragraph(
@ -958,6 +965,38 @@ const fn = () => 'GitLab';
),
),
},
{
markdown: `
This is a footnote [^footnote]
Paragraph
[^footnote]: Footnote definition
Paragraph
`,
expectedDoc: doc(
paragraph(
sourceAttrs('0:30', 'This is a footnote [^footnote]'),
'This is a footnote ',
footnoteReference({
...sourceAttrs('19:30', '[^footnote]'),
identifier: 'footnote',
label: 'footnote',
}),
),
paragraph(sourceAttrs('32:41', 'Paragraph'), 'Paragraph'),
footnoteDefinition(
{
...sourceAttrs('43:75', '[^footnote]: Footnote definition'),
identifier: 'footnote',
label: 'footnote',
},
paragraph(sourceAttrs('56:75', 'Footnote definition'), 'Footnote definition'),
),
paragraph(sourceAttrs('77:86', 'Paragraph'), 'Paragraph'),
),
},
];
const runOnly = examples.find((example) => example.only === true);

View File

@ -1,35 +1,48 @@
import { render } from '~/lib/gfm';
describe('gfm', () => {
const markdownToAST = async (markdown) => {
let result;
await render({
markdown,
renderer: (tree) => {
result = tree;
},
});
return result;
};
const expectInRoot = (result, ...nodes) => {
expect(result).toEqual(
expect.objectContaining({
children: expect.arrayContaining(nodes),
}),
);
};
describe('render', () => {
it('processes Commonmark and provides an ast to the renderer function', async () => {
let result;
await render({
markdown: 'This is text',
renderer: (tree) => {
result = tree;
},
});
const result = await markdownToAST('This is text');
expect(result.type).toBe('root');
});
it('transforms raw HTML into individual nodes in the AST', async () => {
let result;
const result = await markdownToAST('<strong>This is bold text</strong>');
await render({
markdown: '<strong>This is bold text</strong>',
renderer: (tree) => {
result = tree;
},
});
expect(result.children[0].children[0]).toMatchObject({
type: 'element',
tagName: 'strong',
properties: {},
});
expectInRoot(
result,
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
type: 'element',
tagName: 'strong',
}),
]),
}),
);
});
it('returns the result of executing the renderer function', async () => {
@ -44,5 +57,40 @@ describe('gfm', () => {
expect(result).toEqual(rendered);
});
it('transforms footnotes into footnotedefinition and footnotereference tags', async () => {
const result = await markdownToAST(
`footnote reference [^footnote]
[^footnote]: Footnote definition`,
);
expectInRoot(
result,
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
type: 'element',
tagName: 'footnotereference',
properties: {
identifier: 'footnote',
label: 'footnote',
},
}),
]),
}),
);
expectInRoot(
result,
expect.objectContaining({
tagName: 'footnotedefinition',
properties: {
identifier: 'footnote',
label: 'footnote',
},
}),
);
});
});
});

View File

@ -3,8 +3,9 @@ import { shallowMount, mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import setWindowLocation from 'helpers/set_window_location_helper';
import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue';
import WalkthroughPopover from '~/pipeline_editor/components/popovers/walkthrough_popover.vue';
import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue';
import CiValidate from '~/pipeline_editor/components/validate/ci_validate.vue';
import WalkthroughPopover from '~/pipeline_editor/components/popovers/walkthrough_popover.vue';
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue';
import {
@ -58,10 +59,12 @@ describe('Pipeline editor tabs component', () => {
const findEditorTab = () => wrapper.find('[data-testid="editor-tab"]');
const findLintTab = () => wrapper.find('[data-testid="lint-tab"]');
const findMergedTab = () => wrapper.find('[data-testid="merged-tab"]');
const findValidateTab = () => wrapper.find('[data-testid="validate-tab"]');
const findVisualizationTab = () => wrapper.find('[data-testid="visualization-tab"]');
const findAlert = () => wrapper.findComponent(GlAlert);
const findCiLint = () => wrapper.findComponent(CiLint);
const findCiValidate = () => wrapper.findComponent(CiValidate);
const findGlTabs = () => wrapper.findComponent(GlTabs);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findPipelineGraph = () => wrapper.findComponent(PipelineGraph);
@ -109,6 +112,61 @@ describe('Pipeline editor tabs component', () => {
});
});
describe('validate tab', () => {
describe('with simulatePipeline feature flag ON', () => {
describe('while loading', () => {
beforeEach(() => {
createComponent({
appStatus: EDITOR_APP_STATUS_LOADING,
provide: {
glFeatures: {
simulatePipeline: true,
},
},
});
});
it('displays a loading icon if the lint query is loading', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not display the validate component', () => {
expect(findCiValidate().exists()).toBe(false);
});
});
describe('after loading', () => {
beforeEach(() => {
createComponent({
provide: { glFeatures: { simulatePipeline: true } },
});
});
it('displays the tab and the validate component', () => {
expect(findValidateTab().exists()).toBe(true);
expect(findCiValidate().exists()).toBe(true);
});
});
});
describe('with simulatePipeline feature flag OFF', () => {
beforeEach(() => {
createComponent({
provide: {
glFeatures: {
simulatePipeline: false,
},
},
});
});
it('does not render the tab and the validate component', () => {
expect(findValidateTab().exists()).toBe(false);
expect(findCiValidate().exists()).toBe(false);
});
});
});
describe('lint tab', () => {
describe('while loading', () => {
beforeEach(() => {
@ -123,6 +181,7 @@ describe('Pipeline editor tabs component', () => {
expect(findCiLint().exists()).toBe(false);
});
});
describe('after loading', () => {
beforeEach(() => {
createComponent();
@ -133,8 +192,24 @@ describe('Pipeline editor tabs component', () => {
expect(findCiLint().exists()).toBe(true);
});
});
});
describe('with simulatePipeline feature flag ON', () => {
beforeEach(() => {
createComponent({
provide: {
glFeatures: {
simulatePipeline: true,
},
},
});
});
it('does not render the tab and the lint component', () => {
expect(findLintTab().exists()).toBe(false);
expect(findCiLint().exists()).toBe(false);
});
});
});
describe('merged tab', () => {
describe('while loading', () => {
beforeEach(() => {

View File

@ -0,0 +1,40 @@
import { GlButton, GlDropdown } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import CiValidate, { i18n } from '~/pipeline_editor/components/validate/ci_validate.vue';
describe('Pipeline Editor Validate Tab', () => {
let wrapper;
const createComponent = ({ stubs } = {}) => {
wrapper = shallowMount(CiValidate, {
provide: {
validateTabIllustrationPath: '/path/to/img',
},
stubs,
});
};
const findCta = () => wrapper.findComponent(GlButton);
const findPipelineSource = () => wrapper.findComponent(GlDropdown);
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders disabled pipeline source dropdown', () => {
expect(findPipelineSource().exists()).toBe(true);
expect(findPipelineSource().attributes('text')).toBe(i18n.pipelineSourceDefault);
expect(findPipelineSource().attributes('disabled')).toBe('true');
});
it('renders CTA', () => {
expect(findCta().exists()).toBe(true);
expect(findCta().text()).toBe(i18n.cta);
});
});
});

View File

@ -47,17 +47,12 @@ describe('Linked pipeline', () => {
const findPipelineLink = () => wrapper.findByTestId('pipelineLink');
const findRetryButton = () => wrapper.findByLabelText('Retry downstream pipeline');
const createWrapper = ({ propsData, downstreamRetryAction = false }) => {
const createWrapper = ({ propsData }) => {
const mockApollo = createMockApollo();
wrapper = extendedWrapper(
mount(LinkedPipelineComponent, {
propsData,
provide: {
glFeatures: {
downstreamRetryAction,
},
},
apolloProvider: mockApollo,
}),
);
@ -164,205 +159,188 @@ describe('Linked pipeline', () => {
});
describe('action button', () => {
describe('with the `downstream_retry_action` flag on', () => {
describe('with permissions', () => {
describe('on an upstream', () => {
describe('when retryable', () => {
beforeEach(() => {
const retryablePipeline = {
...upstreamProps,
pipeline: { ...mockPipeline, retryable: true },
};
describe('with permissions', () => {
describe('on an upstream', () => {
describe('when retryable', () => {
beforeEach(() => {
const retryablePipeline = {
...upstreamProps,
pipeline: { ...mockPipeline, retryable: true },
};
createWrapper({ propsData: retryablePipeline, downstreamRetryAction: true });
});
it('does not show the retry or cancel button', () => {
expect(findCancelButton().exists()).toBe(false);
expect(findRetryButton().exists()).toBe(false);
});
});
});
describe('on a downstream', () => {
describe('when retryable', () => {
beforeEach(() => {
const retryablePipeline = {
...downstreamProps,
pipeline: { ...mockPipeline, retryable: true },
};
createWrapper({ propsData: retryablePipeline, downstreamRetryAction: true });
});
it('shows only the retry button', () => {
expect(findCancelButton().exists()).toBe(false);
expect(findRetryButton().exists()).toBe(true);
});
it.each`
findElement | name
${findRetryButton} | ${'retry button'}
${findExpandButton} | ${'expand button'}
`('hides the card tooltip when $name is hovered', async ({ findElement }) => {
expect(findCardTooltip().exists()).toBe(true);
await findElement().trigger('mouseover');
expect(findCardTooltip().exists()).toBe(false);
});
describe('and the retry button is clicked', () => {
describe('on success', () => {
beforeEach(async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue();
jest.spyOn(wrapper.vm, '$emit');
await findRetryButton().trigger('click');
});
it('calls the retry mutation ', () => {
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: RetryPipelineMutation,
variables: {
id: convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, mockPipeline.id),
},
});
});
it('emits the refreshPipelineGraph event', () => {
expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph');
});
});
describe('on failure', () => {
beforeEach(async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] });
jest.spyOn(wrapper.vm, '$emit');
await findRetryButton().trigger('click');
});
it('emits an error event', () => {
expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', {
type: ACTION_FAILURE,
});
});
});
});
createWrapper({ propsData: retryablePipeline });
});
describe('when cancelable', () => {
beforeEach(() => {
const cancelablePipeline = {
...downstreamProps,
pipeline: { ...mockPipeline, cancelable: true },
};
createWrapper({ propsData: cancelablePipeline, downstreamRetryAction: true });
});
it('shows only the cancel button ', () => {
expect(findCancelButton().exists()).toBe(true);
expect(findRetryButton().exists()).toBe(false);
});
it.each`
findElement | name
${findCancelButton} | ${'cancel button'}
${findExpandButton} | ${'expand button'}
`('hides the card tooltip when $name is hovered', async ({ findElement }) => {
expect(findCardTooltip().exists()).toBe(true);
await findElement().trigger('mouseover');
expect(findCardTooltip().exists()).toBe(false);
});
describe('and the cancel button is clicked', () => {
describe('on success', () => {
beforeEach(async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue();
jest.spyOn(wrapper.vm, '$emit');
await findCancelButton().trigger('click');
});
it('calls the cancel mutation', () => {
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: CancelPipelineMutation,
variables: {
id: convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, mockPipeline.id),
},
});
});
it('emits the refreshPipelineGraph event', () => {
expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph');
});
});
describe('on failure', () => {
beforeEach(async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] });
jest.spyOn(wrapper.vm, '$emit');
await findCancelButton().trigger('click');
});
it('emits an error event', () => {
expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', {
type: ACTION_FAILURE,
});
});
});
});
});
describe('when both cancellable and retryable', () => {
beforeEach(() => {
const pipelineWithTwoActions = {
...downstreamProps,
pipeline: { ...mockPipeline, cancelable: true, retryable: true },
};
createWrapper({ propsData: pipelineWithTwoActions, downstreamRetryAction: true });
});
it('only shows the cancel button', () => {
expect(findRetryButton().exists()).toBe(false);
expect(findCancelButton().exists()).toBe(true);
});
it('does not show the retry or cancel button', () => {
expect(findCancelButton().exists()).toBe(false);
expect(findRetryButton().exists()).toBe(false);
});
});
});
describe('without permissions', () => {
beforeEach(() => {
const pipelineWithTwoActions = {
...downstreamProps,
pipeline: {
...mockPipeline,
cancelable: true,
retryable: true,
userPermissions: { updatePipeline: false },
},
};
describe('on a downstream', () => {
describe('when retryable', () => {
beforeEach(() => {
const retryablePipeline = {
...downstreamProps,
pipeline: { ...mockPipeline, retryable: true },
};
createWrapper({ propsData: pipelineWithTwoActions });
createWrapper({ propsData: retryablePipeline });
});
it('shows only the retry button', () => {
expect(findCancelButton().exists()).toBe(false);
expect(findRetryButton().exists()).toBe(true);
});
it.each`
findElement | name
${findRetryButton} | ${'retry button'}
${findExpandButton} | ${'expand button'}
`('hides the card tooltip when $name is hovered', async ({ findElement }) => {
expect(findCardTooltip().exists()).toBe(true);
await findElement().trigger('mouseover');
expect(findCardTooltip().exists()).toBe(false);
});
describe('and the retry button is clicked', () => {
describe('on success', () => {
beforeEach(async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue();
jest.spyOn(wrapper.vm, '$emit');
await findRetryButton().trigger('click');
});
it('calls the retry mutation ', () => {
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: RetryPipelineMutation,
variables: {
id: convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, mockPipeline.id),
},
});
});
it('emits the refreshPipelineGraph event', () => {
expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph');
});
});
describe('on failure', () => {
beforeEach(async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] });
jest.spyOn(wrapper.vm, '$emit');
await findRetryButton().trigger('click');
});
it('emits an error event', () => {
expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', {
type: ACTION_FAILURE,
});
});
});
});
});
it('does not show any action button', () => {
expect(findRetryButton().exists()).toBe(false);
expect(findCancelButton().exists()).toBe(false);
describe('when cancelable', () => {
beforeEach(() => {
const cancelablePipeline = {
...downstreamProps,
pipeline: { ...mockPipeline, cancelable: true },
};
createWrapper({ propsData: cancelablePipeline });
});
it('shows only the cancel button ', () => {
expect(findCancelButton().exists()).toBe(true);
expect(findRetryButton().exists()).toBe(false);
});
it.each`
findElement | name
${findCancelButton} | ${'cancel button'}
${findExpandButton} | ${'expand button'}
`('hides the card tooltip when $name is hovered', async ({ findElement }) => {
expect(findCardTooltip().exists()).toBe(true);
await findElement().trigger('mouseover');
expect(findCardTooltip().exists()).toBe(false);
});
describe('and the cancel button is clicked', () => {
describe('on success', () => {
beforeEach(async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue();
jest.spyOn(wrapper.vm, '$emit');
await findCancelButton().trigger('click');
});
it('calls the cancel mutation', () => {
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: CancelPipelineMutation,
variables: {
id: convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, mockPipeline.id),
},
});
});
it('emits the refreshPipelineGraph event', () => {
expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph');
});
});
describe('on failure', () => {
beforeEach(async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] });
jest.spyOn(wrapper.vm, '$emit');
await findCancelButton().trigger('click');
});
it('emits an error event', () => {
expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', {
type: ACTION_FAILURE,
});
});
});
});
});
describe('when both cancellable and retryable', () => {
beforeEach(() => {
const pipelineWithTwoActions = {
...downstreamProps,
pipeline: { ...mockPipeline, cancelable: true, retryable: true },
};
createWrapper({ propsData: pipelineWithTwoActions });
});
it('only shows the cancel button', () => {
expect(findRetryButton().exists()).toBe(false);
expect(findCancelButton().exists()).toBe(true);
});
});
});
});
describe('with the `downstream_retry_action` flag off', () => {
describe('without permissions', () => {
beforeEach(() => {
const pipelineWithTwoActions = {
...downstreamProps,
pipeline: { ...mockPipeline, cancelable: true, retryable: true },
pipeline: {
...mockPipeline,
cancelable: true,
retryable: true,
userPermissions: { updatePipeline: false },
},
};
createWrapper({ propsData: pipelineWithTwoActions });
});
it('does not show any action button', () => {
expect(findRetryButton().exists()).toBe(false);
expect(findCancelButton().exists()).toBe(false);

View File

@ -0,0 +1,101 @@
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BranchDropdown, {
i18n,
} from '~/projects/settings/branch_rules/components/branch_dropdown.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import branchesQuery from '~/projects/settings/branch_rules/queries/branches.query.graphql';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/flash';
Vue.use(VueApollo);
jest.mock('~/flash');
describe('Branch dropdown', () => {
let wrapper;
const projectPath = 'test/project';
const value = 'main';
const mockBranchNames = ['test 1', 'test 2'];
const createComponent = async ({ branchNames = mockBranchNames, resolver } = {}) => {
const mockResolver =
resolver ||
jest.fn().mockResolvedValue({
data: { project: { id: '1', repository: { branchNames } } },
});
const apolloProvider = createMockApollo([[branchesQuery, mockResolver]]);
wrapper = shallowMountExtended(BranchDropdown, {
apolloProvider,
propsData: { projectPath, value },
});
await waitForPromises();
};
const findGlDropdown = () => wrapper.find(GlDropdown);
const findAllBranches = () => wrapper.findAll(GlDropdownItem);
const findNoDataMsg = () => wrapper.findByTestId('no-data');
const findGlSearchBoxByType = () => wrapper.find(GlSearchBoxByType);
const findWildcardButton = () => wrapper.findByTestId('create-wildcard-button');
const setSearchTerm = (searchTerm) => findGlSearchBoxByType().vm.$emit('input', searchTerm);
beforeEach(() => createComponent());
it('renders a GlDropdown component with the correct props', () => {
expect(findGlDropdown().props()).toMatchObject({ text: value });
});
it('renders GlDropdownItem components for each branch', () => {
expect(findAllBranches().length).toBe(mockBranchNames.length);
mockBranchNames.forEach((branchName, index) =>
expect(findAllBranches().at(index).text()).toBe(branchName),
);
});
it('emits `select` with the branch name when a branch is clicked', () => {
findAllBranches().at(0).vm.$emit('click');
expect(wrapper.emitted('input')).toEqual([[mockBranchNames[0]]]);
});
describe('branch searching', () => {
it('displays a message if no branches can be found', async () => {
await createComponent({ branchNames: [] });
expect(findNoDataMsg().text()).toBe(i18n.noMatch);
});
it('displays a loading state while search request is in flight', async () => {
setSearchTerm('test');
await nextTick();
expect(findGlSearchBoxByType().props()).toMatchObject({ isLoading: true });
});
it('renders a wildcard button', async () => {
const searchTerm = 'test-*';
setSearchTerm(searchTerm);
await nextTick();
expect(findWildcardButton().exists()).toBe(true);
findWildcardButton().vm.$emit('click');
expect(wrapper.emitted('createWildcard')).toEqual([[searchTerm]]);
});
});
it('displays an error message if fetch failed', async () => {
const error = new Error('an error occurred');
const resolver = jest.fn().mockRejectedValueOnce(error);
await createComponent({ resolver });
expect(createAlert).toHaveBeenCalledWith({
message: i18n.fetchBranchesError,
captureError: true,
error,
});
});
});

View File

@ -0,0 +1,49 @@
import { nextTick } from 'vue';
import { getParameterByName } from '~/lib/utils/url_utility';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import RuleEdit from '~/projects/settings/branch_rules/components/rule_edit.vue';
import BranchDropdown from '~/projects/settings/branch_rules/components/branch_dropdown.vue';
jest.mock('~/lib/utils/url_utility', () => ({
getParameterByName: jest.fn().mockImplementation(() => 'main'),
}));
describe('Edit branch rule', () => {
let wrapper;
const projectPath = 'test/testing';
const createComponent = () => {
wrapper = shallowMountExtended(RuleEdit, { propsData: { projectPath } });
};
const findBranchDropdown = () => wrapper.find(BranchDropdown);
beforeEach(() => createComponent());
it('gets the branch param from url', () => {
expect(getParameterByName).toHaveBeenCalledWith('branch');
});
describe('BranchDropdown', () => {
it('renders a BranchDropdown component with the correct props', () => {
expect(findBranchDropdown().props()).toMatchObject({
projectPath,
value: 'main',
});
});
it('sets the correct value when `input` is emitted', async () => {
const branch = 'test';
findBranchDropdown().vm.$emit('input', branch);
await nextTick();
expect(findBranchDropdown().props('value')).toBe(branch);
});
it('sets the correct value when `createWildcard` is emitted', async () => {
const wildcard = 'test-*';
findBranchDropdown().vm.$emit('createWildcard', wildcard);
await nextTick();
expect(findBranchDropdown().props('value')).toBe(wildcard);
});
});
});

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::WorkItems::UpdateWidgets do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } }
let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
describe '#resolve' do
before do
stub_spam_services
end
context 'when no work item matches the given id' do
let(:current_user) { developer }
let(:gid) { global_id_of(id: non_existing_record_id, model_name: WorkItem.name) }
it 'raises an error' do
expect { mutation.resolve(id: gid, resolve: true) }.to raise_error(
Gitlab::Graphql::Errors::ResourceNotAvailable,
Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR
)
end
end
context 'when user can access the requested work item', :aggregate_failures do
let(:current_user) { developer }
let(:args) { {} }
let_it_be(:work_item) { create(:work_item, project: project) }
subject { mutation.resolve(id: work_item.to_global_id, **args) }
context 'when `:work_items` is disabled for a project' do
let_it_be(:project2) { create(:project) }
it 'returns an error' do
stub_feature_flags(work_items: project2) # only enable `work_item` for project2
expect(subject[:errors]).to contain_exactly('`work_items` feature flag disabled for this project')
end
end
context 'when resolved with an input for description widget' do
let(:args) { { description_widget: { description: "updated description" } } }
it 'returns the updated work item' do
expect(subject[:work_item].description).to eq("updated description")
expect(subject[:errors]).to be_empty
end
end
end
end
end

View File

@ -31,7 +31,13 @@ RSpec.describe Ci::PipelineEditorHelper do
allow(helper)
.to receive(:image_path)
.and_return('foo')
.with('illustrations/empty-state/empty-dag-md.svg')
.and_return('illustrations/empty.svg')
allow(helper)
.to receive(:image_path)
.with('illustrations/project-run-CICD-pipelines-sm.svg')
.and_return('illustrations/validate.svg')
end
subject(:pipeline_editor_data) { helper.js_pipeline_editor_data(project) }
@ -43,7 +49,7 @@ RSpec.describe Ci::PipelineEditorHelper do
"ci-examples-help-page-path" => help_page_path('ci/examples/index'),
"ci-help-page-path" => help_page_path('ci/index'),
"default-branch" => project.default_branch_or_main,
"empty-state-illustration-path" => 'foo',
"empty-state-illustration-path" => 'illustrations/empty.svg',
"initial-branch-name" => nil,
"includes-help-page-path" => help_page_path('ci/yaml/includes'),
"lint-help-page-path" => help_page_path('ci/lint', anchor: 'check-cicd-syntax'),
@ -57,6 +63,7 @@ RSpec.describe Ci::PipelineEditorHelper do
"project-namespace" => project.namespace.full_path,
"runner-help-page-path" => help_page_path('ci/runners/index'),
"total-branches" => project.repository.branches.length,
"validate-tab-illustration-path" => 'illustrations/validate.svg',
"yml-help-page-path" => help_page_path('ci/yaml/index')
})
end
@ -71,7 +78,7 @@ RSpec.describe Ci::PipelineEditorHelper do
"ci-examples-help-page-path" => help_page_path('ci/examples/index'),
"ci-help-page-path" => help_page_path('ci/index'),
"default-branch" => project.default_branch_or_main,
"empty-state-illustration-path" => 'foo',
"empty-state-illustration-path" => 'illustrations/empty.svg',
"initial-branch-name" => nil,
"includes-help-page-path" => help_page_path('ci/yaml/includes'),
"lint-help-page-path" => help_page_path('ci/lint', anchor: 'check-cicd-syntax'),
@ -85,6 +92,7 @@ RSpec.describe Ci::PipelineEditorHelper do
"project-namespace" => project.namespace.full_path,
"runner-help-page-path" => help_page_path('ci/runners/index'),
"total-branches" => 0,
"validate-tab-illustration-path" => 'illustrations/validate.svg',
"yml-help-page-path" => help_page_path('ci/yaml/index')
})
end

View File

@ -384,4 +384,58 @@ RSpec.describe Gitlab::Database::BatchCount do
subject { described_class.method(:batch_sum) }
end
end
describe '#batch_average' do
let(:model) { Issue }
let(:column) { :weight }
before do
Issue.update_all(weight: 2)
end
it 'returns the average of values in the given column' do
expect(described_class.batch_average(model, column)).to eq(2)
end
it 'works when given an Arel column' do
expect(described_class.batch_average(model, model.arel_table[column])).to eq(2)
end
it 'works with a batch size of 50K' do
expect(described_class.batch_average(model, column, batch_size: 50_000)).to eq(2)
end
it 'works with start and finish provided' do
expect(described_class.batch_average(model, column, start: model.minimum(:id), finish: model.maximum(:id))).to eq(2)
end
it "defaults the batch size to #{Gitlab::Database::BatchCounter::DEFAULT_AVERAGE_BATCH_SIZE}" do
min_id = model.minimum(:id)
relation = instance_double(ActiveRecord::Relation)
allow(model).to receive_message_chain(:select, public_send: relation)
batch_end_id = min_id + calculate_batch_size(Gitlab::Database::BatchCounter::DEFAULT_AVERAGE_BATCH_SIZE)
expect(relation).to receive(:where).with("id" => min_id..batch_end_id).and_return(double(send: 1))
described_class.batch_average(model, column)
end
it_behaves_like 'when a transaction is open' do
subject { described_class.batch_average(model, column) }
end
it_behaves_like 'disallowed configurations', :batch_average do
let(:args) { [model, column] }
let(:default_batch_size) { Gitlab::Database::BatchCounter::DEFAULT_AVERAGE_BATCH_SIZE }
let(:small_batch_size) { Gitlab::Database::BatchCounter::DEFAULT_AVERAGE_BATCH_SIZE - 1 }
end
it_behaves_like 'when batch fetch query is canceled' do
let(:mode) { :itself }
let(:operation) { :average }
let(:operation_args) { [column] }
subject { described_class.method(:batch_average) }
end
end
end

View File

@ -71,6 +71,17 @@ RSpec.describe Gitlab::Usage::Metrics::NameSuggestion do
end
end
context 'for average metrics' do
it_behaves_like 'name suggestion' do
# corresponding metric is collected with average(Ci::Pipeline, :duration)
let(:key_path) { 'counts.ci_pipeline_duration' }
let(:operation) { :average }
let(:relation) { Ci::Pipeline }
let(:column) { :duration}
let(:name_suggestion) { /average_duration_from_ci_pipelines/ }
end
end
context 'for redis metrics' do
it_behaves_like 'name suggestion' do
# corresponding metric is collected with redis_usage_data { unique_visit_service.unique_visits_for(targets: :analytics) }

View File

@ -61,6 +61,12 @@ RSpec.describe Gitlab::Usage::Metrics::Query do
end
end
describe '.average' do
it 'returns the raw SQL' do
expect(described_class.for(:average, Issue, :weight)).to eq('SELECT AVG("issues"."weight") FROM "issues"')
end
end
describe 'estimate_batch_distinct_count' do
it 'returns the raw SQL' do
expect(described_class.for(:estimate_batch_distinct_count, Issue, :author_id)).to eq('SELECT COUNT(DISTINCT "issues"."author_id") FROM "issues"')

View File

@ -259,6 +259,37 @@ RSpec.describe Gitlab::Utils::UsageData do
end
end
describe '#average' do
let(:relation) { double(:relation) }
it 'returns the average when operation succeeds' do
allow(Gitlab::Database::BatchCount)
.to receive(:batch_average)
.with(relation, :column, batch_size: 100, start: 2, finish: 3)
.and_return(1)
expect(described_class.average(relation, :column, batch_size: 100, start: 2, finish: 3)).to eq(1)
end
it 'records duration' do
expect(described_class).to receive(:with_duration)
allow(Gitlab::Database::BatchCount).to receive(:batch_average).and_return(1)
described_class.average(relation, :column)
end
context 'when operation fails' do
subject { described_class.average(relation, :column) }
let(:fallback) { 15 }
let(:failing_class) { Gitlab::Database::BatchCount }
let(:failing_method) { :batch_average }
it_behaves_like 'failing hardening method'
end
end
describe '#histogram' do
let_it_be(:projects) { create_list(:project, 3) }

View File

@ -0,0 +1,78 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Update work item widgets' do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } }
let_it_be(:work_item, refind: true) { create(:work_item, project: project) }
let(:input) do
{
'descriptionWidget' => { 'description' => 'updated description' }
}
end
let(:mutation) { graphql_mutation(:workItemUpdateWidgets, input.merge('id' => work_item.to_global_id.to_s)) }
let(:mutation_response) { graphql_mutation_response(:work_item_update_widgets) }
context 'the user is not allowed to update a work item' do
let(:current_user) { create(:user) }
it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when user has permissions to update a work item', :aggregate_failures do
let(:current_user) { developer }
context 'when the updated work item is not valid' do
it 'returns validation errors without the work item' do
errors = ActiveModel::Errors.new(work_item).tap { |e| e.add(:description, 'error message') }
allow_next_found_instance_of(::WorkItem) do |instance|
allow(instance).to receive(:valid?).and_return(false)
allow(instance).to receive(:errors).and_return(errors)
end
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response['workItem']).to be_nil
expect(mutation_response['errors']).to match_array(['Description error message'])
end
end
it 'updates the work item widgets' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
work_item.reload
end.to change(work_item, :description).from(nil).to('updated description')
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['workItem']).to include(
'title' => work_item.title
)
end
it_behaves_like 'has spam protection' do
let(:mutation_class) { ::Mutations::WorkItems::UpdateWidgets }
end
context 'when the work_items feature flag is disabled' do
before do
stub_feature_flags(work_items: false)
end
it 'does not update the work item and returns and error' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
work_item.reload
end.to not_change(work_item, :title)
expect(mutation_response['errors']).to contain_exactly('`work_items` feature flag disabled for this project')
end
end
end
end

View File

@ -8,11 +8,12 @@ RSpec.describe WorkItems::UpdateService do
let_it_be_with_reload(:work_item) { create(:work_item, project: project, assignees: [developer]) }
let(:spam_params) { double }
let(:widget_params) { {} }
let(:opts) { {} }
let(:current_user) { developer }
describe '#execute' do
subject(:update_work_item) { described_class.new(project: project, current_user: current_user, params: opts, spam_params: spam_params).execute(work_item) }
subject(:update_work_item) { described_class.new(project: project, current_user: current_user, params: opts, spam_params: spam_params, widget_params: widget_params).execute(work_item) }
before do
stub_spam_services
@ -69,5 +70,17 @@ RSpec.describe WorkItems::UpdateService do
end
end
end
context 'when updating widgets' do
context 'for the description widget' do
let(:widget_params) { { description_widget: { description: 'changed' } } }
it 'updates the description of the work item' do
update_work_item
expect(work_item.description).to eq('changed')
end
end
end
end
end