{{ content }}
+
+ #{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