diff options
39 files changed, 500 insertions, 153 deletions
diff --git a/.gitlab/ci/test-on-gdk/main.gitlab-ci.yml b/.gitlab/ci/test-on-gdk/main.gitlab-ci.yml index 9051532b0d1..a64dd450c82 100644 --- a/.gitlab/ci/test-on-gdk/main.gitlab-ci.yml +++ b/.gitlab/ci/test-on-gdk/main.gitlab-ci.yml @@ -160,9 +160,7 @@ gdk-qa-reliable: QA_RUN_TYPE: gdk-qa-blocking parallel: 10 rules: - - if: $CI_MERGE_REQUEST_LABELS =~ /devops::govern|devops::create|devops::verify|devops::manage|devops::data stores/ - - when: on_success - allow_failure: true + - when: always gdk-qa-reliable-with-load-balancer: extends: diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 05865dc7305..e68b10ae752 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -125,7 +125,6 @@ export default { <template> <li - data-qa-selector="board_card" :class="[ { 'multi-select gl-bg-blue-50 gl-border-blue-200': multiSelectVisible, @@ -141,7 +140,7 @@ export default { :data-item-iid="item.iid" :data-item-path="item.referencePath" :style="cardStyle" - data-testid="board_card" + data-testid="board-card" class="board-card gl-p-5 gl-rounded-base gl-line-height-normal gl-relative gl-mb-3" @click="toggleIssue($event)" > diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index bcd7db8dcb4..67a4c5eba45 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -93,7 +93,7 @@ export default { }" :data-list-id="list.id" class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal is-expandable" - data-qa-selector="board_list" + data-testid="board-list" > <div class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-gray-50" diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 3c2659b00c9..554f3bfa416 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -219,7 +219,7 @@ export default { <template> <div v-cloak - data-qa-selector="boards_list" + data-testid="boards-list" class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-min-h-0" > <gl-alert v-if="errorToDisplay" variant="danger" :dismissible="true" @dismiss="dismissError"> diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 6ea94e04e44..a3d55ac8306 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -140,7 +140,7 @@ export default { variant: this.buttonKind, disabled: this.submitDisabled, loading: this.isLoading, - 'data-qa-selector': 'save_changes_button', + 'data-testid': 'save-changes-button', }, }; }, @@ -324,7 +324,7 @@ export default { ref="name" v-model="board.name" class="form-control" - data-qa-selector="board_name_field" + data-testid="board-name-field" type="text" :placeholder="$options.i18n.titleFieldPlaceholder" @keyup.enter="submit" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 1bb7e88122a..2693a6bb5ea 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -653,7 +653,7 @@ export default { <div v-show="!list.collapsed" class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column gl-min-h-0" - data-qa-selector="board_list_cards_area" + data-testid="board-list-cards-area" > <div v-if="loading" diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 3dd7f7b70f5..0235edd69ac 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -365,7 +365,6 @@ export default { }" :style="headerStyle" class="board-header gl-relative" - data-qa-selector="board_list_header" data-testid="board-list-header" > <h3 diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 7bc4c89699c..cd2a4a02b2e 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -289,7 +289,6 @@ export default { v-if="showDropdown" block data-testid="boards-dropdown" - data-qa-selector="boards_dropdown" searchable :searching="loading" toggle-class="gl-min-w-20" @@ -322,7 +321,7 @@ export default { block class="gl-justify-content-start!" category="tertiary" - data-qa-selector="create_new_board_button" + data-testid="create-new-board-button" data-track-action="click_button" data-track-label="create_new_board" data-track-property="dropdown" diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue index bc896932ffc..69e6cc870d2 100644 --- a/app/assets/javascripts/boards/components/config_toggle.vue +++ b/app/assets/javascripts/boards/components/config_toggle.vue @@ -49,7 +49,7 @@ export default { v-gl-tooltip :title="tooltipTitle" :class="{ 'dot-highlight': hasScope || boardHasScope }" - data-qa-selector="boards_config_button" + data-testid="boards-config-button" @click.prevent="showPage" > {{ buttonText }} diff --git a/app/assets/javascripts/boards/components/toggle_focus.vue b/app/assets/javascripts/boards/components/toggle_focus.vue index 990a6fa63d4..a886abf9e61 100644 --- a/app/assets/javascripts/boards/components/toggle_focus.vue +++ b/app/assets/javascripts/boards/components/toggle_focus.vue @@ -38,7 +38,7 @@ export default { v-gl-tooltip category="tertiary" :icon="isFullscreen ? 'minimize' : 'maximize'" - data-qa-selector="focus_mode_button" + data-testid="focus-mode-button" :title="$options.i18n.toggleFocusMode" :aria-label="$options.i18n.toggleFocusMode" @click="toggleFocusMode" diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue index 154a8e866d0..377200ab804 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue @@ -71,8 +71,7 @@ export default { size="small" class="dropdown-header-button gl-p-0!" icon="close" - data-testid="close-button" - data-qa-selector="close_labels_dropdown_button" + data-testid="close-labels-dropdown-button" @click="$emit('closeDropdown')" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue index b34a6b11092..1f5896204ee 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue @@ -144,7 +144,7 @@ export default { </slot> </template> <slot name="default"> - <gl-dropdown-form class="gl-relative gl-min-h-7" data-qa-selector="labels_dropdown_content"> + <gl-dropdown-form class="gl-relative gl-min-h-7" data-testid="labels-dropdown-content"> <gl-loading-icon v-if="isLoading" size="lg" diff --git a/app/models/group.rb b/app/models/group.rb index 587451ac195..bc6125887d4 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -663,12 +663,6 @@ class Group < Namespace .non_invite end - def users_with_parents - User - .where(id: members_with_parents.select(:user_id)) - .reorder(nil) - end - def users_with_descendants User .where(id: members_with_descendants.select(:user_id)) diff --git a/app/models/work_items/widgets/hierarchy.rb b/app/models/work_items/widgets/hierarchy.rb index b5d8a2ced45..9d90a56d59e 100644 --- a/app/models/work_items/widgets/hierarchy.rb +++ b/app/models/work_items/widgets/hierarchy.rb @@ -12,17 +12,19 @@ module WorkItems end def self.quick_action_commands - [:set_parent] + [:set_parent, :add_child] end def self.quick_action_params - [:set_parent] + [:set_parent, :add_child] end def self.process_quick_action_param(param_name, value) - return super unless param_name == :set_parent && value + return super unless param_name.in?(quick_action_params) && value.present? - { parent: value } + return { parent: value } if param_name == :set_parent + + return { children: value } if param_name == :add_child end end end diff --git a/config/feature_flags/development/fetch_commits_for_bitbucket_server.yml b/config/feature_flags/development/fetch_commits_for_bitbucket_server.yml new file mode 100644 index 00000000000..5524b0bf4d7 --- /dev/null +++ b/config/feature_flags/development/fetch_commits_for_bitbucket_server.yml @@ -0,0 +1,8 @@ +--- +name: fetch_commits_for_bitbucket_server +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/133606 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/427699 +milestone: '16.5' +type: development +group: group::import and integrate +default_enabled: false diff --git a/config/metrics/counts_28d/20230927152527_i_quickactions_add_child_monthly.yml b/config/metrics/counts_28d/20230927152527_i_quickactions_add_child_monthly.yml new file mode 100644 index 00000000000..bef39963b56 --- /dev/null +++ b/config/metrics/counts_28d/20230927152527_i_quickactions_add_child_monthly.yml @@ -0,0 +1,26 @@ +--- +key_path: redis_hll_counters.quickactions.i_quickactions_add_child_monthly +name: quickactions_add_child_monthly +description: Count of MAU using the `/add_child` quick action +product_section: dev +product_stage: plan +product_group: product_planning +value_type: number +status: active +milestone: "16.5" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132761 +time_frame: 28d +data_source: redis_hll +data_category: optional +instrumentation_class: RedisHLLMetric +options: + events: + - i_quickactions_add_child +performance_indicator_type: [] +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/config/metrics/counts_7d/20230922165305_i_quickactions_set_parent_monthly.yml b/config/metrics/counts_7d/20230922165305_i_quickactions_set_parent_monthly.yml index 56dfb05114c..9af8f83ce79 100644 --- a/config/metrics/counts_7d/20230922165305_i_quickactions_set_parent_monthly.yml +++ b/config/metrics/counts_7d/20230922165305_i_quickactions_set_parent_monthly.yml @@ -1,7 +1,7 @@ --- key_path: redis_hll_counters.quickactions.i_quickactions_set_parent_monthly name: quickactions_set_parent_monthly -description: Count of WAU using the `/set_parent` quick action +description: Count of MAU using the `/set_parent` quick action product_section: dev product_stage: plan product_group: product_planning diff --git a/config/metrics/counts_7d/20230927152525_i_quickactions_add_child_weekly.yml b/config/metrics/counts_7d/20230927152525_i_quickactions_add_child_weekly.yml new file mode 100644 index 00000000000..7476a452d8f --- /dev/null +++ b/config/metrics/counts_7d/20230927152525_i_quickactions_add_child_weekly.yml @@ -0,0 +1,26 @@ +--- +key_path: redis_hll_counters.quickactions.i_quickactions_add_child_weekly +name: quickactions_add_child_weekly +description: Count of WAU using the `/add_child` quick action +product_section: dev +product_stage: plan +product_group: product_planning +value_type: number +status: active +milestone: "16.5" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132761 +time_frame: 7d +data_source: redis_hll +data_category: optional +instrumentation_class: RedisHLLMetric +options: + events: + - i_quickactions_add_child +performance_indicator_type: [] +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/data/deprecations/16-4-deprecate-newly-detected-field.yml b/data/deprecations/16-4-deprecate-newly-detected-field.yml new file mode 100644 index 00000000000..499bfd2147d --- /dev/null +++ b/data/deprecations/16-4-deprecate-newly-detected-field.yml @@ -0,0 +1,10 @@ +- title: "Security policy field `newly_detected` is deprecated" # (required) Clearly explain the change, or planned change. For example, "The `confidential` field for a `Note` is deprecated" or "CI/CD job names will be limited to 250 characters." + removal_milestone: "17.0" # (required) The milestone when this feature is planned to be removed + announcement_milestone: "16.5" # (required) The milestone when this feature was first announced as deprecated. + breaking_change: true # (required) Change to false if this is not a breaking change. + reporter: g.hickman # (required) GitLab username of the person reporting the change + stage: govern # (required) String value of the stage that the feature was created in. e.g., Growth + issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/422414 # (required) Link to the deprecation issue in GitLab + body: | # (required) Do not modify this line, instead modify the lines below. + In [Support additional filters for scan result policies](https://gitlab.com/groups/gitlab-org/-/epics/6826#note_1341377224), we broke the `newly_detected` field into two options: `new_needs_triage` and `new_dismissed`. By including both options in the security policy YAML, you will achieve the same result as the original `newly_detected` field. However, you may now narrow your filter to ignore findings that have been dismissed by only using `new_needs_triage`. + documentation_url: https://docs.gitlab.com/ee/user/application_security/policies/scan-result-policies.html#scan_finding-rule-type # (optional) This is a link to the current documentation page diff --git a/doc/update/deprecations.md b/doc/update/deprecations.md index 4660b96bd53..3bb7f9816b4 100644 --- a/doc/update/deprecations.md +++ b/doc/update/deprecations.md @@ -844,6 +844,20 @@ Before upgrading to GitLab 17.0, please ensure you have [migrated](https://docs. <div class="deprecation breaking-change" data-milestone="17.0"> +### Security policy field `newly_detected` is deprecated + +<div class="deprecation-notes"> +- Announced in GitLab <span class="milestone">16.5</span> +- Removal in GitLab <span class="milestone">17.0</span> ([breaking change](https://docs.gitlab.com/ee/update/terminology.html#breaking-change)) +- To discuss this change or learn more, see the [deprecation issue](https://gitlab.com/gitlab-org/gitlab/-/issues/422414). +</div> + +In [Support additional filters for scan result policies](https://gitlab.com/groups/gitlab-org/-/epics/6826#note_1341377224), we broke the `newly_detected` field into two options: `new_needs_triage` and `new_dismissed`. By including both options in the security policy YAML, you will achieve the same result as the original `newly_detected` field. However, you may now narrow your filter to ignore findings that have been dismissed by only using `new_needs_triage`. + +</div> + +<div class="deprecation breaking-change" data-milestone="17.0"> + ### Self-managed certificate-based integration with Kubernetes <div class="deprecation-notes"> diff --git a/doc/user/analytics/value_streams_dashboard.md b/doc/user/analytics/value_streams_dashboard.md index ed637dd886f..a0103663181 100644 --- a/doc/user/analytics/value_streams_dashboard.md +++ b/doc/user/analytics/value_streams_dashboard.md @@ -119,8 +119,6 @@ To view the value streams dashboard: You can customize the Value Streams Dashboard and configure what subgroups and projects to include in the page. -A view can display maximum four subgroups or projects. - ### Using query parameters To display multiple subgroups and projects, specify their path as a URL parameter. diff --git a/doc/user/application_security/dependency_scanning/index.md b/doc/user/application_security/dependency_scanning/index.md index f919f584c82..c04134de2b2 100644 --- a/doc/user/application_security/dependency_scanning/index.md +++ b/doc/user/application_security/dependency_scanning/index.md @@ -230,8 +230,7 @@ table.supported-languages ul { <li> <a id="notes-regarding-supported-languages-and-package-managers-2"></a> <p> - Java 21 LTS is only available when using <a href="https://maven.apache.org/">Maven</a> and is not supported when - <a href="https://docs.gitlab.com/ee/development/fips_compliance.html#enable-fips-mode">FIPS mode</a> is enabled. + Java 21 LTS is only available when using <a href="https://maven.apache.org/">Maven</a> or <a href="https://gradle.org/">Gradle</a>. Java 21 LTS for <a href="https://www.scala-sbt.org/">sbt</a> is not yet available and tracked in <a href="https://gitlab.com/gitlab-org/gitlab/-/issues/421174">issue 421174</a>. It is not supported when <a href="https://docs.gitlab.com/ee/development/fips_compliance.html#enable-fips-mode">FIPS mode</a> is enabled. </p> </li> <li> diff --git a/doc/user/application_security/sast/rules.md b/doc/user/application_security/sast/rules.md index 4e7a6387f9b..e4054764e1f 100644 --- a/doc/user/application_security/sast/rules.md +++ b/doc/user/application_security/sast/rules.md @@ -38,6 +38,18 @@ Analyzers and their rules are updated [at least monthly](../index.md#vulnerabili The GitLab ruleset for the Semgrep-based analyzer is managed in [the GitLab-managed open-source `sast-rules` project](https://gitlab.com/gitlab-org/security-products/sast-rules). When rules are updated, they're released as part of the [Semgrep-based analyzer](https://gitlab.com/gitlab-org/security-products/analyzers/semgrep)'s container image. +### Rule update policies + +Updates to SAST rules are not [breaking changes](../../../update/terminology.md#breaking-change). +This means that rules may be added, removed, or updated without prior notice. + +However, to make rule changes more convenient and understandable, GitLab: + +- Documents [rule changes](#important-rule-changes) that are planned or completed. +- [Automatically resolves](index.md#automatic-vulnerability-resolution) findings from rules after they are removed for Semgrep-based analyzers. +- Enables you to [change the status on vulnerabilities where activity = "no longer detected" in bulk](../vulnerability_report/index.md#change-status-of-vulnerabilities). +- Evaluates proposed rule changes for the impact they will have on existing vulnerability records. + ## Configure rules in your projects You should use the default SAST rules unless you have a specific reason to make a change. diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md index 95f4f8b1d05..16967a3a46e 100644 --- a/doc/user/project/quick_actions.md +++ b/doc/user/project/quick_actions.md @@ -146,6 +146,7 @@ To auto-format this table, use the VS Code Markdown Table formatter: `https://do |:--------------------------------------------------------------|:-----------------------|:-----------------------|:-----------------------|:-------| | `/assign @user1 @user2` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Assign one or more users. | | `/assign me` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Assign yourself. | +| `/add_child <work_item>` | **{dotted-circle}** No | **{check-circle}** Yes | **{dotted-circle}** No | Add child to `<work_item>`. The `<work_item>` value should be in the format of `#iid`, `group/project#iid`, or a URL to a work item. Multiple work items can be added as children at the same time. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/420797) in GitLab 16.5. | | `/award :emoji:` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Toggle an emoji reaction. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/412275) in GitLab 16.5 | | `/cc @user` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Mention a user. In GitLab 15.0 and later, this command performs no action. You can instead type `CC @user` or only `@user`. [In GitLab 14.9 and earlier](https://gitlab.com/gitlab-org/gitlab/-/issues/31200), mentioning a user at the start of a line creates a specific type of to-do item notification. | | `/checkin_reminder <cadence>` | **{dotted-circle}** No| **{check-circle}** Yes | **{dotted-circle}** No | Schedule [check-in reminders](../okrs.md#schedule-okr-check-in-reminders). Options are `weekly`, `twice-monthly`, `monthly`, or `never` (default). [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/422761) in GitLab 16.4 with flags named `okrs_mvc` and `okr_checkin_reminders`. | diff --git a/lib/bitbucket_server/representation/pull_request.rb b/lib/bitbucket_server/representation/pull_request.rb index 66dba5fefc7..996a10318f5 100644 --- a/lib/bitbucket_server/representation/pull_request.rb +++ b/lib/bitbucket_server/representation/pull_request.rb @@ -44,6 +44,10 @@ module BitbucketServer state == 'merged' end + def closed? + state == 'closed' + end + def created_at self.class.convert_timestamp(created_date) end diff --git a/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer.rb b/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer.rb index 92ec10bf037..ae73681f7f8 100644 --- a/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer.rb +++ b/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer.rb @@ -20,6 +20,22 @@ module Gitlab break if pull_requests.empty? + commits_to_fetch = pull_requests.filter_map do |pull_request| + next if already_processed?(pull_request) + next unless pull_request.merged? || pull_request.closed? + + [pull_request.source_branch_sha, pull_request.target_branch_sha] + end.flatten + + # Bitbucket Server keeps tracks of references for open pull requests in + # refs/heads/pull-requests, but closed and merged requests get moved + # into hidden internal refs under stash-refs/pull-requests. As a result, + # they are not fetched by default. + # + # This method call explicitly fetches head and start commits for affected pull requests. + # That allows us to correctly assign diffs and commits to merge requests. + fetch_missing_commits(commits_to_fetch) + pull_requests.each do |pull_request| # Needs to come before `already_processed?` as `jobs_remaining` resets to zero when the job restarts and # jobs_remaining needs to be the total amount of enqueued jobs @@ -42,6 +58,15 @@ module Gitlab private + def fetch_missing_commits(commits_to_fetch) + return if commits_to_fetch.blank? + return unless Feature.enabled?(:fetch_commits_for_bitbucket_server, project.group) + + project.repository.fetch_remote(project.import_url, refmap: commits_to_fetch, prune: false) + rescue StandardError => e + track_import_failure!(project, exception: e) + end + def sidekiq_worker_class ImportPullRequestWorker end diff --git a/lib/gitlab/quick_actions/work_item_actions.rb b/lib/gitlab/quick_actions/work_item_actions.rb index e302b832505..2adee0f9a9a 100644 --- a/lib/gitlab/quick_actions/work_item_actions.rb +++ b/lib/gitlab/quick_actions/work_item_actions.rb @@ -36,9 +36,21 @@ module Gitlab params 'Parent #iid, reference or URL' condition { supports_parent? && can_admin_link? } command :set_parent do |parent_param| - @updates[:set_parent] = extract_work_item(parent_param) + @updates[:set_parent] = extract_work_items(parent_param).first @execution_message[:set_parent] = success_msg[:set_parent] end + + desc { _('Add children to work item') } + explanation do |child_param| + format(_("Add %{child_ref} to this work item as child(ren)."), child_ref: child_param) + end + types WorkItem + params 'Children #iids, references or URLs' + condition { supports_children? && can_admin_link? } + command :add_child do |child_param| + @updates[:add_child] = extract_work_items(child_param) + @execution_message[:add_child] = success_msg[:add_child] + end end private @@ -64,15 +76,17 @@ module Gitlab nil end - def extract_work_item(params) + # rubocop: disable CodeReuse/ActiveRecord + def extract_work_items(params) return if params.nil? issuable_type = params.include?('work_items') ? :work_item : :issue - issuable = extract_references(params, issuable_type).first - return unless issuable + issuables = extract_references(params, issuable_type) + return unless issuables - WorkItem.find(issuable.id) + WorkItem.find(issuables.pluck(:id)) end + # rubocop: enable CodeReuse/ActiveRecord def validate_promote_to(type) return error_msg(:not_found, action: 'promote') unless type && supports_promote_to?(type.name) @@ -111,7 +125,8 @@ module Gitlab { type: _('Type changed successfully.'), promote_to: _("Work item promoted successfully."), - set_parent: _('Work item parent set successfully') + set_parent: _('Work item parent set successfully'), + add_child: _('Child work item(s) added successfully') } end @@ -119,6 +134,10 @@ module Gitlab ::WorkItems::HierarchyRestriction.find_by_child_type_id(quick_action_target.work_item_type_id).present? end + def supports_children? + ::WorkItems::HierarchyRestriction.find_by_parent_type_id(quick_action_target.work_item_type_id).present? + end + def can_admin_link? current_user.can?(:admin_issue_link, quick_action_target) end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 46033498331..822110ae218 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2737,6 +2737,9 @@ msgstr "" msgid "Add \"%{value}\"" msgstr "" +msgid "Add %{child_ref} to this work item as child(ren)." +msgstr "" + msgid "Add %{linkStart}assets%{linkEnd} to your Release. GitLab automatically includes read-only assets, like source code and release evidence." msgstr "" @@ -2866,6 +2869,9 @@ msgstr "" msgid "Add child epic to an epic" msgstr "" +msgid "Add children to work item" +msgstr "" + msgid "Add comment now" msgstr "" @@ -10047,6 +10053,9 @@ msgstr "" msgid "Child issues and epics" msgstr "" +msgid "Child work item(s) added successfully" +msgstr "" + msgid "Chinese language support using" msgstr "" diff --git a/qa/qa/page/component/issue_board/show.rb b/qa/qa/page/component/issue_board/show.rb index 41bb33ed943..6fbe2b7036c 100644 --- a/qa/qa/page/component/issue_board/show.rb +++ b/qa/qa/page/component/issue_board/show.rb @@ -6,104 +6,112 @@ module QA module IssueBoard class Show < QA::Page::Base view 'app/assets/javascripts/boards/components/board_card.vue' do - element :board_card + element 'board-card' + end + + view 'app/assets/javascripts/boards/components/board_column.vue' do + element 'board-list' end view 'app/assets/javascripts/boards/components/board_form.vue' do - element :board_name_field - element :save_changes_button + element 'board-name-field' + element 'save-changes-button' end view 'app/assets/javascripts/boards/components/board_list.vue' do - element :board_list_cards_area + element 'board-list-cards-area' + end + + view 'app/assets/javascripts/boards/components/board_list_header.vue' do + element 'board-list-header' end view 'app/assets/javascripts/boards/components/boards_selector.vue' do - element :boards_dropdown - element :create_new_board_button + element 'boards-dropdown' + element 'create-new-board-button' end view 'app/assets/javascripts/boards/components/board_content.vue' do - element :boards_list + element 'boards-list' end view 'app/assets/javascripts/boards/components/toggle_focus.vue' do - element :focus_mode_button + element 'focus-mode-button' end view 'app/assets/javascripts/boards/components/config_toggle.vue' do - element :boards_config_button + element 'boards-config-button' end # The `focused_board` method does not use `find_element` with an element defined - # with the attribute `data-qa-selector` since such element is not unique when the + # with the attribute `data-testid` since such element is not unique when the # `is-focused` class is not set, and it was not possible to find a better solution. def focused_board find('.issue-boards-content.js-focus-mode-board.is-focused') end def boards_dropdown - find_element(:boards_dropdown) + find_element('boards-dropdown') end def boards_list_cards_area_with_index(index) wait_boards_list_finish_loading do - within_element_by_index(:board_list, index) do - find_element(:board_list_cards_area) + within_element_by_index('board-list', index) do + find_element('board-list-cards-area') end end end def boards_list_header_with_index(index) wait_boards_list_finish_loading do - within_element_by_index(:board_list, index) do - find_element(:board_list_header) + within_element_by_index('board-list', index) do + find_element('board-list-header') end end end def card_of_list_with_index(index) wait_boards_list_finish_loading do - within_element_by_index(:board_list, index) do - find_element(:board_card) + within_element_by_index('board-list', index) do + find_element('board-card') end end end def click_boards_config_button - click_element(:boards_config_button) + click_element('boards-config-button') wait_for_requests end def click_boards_dropdown_button # The dropdown button comes from the `GlDropdown` component of `@gitlab/ui`, # so it wasn't possible to add a `data-qa-selector` to it. - find_element(:boards_dropdown).find('button').click + find_element('boards-dropdown').find('button').click end def click_focus_mode_button - click_element(:focus_mode_button) + click_element('focus-mode-button') end def create_new_board(board_name) click_boards_dropdown_button - click_element(:create_new_board_button) + click_element('create-new-board-button') set_name(board_name) end def has_modal_board_name_field? - has_element?(:board_name_field, wait: 1) + has_element?('board-name-field', wait: 1) end def set_name(name) - find_element(:board_name_field).set(name) - click_element(:save_changes_button) + find_element('board-name-field').set(name) + click_element('save-changes-button') end private def wait_boards_list_finish_loading - within_element(:boards_list) do + within_element('boards-list') do wait_until(reload: false, max_duration: 5, sleep_interval: 1) do finished_loading? && (block_given? ? yield : true) end diff --git a/qa/qa/specs/features/api/1_manage/import/import_large_github_repo_spec.rb b/qa/qa/specs/features/api/1_manage/import/import_large_github_repo_spec.rb index 416162c806c..02b3d4cf32b 100644 --- a/qa/qa/specs/features/api/1_manage/import/import_large_github_repo_spec.rb +++ b/qa/qa/specs/features/api/1_manage/import/import_large_github_repo_spec.rb @@ -12,13 +12,18 @@ module QA tags: { import_type: ENV["QA_IMPORT_TYPE"], import_repo: ENV["QA_LARGE_IMPORT_REPO"] || "rspec/rspec-core" } } do describe 'Project import', product_group: :import_and_integrate do # rubocop:disable RSpec/MultipleMemoizedHelpers + # Full object comparison is a fairly heavy operation + # Importer itself returns counts of objects it fetched and counts it imported + # We can use that for a lightweight comparison for very large projects + let(:only_stats_comparison) { ENV["QA_LARGE_IMPORT_GH_ONLY_STATS_COMPARISON"] == "true" } let(:github_repo) { ENV['QA_LARGE_IMPORT_REPO'] || 'rspec/rspec-core' } let(:import_max_duration) { ENV['QA_LARGE_IMPORT_DURATION']&.to_i || 7200 } let(:api_parallel_threads) { ENV['QA_LARGE_IMPORT_API_PARALLEL']&.to_i || Etc.nprocessors } + let(:logger) { Runtime::Logger.logger } let(:differ) { RSpec::Support::Differ.new(color: true) } let(:gitlab_address) { QA::Runtime::Scenario.gitlab_address.chomp("/") } - let(:dummy_url) { "https://example.com" } + let(:dummy_url) { "https://example.com" } # this is used to replace all dynamic urls in descriptions and comments let(:api_request_params) { { auto_paginate: true, attempts: 2 } } let(:created_by_pattern) { /\*Created by: \S+\*\n\n/ } @@ -206,95 +211,88 @@ module QA after do |example| unless defined?(@import_time) - next save_json( - "data", - { - status: "failed", - importer: :github, - import_finished: false, - import_time: Time.now - @start, - source: { - name: "GitHub", - project_name: github_repo, - address: "https://github.com" - }, - target: { - name: "GitLab", - address: gitlab_address - } - } - ) + next save_data_json(test_result_data({ + status: "failed", + importer: :github, + import_finished: false, + import_time: Time.now - @start + })) end # add additional import time metric example.metadata[:custom_test_metrics][:fields] = { import_time: @import_time } # save data for comparison notification creation - save_json( - "data", - { + if only_stats_comparison + next save_data_json(test_result_data({ status: example.exception ? "failed" : "passed", - importer: :github, import_time: @import_time, import_finished: true, errors: imported_project.project_import_status[:failed_relations], - reported_stats: @stats, - source: { - name: "GitHub", - project_name: github_repo, - address: "https://github.com", - data: { - branches: gh_branches.length, - commits: gh_commits.length, - labels: gh_labels.length, - milestones: gh_milestones.length, - mrs: gh_prs.length, - mr_comments: gh_prs.sum { |_k, v| v[:comments].length }, - mr_events: gh_prs.sum { |_k, v| v[:events].length }, - issues: gh_issues.length, - issue_comments: gh_issues.sum { |_k, v| v[:comments].length }, - issue_events: gh_issues.sum { |_k, v| v[:events].length } - } - }, - target: { - name: "GitLab", - project_name: imported_project.path_with_namespace, - address: gitlab_address, - data: { - branches: gl_branches.length, - commits: gl_commits.length, - labels: gl_labels.length, - milestones: gl_milestones.length, - mrs: mrs.length, - mr_comments: mrs.sum { |_k, v| v[:comments].length }, - mr_events: mrs.sum { |_k, v| v[:events].length }, - issues: gl_issues.length, - issue_comments: gl_issues.sum { |_k, v| v[:comments].length }, - issue_events: gl_issues.sum { |_k, v| v[:events].length } - } - }, - not_imported: { - mrs: @mr_diff, - issues: @issue_diff + reported_stats: @stats + })) + end + + save_data_json(test_result_data({ + status: example.exception ? "failed" : "passed", + import_time: @import_time, + import_finished: true, + errors: imported_project.project_import_status[:failed_relations], + reported_stats: @stats, + source: { + data: { + branches: gh_branches.length, + commits: gh_commits.length, + labels: gh_labels.length, + milestones: gh_milestones.length, + mrs: gh_prs.length, + mr_comments: gh_prs.sum { |_k, v| v[:comments].length }, + mr_events: gh_prs.sum { |_k, v| v[:events].length }, + issues: gh_issues.length, + issue_comments: gh_issues.sum { |_k, v| v[:comments].length }, + issue_events: gh_issues.sum { |_k, v| v[:events].length } + } + }, + target: { + project_name: imported_project.path_with_namespace, + data: { + branches: gl_branches.length, + commits: gl_commits.length, + labels: gl_labels.length, + milestones: gl_milestones.length, + mrs: mrs.length, + mr_comments: mrs.sum { |_k, v| v[:comments].length }, + mr_events: mrs.sum { |_k, v| v[:events].length }, + issues: gl_issues.length, + issue_comments: gl_issues.sum { |_k, v| v[:comments].length }, + issue_events: gl_issues.sum { |_k, v| v[:events].length } } + }, + not_imported: { + mrs: @mr_diff, + issues: @issue_diff } - ) + })) end it( 'imports large Github repo via api', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347668' ) do + if only_stats_comparison + logger.warn("Test is running in lightweight comparison mode, only object counts will be compared!") + end + @start = Time.now # trigger import and log project paths logger.info("== Triggering import of project '#{github_repo}' in to '#{imported_project.reload!.full_path}' ==") # fetch all objects right after import has started - fetch_github_objects + fetch_github_objects unless only_stats_comparison import_status = -> { imported_project.project_import_status.yield_self do |status| - @stats = status.dig(:stats, :imported) + @stats = status[:stats]&.slice(:fetched, :imported) # fail fast if import explicitly failed raise "Import of '#{imported_project.full_path}' failed!" if status[:import_status] == 'failed' @@ -308,15 +306,38 @@ module QA @import_time = Time.now - @start - aggregate_failures do - verify_repository_import - verify_labels_import - verify_milestones_import - verify_merge_requests_import - verify_issues_import + if only_stats_comparison + expect(@stats[:fetched]).to eq(@stats[:imported]) + else + aggregate_failures do + verify_repository_import + verify_labels_import + verify_milestones_import + verify_merge_requests_import + verify_issues_import + end end end + # Base test result data used for test result reporting + # + # @param [Hash] additional_data + # @return [Hash] + def test_result_data(additional_data = {}) + { + importer: :github, + source: { + name: "GitHub", + project_name: github_repo, + address: "https://github.com" + }, + target: { + name: "GitLab", + address: gitlab_address + } + }.deep_merge(additional_data) + end + # Persist all objects from repository being imported # # @return [void] @@ -634,11 +655,10 @@ module QA # Save json as file # - # @param [String] name # @param [Hash] json # @return [void] - def save_json(name, json) - File.open("tmp/#{name}.json", "w") { |file| file.write(JSON.pretty_generate(json)) } + def save_data_json(json) + File.open("tmp/github-import-data.json", "w") { |file| file.write(JSON.pretty_generate(json)) } end # Extract id number from web url of issue or pull request diff --git a/qa/qa/specs/features/api/1_manage/migration/gitlab_migration_large_project_spec.rb b/qa/qa/specs/features/api/1_manage/migration/gitlab_migration_large_project_spec.rb index 7d7cef5b4fc..9032d07d1cd 100644 --- a/qa/qa/specs/features/api/1_manage/migration/gitlab_migration_large_project_spec.rb +++ b/qa/qa/specs/features/api/1_manage/migration/gitlab_migration_large_project_spec.rb @@ -78,7 +78,6 @@ module QA after do |example| unless defined?(@import_time) next save_json( - "data", { status: "failed", importer: :gitlab, @@ -101,7 +100,6 @@ module QA example.metadata[:custom_test_metrics][:fields] = { import_time: @import_time } # save data for comparison notification creation save_json( - "data", { status: example.exception ? "failed" : "passed", importer: :gitlab, @@ -429,11 +427,10 @@ module QA # Save json as file # - # @param [String] name # @param [Hash] json # @return [void] - def save_json(name, json) - File.open("tmp/#{name}.json", "w") { |file| file.write(JSON.pretty_generate(json)) } + def save_json(json) + File.open("tmp/gitlab-import-data.json", "w") { |file| file.write(JSON.pretty_generate(json)) } end end end diff --git a/scripts/internal_events/monitor.rb b/scripts/internal_events/monitor.rb index 55811be999b..c7a261f62c3 100644 --- a/scripts/internal_events/monitor.rb +++ b/scripts/internal_events/monitor.rb @@ -153,6 +153,8 @@ begin sleep 1 end +rescue Interrupt + # Quietly shut down ensure print "\e[?1049l" # Restores the original screen buffer print "\e[H" # Moves the cursor home diff --git a/spec/features/boards/sidebar_labels_in_namespaces_spec.rb b/spec/features/boards/sidebar_labels_in_namespaces_spec.rb index ffed4a0854f..68c2b2587e7 100644 --- a/spec/features/boards/sidebar_labels_in_namespaces_spec.rb +++ b/spec/features/boards/sidebar_labels_in_namespaces_spec.rb @@ -7,7 +7,7 @@ RSpec.describe 'Issue boards sidebar labels select', :js, feature_category: :tea include_context 'labels from nested groups and projects' - let(:card) { find('.board:nth-child(1)').first('[data-testid="board_card"]') } + let(:card) { find('.board:nth-child(1)').first('[data-testid="board-card"]') } context 'group boards' do context 'in the top-level group board' do diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index 358da1e1279..71cc9a28575 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -28,7 +28,7 @@ RSpec.describe 'Project issue boards sidebar', :js, feature_category: :team_plan it_behaves_like 'issue boards sidebar' def first_card - find('.board:nth-child(1)').first("[data-testid='board_card']") + find('.board:nth-child(1)').first("[data-testid='board-card']") end def click_first_issue_card diff --git a/spec/features/boards/user_visits_board_spec.rb b/spec/features/boards/user_visits_board_spec.rb index 5867ec17070..4741f58d883 100644 --- a/spec/features/boards/user_visits_board_spec.rb +++ b/spec/features/boards/user_visits_board_spec.rb @@ -53,7 +53,7 @@ RSpec.describe 'User visits issue boards', :js, feature_category: :team_planning it 'displays all issues satisfiying filter params and correctly sets url params' do expect(page).to have_current_path(board_path) - page.assert_selector('[data-testid="board_card"]', count: expected_issues.length) + page.assert_selector('[data-testid="board-card"]', count: expected_issues.length) expected_issues.each { |issue_title| expect(page).to have_link issue_title } end end diff --git a/spec/lib/bitbucket_server/representation/pull_request_spec.rb b/spec/lib/bitbucket_server/representation/pull_request_spec.rb index 4d8bb3a4407..2d67dd88b24 100644 --- a/spec/lib/bitbucket_server/representation/pull_request_spec.rb +++ b/spec/lib/bitbucket_server/representation/pull_request_spec.rb @@ -82,6 +82,18 @@ RSpec.describe BitbucketServer::Representation::PullRequest, feature_category: : it { expect(subject.merged?).to be_truthy } end + describe '#closed?' do + it { expect(subject.closed?).to be_falsey } + + context 'for declined pull requests' do + before do + sample_data['state'] = 'DECLINED' + end + + it { expect(subject.closed?).to be_truthy } + end + end + describe '#created_at' do it { expect(subject.created_at.to_i).to eq(sample_data['createdDate'] / 1000) } end diff --git a/spec/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer_spec.rb b/spec/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer_spec.rb index b9a9c8dac29..af8a0202083 100644 --- a/spec/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer_spec.rb +++ b/spec/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Gitlab::BitbucketServerImport::Importers::PullRequestsImporter, feature_category: :importers do let_it_be(:project) do - create(:project, :import_started, + create(:project, :with_import_url, :import_started, :empty_repo, import_data_attributes: { data: { 'project_key' => 'key', 'repo_slug' => 'slug' }, credentials: { 'base_uri' => 'http://bitbucket.org/', 'user' => 'bitbucket', 'password' => 'password' } @@ -19,8 +19,30 @@ RSpec.describe Gitlab::BitbucketServerImport::Importers::PullRequestsImporter, f allow_next_instance_of(BitbucketServer::Client) do |client| allow(client).to receive(:pull_requests).and_return( [ - BitbucketServer::Representation::PullRequest.new({ 'id' => 1 }), - BitbucketServer::Representation::PullRequest.new({ 'id' => 2 }) + BitbucketServer::Representation::PullRequest.new( + { + 'id' => 1, + 'state' => 'MERGED', + 'fromRef' => { 'latestCommit' => 'aaaa1' }, + 'toRef' => { 'latestCommit' => 'aaaa2' } + } + ), + BitbucketServer::Representation::PullRequest.new( + { + 'id' => 2, + 'state' => 'DECLINED', + 'fromRef' => { 'latestCommit' => 'bbbb1' }, + 'toRef' => { 'latestCommit' => 'bbbb2' } + } + ), + BitbucketServer::Representation::PullRequest.new( + { + 'id' => 3, + 'state' => 'OPEN', + 'fromRef' => { 'latestCommit' => 'cccc1' }, + 'toRef' => { 'latestCommit' => 'cccc2' } + } + ) ], [] ) @@ -28,14 +50,14 @@ RSpec.describe Gitlab::BitbucketServerImport::Importers::PullRequestsImporter, f end it 'imports each pull request in parallel', :aggregate_failures do - expect(Gitlab::BitbucketServerImport::ImportPullRequestWorker).to receive(:perform_in).twice + expect(Gitlab::BitbucketServerImport::ImportPullRequestWorker).to receive(:perform_in).thrice waiter = importer.execute expect(waiter).to be_an_instance_of(Gitlab::JobWaiter) - expect(waiter.jobs_remaining).to eq(2) + expect(waiter.jobs_remaining).to eq(3) expect(Gitlab::Cache::Import::Caching.values_from_set(importer.already_processed_cache_key)) - .to match_array(%w[1 2]) + .to match_array(%w[1 2 3]) end context 'when pull request was already processed' do @@ -44,12 +66,68 @@ RSpec.describe Gitlab::BitbucketServerImport::Importers::PullRequestsImporter, f end it 'does not schedule job for processed pull requests', :aggregate_failures do - expect(Gitlab::BitbucketServerImport::ImportPullRequestWorker).to receive(:perform_in).once + expect(Gitlab::BitbucketServerImport::ImportPullRequestWorker).to receive(:perform_in).twice waiter = importer.execute expect(waiter).to be_an_instance_of(Gitlab::JobWaiter) - expect(waiter.jobs_remaining).to eq(2) + expect(waiter.jobs_remaining).to eq(3) + end + end + + context 'when pull requests are in merged or declined status' do + it 'fetches latest commits from the remote repository' do + expect(project.repository).to receive(:fetch_remote).with( + project.import_url, + refmap: %w[aaaa1 aaaa2 bbbb1 bbbb2], + prune: false + ) + + importer.execute + end + + context 'when feature flag "fetch_commits_for_bitbucket_server" is disabled' do + before do + stub_feature_flags(fetch_commits_for_bitbucket_server: false) + end + + it 'does not fetch anything' do + expect(project.repository).not_to receive(:fetch_remote) + importer.execute + end + end + + context 'when there are no commits to process' do + before do + Gitlab::Cache::Import::Caching.set_add(importer.already_processed_cache_key, 1) + Gitlab::Cache::Import::Caching.set_add(importer.already_processed_cache_key, 2) + end + + it 'does not fetch anything' do + expect(project.repository).not_to receive(:fetch_remote) + + importer.execute + end + end + + context 'when fetch process is failed' do + let(:exception) { ArgumentError.new('blank or empty URL') } + + before do + allow(project.repository).to receive(:fetch_remote).and_raise(exception) + end + + it 'rescues and logs the exception' do + expect(Gitlab::Import::ImportFailureService) + .to receive(:track) + .with( + project_id: project.id, + exception: exception, + error_source: described_class.name + ).and_call_original + + importer.execute + end end end end diff --git a/spec/services/notes/quick_actions_service_spec.rb b/spec/services/notes/quick_actions_service_spec.rb index cb9d82535fa..0a16037c976 100644 --- a/spec/services/notes/quick_actions_service_spec.rb +++ b/spec/services/notes/quick_actions_service_spec.rb @@ -334,6 +334,46 @@ RSpec.describe Notes::QuickActionsService, feature_category: :team_planning do end end + describe '/add_child' do + let_it_be_with_reload(:noteable) { create(:work_item, :objective, project: project) } + let_it_be_with_reload(:child) { create(:work_item, :objective, project: project) } + let_it_be_with_reload(:second_child) { create(:work_item, :objective, project: project) } + let_it_be(:note_text) { "/add_child #{child.to_reference}, #{second_child.to_reference}" } + let_it_be(:note) { create(:note, noteable: noteable, project: project, note: note_text) } + let_it_be(:children) { [child, second_child] } + + shared_examples 'adds child work items' do + it 'leaves the note empty' do + expect(execute(note)).to be_empty + end + + it 'adds child work items' do + execute(note) + + expect(noteable.valid?).to be_truthy + expect(noteable.work_item_children).to eq(children) + end + end + + context 'when using work item reference' do + let_it_be(:note_text) { "/add_child #{child.to_reference(full: true)},#{second_child.to_reference(full: true)}" } + + it_behaves_like 'adds child work items' + end + + context 'when using work item iid' do + it_behaves_like 'adds child work items' + end + + context 'when using work item URL' do + let_it_be(:project_path) { "#{Gitlab.config.gitlab.url}/#{project.full_path}" } + let_it_be(:url) { "#{project_path}/work_items/#{child.iid},#{project_path}/work_items/#{second_child.iid}" } + let_it_be(:note_text) { "/add_child #{url}" } + + it_behaves_like 'adds child work items' + end + end + describe '/set_parent' do let_it_be_with_reload(:noteable) { create(:work_item, :objective, project: project) } let_it_be_with_reload(:parent) { create(:work_item, :objective, project: project) } diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb index 57967fa0c3a..2c34d6a59be 100644 --- a/spec/services/quick_actions/interpret_service_spec.rb +++ b/spec/services/quick_actions/interpret_service_spec.rb @@ -3091,6 +3091,55 @@ RSpec.describe QuickActions::InterpretService, feature_category: :team_planning it_behaves_like 'command is not available' end end + + describe '/add_child command' do + let_it_be(:child) { create(:work_item, :issue, project: project) } + let_it_be(:work_item) { create(:work_item, :objective, project: project) } + let_it_be(:child_ref) { child.to_reference(project) } + + let(:command) { "/add_child #{child_ref}" } + + shared_examples 'command is available' do + it 'explanation contains correct message' do + _, explanations = service.explain(command, work_item) + + expect(explanations) + .to contain_exactly("Add #{child_ref} to this work item as child(ren).") + end + + it 'contains command' do + expect(service.available_commands(work_item)).to include(a_hash_including(name: :add_child)) + end + end + + shared_examples 'command is not available' do + it 'explanation is empty' do + _, explanations = service.explain(command, work_item) + + expect(explanations).to eq([]) + end + + it 'does not contain command' do + expect(service.available_commands(work_item)).not_to include(a_hash_including(name: :add_child)) + end + end + + context 'when user can admin link' do + it_behaves_like 'command is available' + + context 'when work item type does not support children' do + let_it_be(:work_item) { build(:work_item, :key_result, project: project) } + + it_behaves_like 'command is not available' + end + end + + context 'when user cannot admin link' do + subject(:service) { described_class.new(project, create(:user)) } + + it_behaves_like 'command is not available' + end + end end describe '#available_commands' do |