diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml index 7459b428021..3a8a8677706 100644 --- a/.gitlab/ci/rules.gitlab-ci.yml +++ b/.gitlab/ci/rules.gitlab-ci.yml @@ -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 diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 2f000905d26..7144b774b05 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -13086e25394b65e4c17eca8484890f62bb2f0b92 +5caf724a8305ea04370dc49f0d9a7d5f3bc8dd4a diff --git a/app/assets/javascripts/content_editor/extensions/footnote_definition.js b/app/assets/javascripts/content_editor/extensions/footnote_definition.js index 6b1c0c9fd7d..bf752918934 100644 --- a/app/assets/javascripts/content_editor/extensions/footnote_definition.js +++ b/app/assets/javascripts/content_editor/extensions/footnote_definition.js @@ -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: { diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js index deb59fde323..87118074462 100644 --- a/app/assets/javascripts/content_editor/extensions/sourcemap.js +++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js @@ -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, diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js index 12b3f288e22..da10c684b0b 100644 --- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js @@ -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', diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js index 537a5867096..b4f941294de 100644 --- a/app/assets/javascripts/lib/gfm/index.js +++ b/app/assets/javascripts/lib/gfm/index.js @@ -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); }; diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index aecb38ea1aa..08d246a9a00 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -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 { + + + + +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, +}; + + + diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index 04fe50e5f09..8f688e6ba76 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -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'; diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index e13d9cf9df0..4caa253b85e 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -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) { diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index 7c9e2485056..225706265c3 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -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', diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue b/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue new file mode 100644 index 00000000000..6bbe0ab7d5f --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue @@ -0,0 +1,110 @@ + + diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue b/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue index 941da667a05..c2e7f4e9b1b 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue @@ -1,11 +1,38 @@ diff --git a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js index 716281c4a3e..8452542540e 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js +++ b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js @@ -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 } }); }, }); } diff --git a/app/assets/javascripts/projects/settings/branch_rules/queries/branches.query.graphql b/app/assets/javascripts/projects/settings/branch_rules/queries/branches.query.graphql new file mode 100644 index 00000000000..a532b544757 --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/queries/branches.query.graphql @@ -0,0 +1,8 @@ +query getBranches($projectPath: ID!, $searchPattern: String!) { + project(fullPath: $projectPath) { + id + repository { + branchNames(searchPattern: $searchPattern, limit: 100, offset: 0) + } + } +} diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb index 84e5d59a2c3..85e258b62e8 100644 --- a/app/controllers/projects/ci/pipeline_editor_controller.rb +++ b/app/controllers/projects/ci/pipeline_editor_controller.rb @@ -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 diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index ac9a9a376dd..adc3a912a91 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -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 diff --git a/app/graphql/mutations/work_items/update_widgets.rb b/app/graphql/mutations/work_items/update_widgets.rb new file mode 100644 index 00000000000..d19da0abaac --- /dev/null +++ b/app/graphql/mutations/work_items/update_widgets.rb @@ -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 diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 084b56c015e..7ce751b8f2a 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -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 diff --git a/app/graphql/types/work_items/widgets/description_input_type.rb b/app/graphql/types/work_items/widgets/description_input_type.rb new file mode 100644 index 00000000000..382cfdf659f --- /dev/null +++ b/app/graphql/types/work_items/widgets/description_input_type.rb @@ -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 diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb index da773e3e8a8..bb3f7b5aa79 100644 --- a/app/helpers/ci/pipeline_editor_helper.rb +++ b/app/helpers/ci/pipeline_editor_helper.rb @@ -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 diff --git a/app/models/work_items/widgets/base.rb b/app/models/work_items/widgets/base.rb index 9d1e48690e0..e7075a7a0e8 100644 --- a/app/models/work_items/widgets/base.rb +++ b/app/models/work_items/widgets/base.rb @@ -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 diff --git a/app/models/work_items/widgets/description.rb b/app/models/work_items/widgets/description.rb index 1e84d172bef..35b6d295321 100644 --- a/app/models/work_items/widgets/description.rb +++ b/app/models/work_items/widgets/description.rb @@ -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 diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb index 5c45f4d90e5..0b420881b4b 100644 --- a/app/services/work_items/update_service.rb +++ b/app/services/work_items/update_service.rb @@ -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 diff --git a/app/views/admin/application_settings/_abuse.html.haml b/app/views/admin/application_settings/_abuse.html.haml index 96fb848b568..fbadd26d0c0 100644 --- a/app/views/admin/application_settings/_abuse.html.haml +++ b/app/views/admin/application_settings/_abuse.html.haml @@ -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 diff --git a/app/views/admin/application_settings/_default_branch.html.haml b/app/views/admin/application_settings/_default_branch.html.haml index f5f45d7a6e9..4a06dcbc031 100644 --- a/app/views/admin/application_settings/_default_branch.html.haml +++ b/app/views/admin/application_settings/_default_branch.html.haml @@ -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 = "#{Gitlab::DefaultBranch.value}" diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml index 6a51d2e39d4..1af4d294c1b 100644 --- a/app/views/admin/application_settings/_diff_limits.html.haml +++ b/app/views/admin/application_settings/_diff_limits.html.haml @@ -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 diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml index 87353890aae..370d3cea07c 100644 --- a/app/views/admin/application_settings/_eks.html.haml +++ b/app/views/admin/application_settings/_eks.html.haml @@ -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 diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml index fd65d4029f5..774c5665edd 100644 --- a/app/views/admin/application_settings/_email.html.haml +++ b/app/views/admin/application_settings/_email.html.haml @@ -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 diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml index 8be52168fdb..4d0faf69958 100644 --- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml +++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml @@ -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 diff --git a/app/views/admin/application_settings/_gitaly.html.haml b/app/views/admin/application_settings/_gitaly.html.haml index ade6dac606a..cc2c6dbcb03 100644 --- a/app/views/admin/application_settings/_gitaly.html.haml +++ b/app/views/admin/application_settings/_gitaly.html.haml @@ -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 diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml index 21eb4caf579..08a4ebe5c71 100644 --- a/app/views/admin/application_settings/_help_page.html.haml +++ b/app/views/admin/application_settings/_help_page.html.haml @@ -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 diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml index a6ed48ef4fe..0477f114bdf 100644 --- a/app/views/admin/application_settings/_localization.html.haml +++ b/app/views/admin/application_settings/_localization.html.haml @@ -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 diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml index 5ef8a24ba39..23b0d2d2092 100644 --- a/app/views/admin/application_settings/_pages.html.haml +++ b/app/views/admin/application_settings/_pages.html.haml @@ -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 diff --git a/app/views/admin/application_settings/_realtime.html.haml b/app/views/admin/application_settings/_realtime.html.haml index 6a7ec05d206..66003f31104 100644 --- a/app/views/admin/application_settings/_realtime.html.haml +++ b/app/views/admin/application_settings/_realtime.html.haml @@ -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 diff --git a/app/views/admin/application_settings/_sidekiq_job_limits.html.haml b/app/views/admin/application_settings/_sidekiq_job_limits.html.haml index eaf4bbf4702..a28e6e62e7f 100644 --- a/app/views/admin/application_settings/_sidekiq_job_limits.html.haml +++ b/app/views/admin/application_settings/_sidekiq_job_limits.html.haml @@ -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 diff --git a/app/views/admin/application_settings/_whats_new.html.haml b/app/views/admin/application_settings/_whats_new.html.haml index b84e3f12e63..8ae912d24b7 100644 --- a/app/views/admin/application_settings/_whats_new.html.haml +++ b/app/views/admin/application_settings/_whats_new.html.haml @@ -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 diff --git a/app/views/projects/settings/branch_rules/index.html.haml b/app/views/projects/settings/branch_rules/index.html.haml index 13612f375c1..384d504e51f 100644 --- a/app/views/projects/settings/branch_rules/index.html.haml +++ b/app/views/projects/settings/branch_rules/index.html.haml @@ -3,4 +3,4 @@ %h3= _('Branch rules') -#js-branch-rules +#js-branch-rules{ data: { project_path: @project.full_path } } diff --git a/app/views/shared/labels/_nav.html.haml b/app/views/shared/labels/_nav.html.haml index 47e9d9b0e4a..622ad9db425 100644 --- a/app/views/shared/labels/_nav.html.haml +++ b/app/views/shared/labels/_nav.html.haml @@ -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') diff --git a/config/feature_flags/development/enable_vulnerability_remediations_from_records.yml b/config/feature_flags/development/enable_vulnerability_remediations_from_records.yml new file mode 100644 index 00000000000..c557ad751f2 --- /dev/null +++ b/config/feature_flags/development/enable_vulnerability_remediations_from_records.yml @@ -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 diff --git a/config/feature_flags/development/downstream_retry_action.yml b/config/feature_flags/development/simulate_pipeline.yml similarity index 74% rename from config/feature_flags/development/downstream_retry_action.yml rename to config/feature_flags/development/simulate_pipeline.yml index 7031c7565ce..3bc12d5b741 100644 --- a/config/feature_flags/development/downstream_retry_action.yml +++ b/config/feature_flags/development/simulate_pipeline.yml @@ -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 diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 109a738e6da..1da6beaee9a 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -5569,6 +5569,32 @@ Input type: `WorkItemUpdateTaskInput` | `task` | [`WorkItem`](#workitem) | Updated task. | | `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 | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `descriptionWidget` | [`WorkItemWidgetDescriptionInput`](#workitemwidgetdescriptioninput) | Input for description widget. | +| `id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| `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. | `id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. | | `stateEvent` | [`WorkItemStateEvent`](#workitemstateevent) | Close or reopen a work item. | | `title` | [`String`](#string) | Title of the work item. | + +### `WorkItemWidgetDescriptionInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `description` | [`String!`](#string) | Description of the work item. | diff --git a/doc/ci/pipelines/index.md b/doc/ci/pipelines/index.md index 20c51dd72fb..76419e61661 100644 --- a/doc/ci/pipelines/index.md +++ b/doc/ci/pipelines/index.md @@ -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. diff --git a/doc/development/fe_guide/frontend_faq.md b/doc/development/fe_guide/frontend_faq.md index 9c892cf89e2..39c39894dac 100644 --- a/doc/development/fe_guide/frontend_faq.md +++ b/doc/development/fe_guide/frontend_faq.md @@ -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: diff --git a/doc/development/pipelines.md b/doc/development/pipelines.md index ae837a00633..71e3b056f6b 100644 --- a/doc/development/pipelines.md +++ b/doc/development/pipelines.md @@ -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 diff --git a/lib/generators/gitlab/usage_metric_generator.rb b/lib/generators/gitlab/usage_metric_generator.rb index 6348d481d14..3624a6eb5a7 100644 --- a/lib/generators/gitlab/usage_metric_generator.rb +++ b/lib/generators/gitlab/usage_metric_generator.rb @@ -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 diff --git a/lib/gitlab/database/batch_count.rb b/lib/gitlab/database/batch_count.rb index 49f56b5be97..92a41bb36ee 100644 --- a/lib/gitlab/database/batch_count.rb +++ b/lib/gitlab/database/batch_count.rb @@ -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 diff --git a/lib/gitlab/database/batch_counter.rb b/lib/gitlab/database/batch_counter.rb index 417511618e4..522b598cd9d 100644 --- a/lib/gitlab/database/batch_counter.rb +++ b/lib/gitlab/database/batch_counter.rb @@ -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 diff --git a/lib/gitlab/usage/metrics/instrumentations/database_metric.rb b/lib/gitlab/usage/metrics/instrumentations/database_metric.rb index 48ff78cfd0f..3b09100f3ff 100644 --- a/lib/gitlab/usage/metrics/instrumentations/database_metric.rb +++ b/lib/gitlab/usage/metrics/instrumentations/database_metric.rb @@ -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 diff --git a/lib/gitlab/usage/metrics/name_suggestion.rb b/lib/gitlab/usage/metrics/name_suggestion.rb index 0728af9e2ca..238a7a51a20 100644 --- a/lib/gitlab/usage/metrics/name_suggestion.rb +++ b/lib/gitlab/usage/metrics/name_suggestion.rb @@ -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 diff --git a/lib/gitlab/usage/metrics/query.rb b/lib/gitlab/usage/metrics/query.rb index 91ffca4a92d..e071b422c16 100644 --- a/lib/gitlab/usage/metrics/query.rb +++ b/lib/gitlab/usage/metrics/query.rb @@ -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(".") diff --git a/lib/gitlab/utils/usage_data.rb b/lib/gitlab/utils/usage_data.rb index 633f4683b6b..4d1b234ae54 100644 --- a/lib/gitlab/utils/usage_data.rb +++ b/lib/gitlab/utils/usage_data.rb @@ -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. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 973bf879c29..9440a61c7a2 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index b818c40f91a..a83d4191f38 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -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 diff --git a/spec/fixtures/markdown/markdown_golden_master_examples.yml b/spec/fixtures/markdown/markdown_golden_master_examples.yml index 9b861516bfe..e1fd246b2d5 100644 --- a/spec/fixtures/markdown/markdown_golden_master_examples.yml +++ b/spec/fixtures/markdown/markdown_golden_master_examples.yml @@ -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: |-

A footnote reference tag looks like this: 1

diff --git a/spec/frontend/content_editor/extensions/footnote_definition_spec.js b/spec/frontend/content_editor/extensions/footnote_definition_spec.js new file mode 100644 index 00000000000..d3dbc56ae0e --- /dev/null +++ b/spec/frontend/content_editor/extensions/footnote_definition_spec.js @@ -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); + }); +}); diff --git a/spec/frontend/content_editor/remark_markdown_processing_spec.js b/spec/frontend/content_editor/remark_markdown_processing_spec.js index cbe809a0788..60dc540e192 100644 --- a/spec/frontend/content_editor/remark_markdown_processing_spec.js +++ b/spec/frontend/content_editor/remark_markdown_processing_spec.js @@ -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); diff --git a/spec/frontend/lib/gfm/index_spec.js b/spec/frontend/lib/gfm/index_spec.js index c9a480e9943..7aab0072364 100644 --- a/spec/frontend/lib/gfm/index_spec.js +++ b/spec/frontend/lib/gfm/index_spec.js @@ -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('This is bold text'); - await render({ - markdown: 'This is bold text', - 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', + }, + }), + ); + }); }); }); diff --git a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js index a822e05c111..3ecf6472544 100644 --- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -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(() => { diff --git a/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js b/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js new file mode 100644 index 00000000000..25972317593 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js @@ -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); + }); + }); +}); diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index 906f4b560f1..fd97c2dbe77 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -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); diff --git a/spec/frontend/projects/settings/branch_rules/branch_dropdown_spec.js b/spec/frontend/projects/settings/branch_rules/branch_dropdown_spec.js new file mode 100644 index 00000000000..5997c2a083c --- /dev/null +++ b/spec/frontend/projects/settings/branch_rules/branch_dropdown_spec.js @@ -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, + }); + }); +}); diff --git a/spec/frontend/projects/settings/branch_rules/rule_edit_spec.js b/spec/frontend/projects/settings/branch_rules/rule_edit_spec.js new file mode 100644 index 00000000000..66ae6ddc02d --- /dev/null +++ b/spec/frontend/projects/settings/branch_rules/rule_edit_spec.js @@ -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); + }); + }); +}); diff --git a/spec/graphql/mutations/work_items/update_widgets_spec.rb b/spec/graphql/mutations/work_items/update_widgets_spec.rb new file mode 100644 index 00000000000..2e54b81b5c7 --- /dev/null +++ b/spec/graphql/mutations/work_items/update_widgets_spec.rb @@ -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 diff --git a/spec/helpers/ci/pipeline_editor_helper_spec.rb b/spec/helpers/ci/pipeline_editor_helper_spec.rb index 429d4c7941a..8366506aa45 100644 --- a/spec/helpers/ci/pipeline_editor_helper_spec.rb +++ b/spec/helpers/ci/pipeline_editor_helper_spec.rb @@ -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 diff --git a/spec/lib/gitlab/database/batch_count_spec.rb b/spec/lib/gitlab/database/batch_count_spec.rb index 028bdce852e..811d4fad95c 100644 --- a/spec/lib/gitlab/database/batch_count_spec.rb +++ b/spec/lib/gitlab/database/batch_count_spec.rb @@ -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 diff --git a/spec/lib/gitlab/usage/metrics/name_suggestion_spec.rb b/spec/lib/gitlab/usage/metrics/name_suggestion_spec.rb index 6955fbcaf5a..ee32ec4bb21 100644 --- a/spec/lib/gitlab/usage/metrics/name_suggestion_spec.rb +++ b/spec/lib/gitlab/usage/metrics/name_suggestion_spec.rb @@ -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) } diff --git a/spec/lib/gitlab/usage/metrics/query_spec.rb b/spec/lib/gitlab/usage/metrics/query_spec.rb index 65b8a7a046b..355d619f768 100644 --- a/spec/lib/gitlab/usage/metrics/query_spec.rb +++ b/spec/lib/gitlab/usage/metrics/query_spec.rb @@ -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"') diff --git a/spec/lib/gitlab/utils/usage_data_spec.rb b/spec/lib/gitlab/utils/usage_data_spec.rb index a74a9f06c6f..25ba5a3e09e 100644 --- a/spec/lib/gitlab/utils/usage_data_spec.rb +++ b/spec/lib/gitlab/utils/usage_data_spec.rb @@ -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) } diff --git a/spec/requests/api/graphql/mutations/work_items/update_widgets_spec.rb b/spec/requests/api/graphql/mutations/work_items/update_widgets_spec.rb new file mode 100644 index 00000000000..595d8fe97ed --- /dev/null +++ b/spec/requests/api/graphql/mutations/work_items/update_widgets_spec.rb @@ -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 diff --git a/spec/services/work_items/update_service_spec.rb b/spec/services/work_items/update_service_spec.rb index b2d3f428899..9030326dadb 100644 --- a/spec/services/work_items/update_service_spec.rb +++ b/spec/services/work_items/update_service_spec.rb @@ -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