Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
9440c17f55
commit
36b47b4bd3
|
@ -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
|
||||
|
|
|
@ -1 +1 @@
|
|||
13086e25394b65e4c17eca8484890f62bb2f0b92
|
||||
5caf724a8305ea04370dc49f0d9a7d5f3bc8dd4a
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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 } });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
query getBranches($projectPath: ID!, $searchPattern: String!) {
|
||||
project(fullPath: $projectPath) {
|
||||
id
|
||||
repository {
|
||||
branchNames(searchPattern: $searchPattern, limit: 100, offset: 0)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>"
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -3,4 +3,4 @@
|
|||
|
||||
%h3= _('Branch rules')
|
||||
|
||||
#js-branch-rules
|
||||
#js-branch-rules{ data: { project_path: @project.full_path } }
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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. |
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(".")
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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"')
|
||||
|
|
|
@ -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) }
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue