summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-02-09 09:08:04 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2023-02-09 09:08:04 +0000
commit2e2db606cc7547b445a11c367d8db6f5feb42443 (patch)
tree58a4d9c8ea1e78aaea796a525ef79c037f3e0e51
parentc789d0002c97a00e262be992adfcc0d26b72910e (diff)
downloadgitlab-ce-2e2db606cc7547b445a11c367d8db6f5feb42443.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue15
-rw-r--r--app/assets/javascripts/ci/runner/admin_new_runner/index.js7
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue76
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue97
-rw-r--r--app/assets/javascripts/ci/runner/constants.js14
-rw-r--r--app/graphql/mutations/work_items/update.rb33
-rw-r--r--app/models/design_user_mention.rb4
-rw-r--r--app/models/work_items/type.rb4
-rw-r--r--app/services/issues/create_service.rb8
-rw-r--r--app/services/issues/update_service.rb8
-rw-r--r--app/views/admin/runners/new.html.haml6
-rw-r--r--config/initializers/1_settings.rb2
-rw-r--r--db/migrate/20230207011344_initialize_conversion_of_design_user_mentions_note_id_to_bigint.rb16
-rw-r--r--db/post_migrate/20230131194959_remove_invalid_deploy_access_level.rb22
-rw-r--r--db/post_migrate/20230207011414_backfill_design_user_mentions_note_id_for_bigint_conversion.rb16
-rw-r--r--db/schema_migrations/202301311949591
-rw-r--r--db/schema_migrations/202302070113441
-rw-r--r--db/schema_migrations/202302070114141
-rw-r--r--db/structure.sql14
-rw-r--r--doc/administration/issue_closing_pattern.md11
-rw-r--r--doc/integration/jira/connect-app.md24
-rw-r--r--doc/user/project/issues/managing_issues.md6
-rw-r--r--locale/gitlab.pot9
-rw-r--r--spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js11
-rw-r--r--spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js96
-rw-r--r--spec/frontend/ci/runner/components/runner_platforms_radio_spec.js154
-rw-r--r--spec/migrations/remove_invalid_deploy_access_level_spec.rb48
-rw-r--r--spec/models/work_items/type_spec.rb18
-rw-r--r--spec/requests/api/graphql/mutations/work_items/update_spec.rb211
-rw-r--r--spec/services/issues/create_service_spec.rb30
-rw-r--r--spec/services/issues/update_service_spec.rb26
-rw-r--r--spec/support/shared_examples/graphql/mutations/work_items/update_description_widget_shared_examples.rb84
32 files changed, 1061 insertions, 12 deletions
diff --git a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue
index 293fbfcf7a7..db65268166c 100644
--- a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue
@@ -1,6 +1,8 @@
<script>
import { GlSprintf, GlLink, GlModalDirective } from '@gitlab/ui';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
+import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue';
+import { DEFAULT_PLATFORM } from '../constants';
export default {
name: 'AdminNewRunnerApp',
@@ -8,6 +10,7 @@ export default {
GlLink,
GlSprintf,
RunnerInstructionsModal,
+ RunnerPlatformsRadioGroup,
},
directives: {
GlModal: GlModalDirective,
@@ -18,6 +21,11 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ platform: DEFAULT_PLATFORM,
+ };
+ },
modalId: 'runners-legacy-registration-instructions-modal',
};
</script>
@@ -44,5 +52,12 @@ export default {
</template>
</gl-sprintf>
</p>
+
+ <hr />
+
+ <h2 class="gl-font-weight-normal gl-font-lg gl-my-5">
+ {{ s__('Runners|Platform') }}
+ </h2>
+ <runner-platforms-radio-group v-model="platform" />
</div>
</template>
diff --git a/app/assets/javascripts/ci/runner/admin_new_runner/index.js b/app/assets/javascripts/ci/runner/admin_new_runner/index.js
index 502d9d33b4d..7aca19572b3 100644
--- a/app/assets/javascripts/ci/runner/admin_new_runner/index.js
+++ b/app/assets/javascripts/ci/runner/admin_new_runner/index.js
@@ -12,7 +12,7 @@ export const initAdminNewRunner = (selector = '#js-admin-new-runner') => {
return null;
}
- const { legacyRegistrationToken } = el.dataset;
+ const { legacyRegistrationToken, awsImgPath, dockerImgPath, kubernetesImgPath } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
@@ -21,6 +21,11 @@ export const initAdminNewRunner = (selector = '#js-admin-new-runner') => {
return new Vue({
el,
apolloProvider,
+ provide: {
+ awsImgPath,
+ dockerImgPath,
+ kubernetesImgPath,
+ },
render(h) {
return h(AdminNewRunnerApp, {
props: {
diff --git a/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue b/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue
new file mode 100644
index 00000000000..d70c51e83f9
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue
@@ -0,0 +1,76 @@
+<script>
+import { GlFormRadio } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFormRadio,
+ },
+ model: {
+ event: 'input',
+ prop: 'checked',
+ },
+ props: {
+ image: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ checked: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ value: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ isChecked() {
+ return this.value && this.value === this.checked;
+ },
+ },
+ methods: {
+ onInput($event) {
+ if (!$event) {
+ return;
+ }
+ this.$emit('input', $event);
+ },
+ onChange($event) {
+ this.$emit('change', $event);
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="runner-platforms-radio gl-display-flex gl-border gl-rounded-base gl-px-5 gl-py-6"
+ :class="{ 'gl-bg-blue-50 gl-border-blue-500': isChecked, 'gl-cursor-pointer': value }"
+ @click="onInput(value)"
+ >
+ <gl-form-radio
+ v-if="value"
+ class="gl-min-h-5"
+ :checked="checked"
+ :value="value"
+ @input="onInput($event)"
+ @change="onChange($event)"
+ >
+ <img v-if="image" :src="image" aria-hidden="true" class="gl-h-5 gl-mr-2" />
+ <span class="gl-font-weight-bold"><slot></slot></span>
+ </gl-form-radio>
+ <div v-else class="gl-h-5">
+ <img v-if="image" :src="image" aria-hidden="true" class="gl-h-5 gl-mr-2" />
+ <span class="gl-font-weight-bold"><slot></slot></span>
+ </div>
+ </div>
+</template>
+
+<style>
+.runner-platforms-radio {
+ min-width: 173px;
+}
+</style>
diff --git a/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue b/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue
new file mode 100644
index 00000000000..304fdadbca2
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue
@@ -0,0 +1,97 @@
+<script>
+import { GlFormRadioGroup, GlIcon, GlLink } from '@gitlab/ui';
+import {
+ LINUX_PLATFORM,
+ MACOS_PLATFORM,
+ WINDOWS_PLATFORM,
+ AWS_PLATFORM,
+ DOCKER_HELP_URL,
+ KUBERNETES_HELP_URL,
+} from '../constants';
+
+import RunnerPlatformsRadio from './runner_platforms_radio.vue';
+
+export default {
+ components: {
+ GlFormRadioGroup,
+ GlLink,
+ GlIcon,
+ RunnerPlatformsRadio,
+ },
+ inject: ['awsImgPath', 'dockerImgPath', 'kubernetesImgPath'],
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ model: this.value,
+ };
+ },
+ watch: {
+ model() {
+ this.$emit('input', this.model);
+ },
+ },
+ LINUX_PLATFORM,
+ MACOS_PLATFORM,
+ WINDOWS_PLATFORM,
+ AWS_PLATFORM,
+ DOCKER_HELP_URL,
+ KUBERNETES_HELP_URL,
+};
+</script>
+
+<template>
+ <gl-form-radio-group v-model="model">
+ <div class="gl-mt-3 gl-mb-6">
+ <label>{{ s__('Runners|Operating systems') }}</label>
+
+ <div class="gl-display-flex gl-flex-wrap gl-gap-5">
+ <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
+ <runner-platforms-radio v-model="model" :value="$options.LINUX_PLATFORM">
+ Linux
+ </runner-platforms-radio>
+ <runner-platforms-radio v-model="model" :value="$options.MACOS_PLATFORM">
+ macOS
+ </runner-platforms-radio>
+ <runner-platforms-radio v-model="model" :value="$options.WINDOWS_PLATFORM">
+ Windows
+ </runner-platforms-radio>
+ </div>
+ </div>
+
+ <div class="gl-mt-3 gl-mb-6">
+ <label>{{ s__('Runners|Cloud templates') }}</label>
+ <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
+ <div class="gl-display-flex gl-flex-wrap gl-gap-5">
+ <runner-platforms-radio v-model="model" :image="awsImgPath" :value="$options.AWS_PLATFORM">
+ AWS
+ </runner-platforms-radio>
+ </div>
+ </div>
+
+ <div class="gl-mt-3 gl-mb-6">
+ <label>{{ s__('Runners|Containers') }}</label>
+
+ <div class="gl-display-flex gl-flex-wrap gl-gap-5">
+ <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
+ <runner-platforms-radio :image="dockerImgPath">
+ <gl-link :href="$options.DOCKER_HELP_URL" target="_blank">
+ Docker
+ <gl-icon name="external-link" />
+ </gl-link>
+ </runner-platforms-radio>
+ <runner-platforms-radio :image="kubernetesImgPath">
+ <gl-link :href="$options.KUBERNETES_HELP_URL" target="_blank">
+ Kubernetes
+ <gl-icon name="external-link" />
+ </gl-link>
+ </runner-platforms-radio>
+ </div>
+ </div>
+ </gl-form-radio-group>
+</template>
diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js
index dc4a62852ed..ddad456a6dc 100644
--- a/app/assets/javascripts/ci/runner/constants.js
+++ b/app/assets/javascripts/ci/runner/constants.js
@@ -172,3 +172,17 @@ export const DEFAULT_MEMBERSHIP = MEMBERSHIP_DESCENDANTS;
export const ADMIN_FILTERED_SEARCH_NAMESPACE = 'admin_runners';
export const GROUP_FILTERED_SEARCH_NAMESPACE = 'group_runners';
+
+// Platforms
+
+export const LINUX_PLATFORM = 'linux';
+export const MACOS_PLATFORM = 'osx';
+export const WINDOWS_PLATFORM = 'windows';
+export const AWS_PLATFORM = 'aws';
+
+export const DEFAULT_PLATFORM = LINUX_PLATFORM;
+
+// Runner docs are in a separate repository and are not shipped with GitLab
+// they are rendered as external URLs.
+export const DOCKER_HELP_URL = 'https://docs.gitlab.com/runner/install/docker.html';
+export const KUBERNETES_HELP_URL = 'https://docs.gitlab.com/runner/install/kubernetes.html';
diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb
index 04c63d8e876..741a0ca771f 100644
--- a/app/graphql/mutations/work_items/update.rb
+++ b/app/graphql/mutations/work_items/update.rb
@@ -22,6 +22,8 @@ module Mutations
spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
widget_params = extract_widget_params!(work_item.work_item_type, attributes)
+ interpret_quick_actions!(work_item, current_user, widget_params, attributes)
+
update_result = ::WorkItems::UpdateService.new(
project: work_item.project,
current_user: current_user,
@@ -43,6 +45,37 @@ module Mutations
def find_object(id:)
GitlabSchema.find_by_gid(id)
end
+
+ def interpret_quick_actions!(work_item, current_user, widget_params, attributes = {})
+ return unless work_item.work_item_type.widgets.include?(::WorkItems::Widgets::Description)
+
+ description_param = widget_params[::WorkItems::Widgets::Description.api_symbol]
+ return unless description_param
+
+ original_description = description_param.fetch(:description, work_item.description)
+
+ description, command_params = QuickActions::InterpretService
+ .new(work_item.project, current_user, {})
+ .execute(original_description, work_item)
+
+ description_param[:description] = description if description && description != original_description
+
+ # Widgets have a set of quick action params that they must process.
+ # Map them to widget_params so they can be picked up by widget services.
+ work_item.work_item_type.widgets
+ .filter { |widget| widget.respond_to?(:quick_action_params) }
+ .each do |widget|
+ widget.quick_action_params
+ .filter { |param_name| command_params.key?(param_name) }
+ .each do |param_name|
+ widget_params[widget.api_symbol] ||= {}
+ widget_params[widget.api_symbol][param_name] = command_params.delete(param_name)
+ end
+ end
+
+ # The command_params not processed by widgets (e.g. title) should be placed in 'attributes'.
+ attributes.merge!(command_params || {})
+ end
end
end
end
diff --git a/app/models/design_user_mention.rb b/app/models/design_user_mention.rb
index baf4db29a0f..87899f65cb1 100644
--- a/app/models/design_user_mention.rb
+++ b/app/models/design_user_mention.rb
@@ -1,6 +1,10 @@
# frozen_string_literal: true
class DesignUserMention < UserMention
+ include IgnorableColumns
+
+ ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22'
+
belongs_to :design, class_name: 'DesignManagement::Design'
belongs_to :note
end
diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb
index 9b434ef946c..258a86d7316 100644
--- a/app/models/work_items/type.rb
+++ b/app/models/work_items/type.rb
@@ -145,6 +145,10 @@ module WorkItems
widgets.include? ::WorkItems::Widgets::Assignees
end
+ def default_issue?
+ name == WorkItems::Type::TYPE_NAMES[:issue]
+ end
+
private
def strip_whitespace
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index f6a1db2dcaa..a92110fd843 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -100,6 +100,14 @@ module Issues
private
+ def handle_quick_actions(issue)
+ # Do not handle quick actions unless the work item is the default Issue.
+ # The available quick actions for a work item depend on its type and widgets.
+ return if @params[:work_item_type].present? && @params[:work_item_type] != WorkItems::Type.default_by_type(:issue)
+
+ super
+ end
+
def authorization_action
:create_issue
end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index d56e7858990..d43df0da3fd 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -116,6 +116,14 @@ module Issues
attr_reader :spam_params
+ def handle_quick_actions(issue)
+ # Do not handle quick actions unless the work item is the default Issue.
+ # The available quick actions for a work item depend on its type and widgets.
+ return unless issue.work_item_type.default_issue?
+
+ super
+ end
+
def handle_date_changes(issue)
return unless issue.previous_changes.slice('due_date', 'start_date').any?
diff --git a/app/views/admin/runners/new.html.haml b/app/views/admin/runners/new.html.haml
index 12a569862e6..cf5638d0294 100644
--- a/app/views/admin/runners/new.html.haml
+++ b/app/views/admin/runners/new.html.haml
@@ -2,4 +2,8 @@
- breadcrumb_title s_('Runner|New')
- page_title s_('Runners|Create an instance runner')
-#js-admin-new-runner{ data: { legacy_registration_token: Gitlab::CurrentSettings.runners_registration_token } }
+#js-admin-new-runner{ data: {
+ legacy_registration_token: Gitlab::CurrentSettings.runners_registration_token,
+ aws_img_path: image_path('illustrations/logos/aws.svg'),
+ kubernetes_img_path: image_path('illustrations/logos/kubernetes.svg'),
+ docker_img_path: image_path('illustrations/third-party-logos/ci_cd-template-logos/docker.png') } }
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index e85510e3a11..52c3b3e449f 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -835,7 +835,7 @@ Gitlab.ee do
Settings.cron_jobs['notify_seats_exceeded_batch_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['notify_seats_exceeded_batch_worker']['cron'] ||= '0 3 * * *'
Settings.cron_jobs['notify_seats_exceeded_batch_worker']['job_class'] ||= 'GitlabSubscriptions::NotifySeatsExceededBatchWorker'
- Settings.cron_jobs['gitlab_subscriptionsschedule_refresh_seats_worker'] ||= Settingslogic.new({})
+ Settings.cron_jobs['gitlab_subscriptions_schedule_refresh_seats_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['gitlab_subscriptions_schedule_refresh_seats_worker']['cron'] ||= "0 */6 * * *"
Settings.cron_jobs['gitlab_subscriptions_schedule_refresh_seats_worker']['job_class'] = 'GitlabSubscriptions::ScheduleRefreshSeatsWorker'
end
diff --git a/db/migrate/20230207011344_initialize_conversion_of_design_user_mentions_note_id_to_bigint.rb b/db/migrate/20230207011344_initialize_conversion_of_design_user_mentions_note_id_to_bigint.rb
new file mode 100644
index 00000000000..6f192bf402c
--- /dev/null
+++ b/db/migrate/20230207011344_initialize_conversion_of_design_user_mentions_note_id_to_bigint.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class InitializeConversionOfDesignUserMentionsNoteIdToBigint < Gitlab::Database::Migration[2.1]
+ TABLE = :design_user_mentions
+ COLUMNS = %i[note_id]
+
+ enable_lock_retries!
+
+ def up
+ initialize_conversion_of_integer_to_bigint(TABLE, COLUMNS)
+ end
+
+ def down
+ revert_initialize_conversion_of_integer_to_bigint(TABLE, COLUMNS)
+ end
+end
diff --git a/db/post_migrate/20230131194959_remove_invalid_deploy_access_level.rb b/db/post_migrate/20230131194959_remove_invalid_deploy_access_level.rb
new file mode 100644
index 00000000000..57364e2200b
--- /dev/null
+++ b/db/post_migrate/20230131194959_remove_invalid_deploy_access_level.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class RemoveInvalidDeployAccessLevel < Gitlab::Database::Migration[2.1]
+ disable_ddl_transaction!
+
+ restrict_gitlab_migration gitlab_schema: :gitlab_main
+
+ # clean up any rows with invalid access_level entries
+ def up
+ update_column_in_batches(:protected_environment_deploy_access_levels, :access_level, nil) do |table, query|
+ query.where(
+ table.grouping(table[:user_id].not_eq(nil).or(table[:group_id].not_eq(nil)))
+ .and(table[:access_level].not_eq(nil)))
+ end
+ end
+
+ def down
+ # no-op
+
+ # we are setting access_level to NULL if group_id or user_id are present
+ end
+end
diff --git a/db/post_migrate/20230207011414_backfill_design_user_mentions_note_id_for_bigint_conversion.rb b/db/post_migrate/20230207011414_backfill_design_user_mentions_note_id_for_bigint_conversion.rb
new file mode 100644
index 00000000000..6e971bad382
--- /dev/null
+++ b/db/post_migrate/20230207011414_backfill_design_user_mentions_note_id_for_bigint_conversion.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class BackfillDesignUserMentionsNoteIdForBigintConversion < Gitlab::Database::Migration[2.1]
+ TABLE = :design_user_mentions
+ COLUMNS = %i[note_id]
+
+ restrict_gitlab_migration gitlab_schema: :gitlab_main
+
+ def up
+ backfill_conversion_of_integer_to_bigint(TABLE, COLUMNS)
+ end
+
+ def down
+ revert_backfill_conversion_of_integer_to_bigint(TABLE, COLUMNS)
+ end
+end
diff --git a/db/schema_migrations/20230131194959 b/db/schema_migrations/20230131194959
new file mode 100644
index 00000000000..141a6de47e3
--- /dev/null
+++ b/db/schema_migrations/20230131194959
@@ -0,0 +1 @@
+6b646889335342e41d3e891c13abe8c0fc3cb04c4a3e8a74d1f5b3ab19704111 \ No newline at end of file
diff --git a/db/schema_migrations/20230207011344 b/db/schema_migrations/20230207011344
new file mode 100644
index 00000000000..0d41804e37a
--- /dev/null
+++ b/db/schema_migrations/20230207011344
@@ -0,0 +1 @@
+c7cf038ab9b9d853a1e321508bdfd3044b5774adf7d08c157c2c0881bda6bac2 \ No newline at end of file
diff --git a/db/schema_migrations/20230207011414 b/db/schema_migrations/20230207011414
new file mode 100644
index 00000000000..3b13317b1e6
--- /dev/null
+++ b/db/schema_migrations/20230207011414
@@ -0,0 +1 @@
+8350a8c8dca95ea8c18e0ee132f8b6a732ddd6981ddb9742aecbfc5ad3daa091 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 1170892789e..ad2d102b5ee 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -252,6 +252,15 @@ BEGIN
END;
$$;
+CREATE FUNCTION trigger_3dc62927cae8() RETURNS trigger
+ LANGUAGE plpgsql
+ AS $$
+BEGIN
+ NEW."note_id_convert_to_bigint" := NEW."note_id";
+ RETURN NEW;
+END;
+$$;
+
CREATE FUNCTION trigger_7f4fcd5aa322() RETURNS trigger
LANGUAGE plpgsql
AS $$
@@ -15143,7 +15152,8 @@ CREATE TABLE design_user_mentions (
note_id integer NOT NULL,
mentioned_users_ids integer[],
mentioned_projects_ids integer[],
- mentioned_groups_ids integer[]
+ mentioned_groups_ids integer[],
+ note_id_convert_to_bigint bigint DEFAULT 0 NOT NULL
);
CREATE SEQUENCE design_user_mentions_id_seq
@@ -33531,6 +33541,8 @@ CREATE TRIGGER trigger_1a857e8db6cd BEFORE INSERT OR UPDATE ON vulnerability_occ
CREATE TRIGGER trigger_3207b8d0d6f3 BEFORE INSERT OR UPDATE ON ci_build_needs FOR EACH ROW EXECUTE FUNCTION trigger_3207b8d0d6f3();
+CREATE TRIGGER trigger_3dc62927cae8 BEFORE INSERT OR UPDATE ON design_user_mentions FOR EACH ROW EXECUTE FUNCTION trigger_3dc62927cae8();
+
CREATE TRIGGER trigger_7f4fcd5aa322 BEFORE INSERT OR UPDATE ON sent_notifications FOR EACH ROW EXECUTE FUNCTION trigger_7f4fcd5aa322();
CREATE TRIGGER trigger_c5a5f48f12b0 BEFORE INSERT OR UPDATE ON epic_user_mentions FOR EACH ROW EXECUTE FUNCTION trigger_c5a5f48f12b0();
diff --git a/doc/administration/issue_closing_pattern.md b/doc/administration/issue_closing_pattern.md
index bdbcdd0093c..1ea6b3bb49c 100644
--- a/doc/administration/issue_closing_pattern.md
+++ b/doc/administration/issue_closing_pattern.md
@@ -8,8 +8,9 @@ type: reference
# Issue closing pattern **(FREE SELF)**
NOTE:
-This is the administration documentation. There is a separate [user documentation](../user/project/issues/managing_issues.md#closing-issues-automatically)
-on issue closing pattern.
+This page explains how an administrator can configure issue closing patterns.
+For user documentation about the feature, see
+[Closing issues automatically](../user/project/issues/managing_issues.md#closing-issues-automatically).
When a commit or merge request resolves one or more issues, it is possible to
automatically close these issues when the commit or merge request lands
@@ -21,9 +22,9 @@ The [default issue closing pattern](../user/project/issues/managing_issues.md#de
covers a wide range of words. You can change the pattern to suit your needs.
NOTE:
-You are advised to use <https://rubular.com> to test the issue closing pattern.
-However, since Rubular doesn't understand `%{issue_ref}`, you can replace this by
-`#\d+` when testing your patterns, which matches only local issue references like `#123`.
+To test the issue closing pattern, use <https://rubular.com>.
+However, Rubular doesn't understand `%{issue_ref}`. When testing your patterns,
+replace this string with `#\d+`, which matches only local issue references like `#123`.
To change the default issue closing pattern:
diff --git a/doc/integration/jira/connect-app.md b/doc/integration/jira/connect-app.md
index a4757d5fa80..065b7080b7b 100644
--- a/doc/integration/jira/connect-app.md
+++ b/doc/integration/jira/connect-app.md
@@ -244,3 +244,27 @@ You might get an error if you have installed the GitLab for Jira Cloud app from
1. Expand the **GitLab for Jira App** section.
1. Clear the **Jira Connect Proxy URL** text box.
1. Select **Save changes**.
+
+### Data sync fails with `Invalid JWT` error
+
+If the GitLab for Jira Cloud app continuously fails to sync data, it may be due to an outdated secret token. Atlassian can send new secret tokens that must be processed and stored by GitLab.
+If GitLab fails to store the token or misses the new token request, an `Invalid JWT` error occurs.
+
+To resolve this issue on GitLab self-managed, follow one of the solutions below, depending on your app installation method.
+
+- If you installed the app from the official marketplace listing:
+
+ 1. Open the GitLab for Jira Cloud app on Jira.
+ 1. Select **Change GitLab version**.
+ 1. Select **GitLab.com (SaaS)**.
+ 1. Select **Change GitLab version** again.
+ 1. Select **GitLab (self-managed)**.
+ 1. Enter your **GitLab instance URL**.
+ 1. Select **Save**.
+
+- If you [installed the GitLab for Jira Cloud app manually](#install-the-gitlab-for-jira-cloud-app-manually):
+
+ - In GitLab 14.9 and later:
+ - Contact the [Jira Software Cloud support](https://support.atlassian.com/jira-software-cloud/) and ask to trigger a new installed lifecycle event for the GitLab for Jira Cloud app in your namespace.
+ - In all GitLab versions:
+ - Re-install the GitLab for Jira Cloud app. This might remove all already synced development panel data.
diff --git a/doc/user/project/issues/managing_issues.md b/doc/user/project/issues/managing_issues.md
index ca737878ea5..75102cf1e6c 100644
--- a/doc/user/project/issues/managing_issues.md
+++ b/doc/user/project/issues/managing_issues.md
@@ -204,9 +204,11 @@ A reopened issue is no different from any other open issue.
### Closing issues automatically
-You can close issues automatically by using certain words in the commit message or MR description.
+You can close issues automatically by using certain words, called a _closing pattern_,
+in a commit message or merge request description. Administrators of self-managed GitLab instances
+can [change the default closing pattern](../../../administration/issue_closing_pattern.md).
-If a commit message or merge request description contains text matching the [defined pattern](#default-closing-pattern),
+If a commit message or merge request description contains text matching the [closing pattern](#default-closing-pattern),
all issues referenced in the matched text are closed when either:
- The commit is pushed to a project's [**default** branch](../repository/branches/default.md).
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 10e7c4a438c..9b002c7bdcc 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -36643,12 +36643,18 @@ msgstr ""
msgid "Runners|Clear selection"
msgstr ""
+msgid "Runners|Cloud templates"
+msgstr ""
+
msgid "Runners|Command to register runner"
msgstr ""
msgid "Runners|Configuration"
msgstr ""
+msgid "Runners|Containers"
+msgstr ""
+
msgid "Runners|Copy instructions"
msgstr ""
@@ -36825,6 +36831,9 @@ msgstr ""
msgid "Runners|Only administrators can view this."
msgstr ""
+msgid "Runners|Operating systems"
+msgstr ""
+
msgid "Runners|Owner"
msgstr ""
diff --git a/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js b/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js
index a8486809cdc..9c254f43504 100644
--- a/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js
+++ b/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js
@@ -6,6 +6,8 @@ import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import AdminNewRunnerApp from '~/ci/runner/admin_new_runner/admin_new_runner_app.vue';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
+import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue';
+import { DEFAULT_PLATFORM } from '~/ci/runner/constants';
const mockLegacyRegistrationToken = 'LEGACY_REGISTRATION_TOKEN';
@@ -16,6 +18,7 @@ describe('AdminNewRunnerApp', () => {
const findLegacyInstructionsLink = () => wrapper.findByTestId('legacy-instructions-link');
const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal);
+ const findRunnerPlatformsRadioGroup = () => wrapper.findComponent(RunnerPlatformsRadioGroup);
const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => {
wrapper = mountFn(AdminNewRunnerApp, {
@@ -50,4 +53,12 @@ describe('AdminNewRunnerApp', () => {
expect(findRunnerInstructionsModal().props('modalId')).toBe(modalId);
});
});
+
+ describe('New runner form fields', () => {
+ describe('Platform', () => {
+ it('shows the platforms radio group', () => {
+ expect(findRunnerPlatformsRadioGroup().props('value')).toBe(DEFAULT_PLATFORM);
+ });
+ });
+ });
});
diff --git a/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js b/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js
new file mode 100644
index 00000000000..12c9afe9758
--- /dev/null
+++ b/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js
@@ -0,0 +1,96 @@
+import { nextTick } from 'vue';
+import { GlFormRadioGroup, GlIcon, GlLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import RunnerPlatformsRadio from '~/ci/runner/components/runner_platforms_radio.vue';
+import {
+ LINUX_PLATFORM,
+ MACOS_PLATFORM,
+ WINDOWS_PLATFORM,
+ AWS_PLATFORM,
+ DOCKER_HELP_URL,
+ KUBERNETES_HELP_URL,
+} from '~/ci/runner/constants';
+
+import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue';
+
+const mockProvide = {
+ awsImgPath: 'awsLogo.svg',
+ dockerImgPath: 'dockerLogo.svg',
+ kubernetesImgPath: 'kubernetesLogo.svg',
+};
+
+describe('RunnerPlatformsRadioGroup', () => {
+ let wrapper;
+
+ const findFormRadioGroup = () => wrapper.findComponent(GlFormRadioGroup);
+ const findFormRadios = () => wrapper.findAllComponents(RunnerPlatformsRadio).wrappers;
+ const findFormRadioByText = (text) =>
+ findFormRadios()
+ .filter((w) => w.text() === text)
+ .at(0);
+
+ const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => {
+ wrapper = mountFn(RunnerPlatformsRadioGroup, {
+ propsData: {
+ value: null,
+ ...props,
+ },
+ provide: mockProvide,
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('contains expected options with images', () => {
+ const labels = findFormRadios().map((w) => [w.text(), w.props('image')]);
+
+ expect(labels).toEqual([
+ ['Linux', null],
+ ['macOS', null],
+ ['Windows', null],
+ ['AWS', mockProvide.awsImgPath],
+ ['Docker', mockProvide.dockerImgPath],
+ ['Kubernetes', mockProvide.kubernetesImgPath],
+ ]);
+ });
+
+ it('allows users to use radio group', async () => {
+ findFormRadioGroup().vm.$emit('input', MACOS_PLATFORM);
+ await nextTick();
+
+ expect(wrapper.emitted('input')[0]).toEqual([MACOS_PLATFORM]);
+ });
+
+ it.each`
+ text | value
+ ${'Linux'} | ${LINUX_PLATFORM}
+ ${'macOS'} | ${MACOS_PLATFORM}
+ ${'Windows'} | ${WINDOWS_PLATFORM}
+ ${'AWS'} | ${AWS_PLATFORM}
+ `('user can select "$text"', async ({ text, value }) => {
+ const radio = findFormRadioByText(text);
+ expect(radio.props('value')).toBe(value);
+
+ radio.vm.$emit('input', value);
+ await nextTick();
+
+ expect(wrapper.emitted('input')[0]).toEqual([value]);
+ });
+
+ it.each`
+ text | href
+ ${'Docker'} | ${DOCKER_HELP_URL}
+ ${'Kubernetes'} | ${KUBERNETES_HELP_URL}
+ `('provides link to "$text" docs', async ({ text, href }) => {
+ const radio = findFormRadioByText(text);
+
+ expect(radio.findComponent(GlLink).attributes()).toEqual({
+ href,
+ target: '_blank',
+ });
+ expect(radio.findComponent(GlIcon).props('name')).toBe('external-link');
+ });
+});
diff --git a/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js b/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js
new file mode 100644
index 00000000000..fb81edd1ae2
--- /dev/null
+++ b/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js
@@ -0,0 +1,154 @@
+import { GlFormRadio } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
+import RunnerPlatformsRadio from '~/ci/runner/components/runner_platforms_radio.vue';
+
+const mockImg = 'mock.svg';
+const mockValue = 'value';
+const mockValue2 = 'value2';
+const mockSlot = '<div>a</div>';
+
+describe('RunnerPlatformsRadio', () => {
+ let wrapper;
+
+ const findDiv = () => wrapper.find('div');
+ const findImg = () => wrapper.find('img');
+ const findFormRadio = () => wrapper.findComponent(GlFormRadio);
+
+ const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => {
+ wrapper = mountFn(RunnerPlatformsRadio, {
+ propsData: {
+ image: mockImg,
+ value: mockValue,
+ ...props,
+ },
+ ...options,
+ });
+ };
+
+ describe('when its selectable', () => {
+ beforeEach(() => {
+ createComponent({
+ props: { value: mockValue },
+ });
+ });
+
+ it('shows the item is clickable', () => {
+ expect(wrapper.classes('gl-cursor-pointer')).toBe(true);
+ });
+
+ it('shows radio option', () => {
+ expect(findFormRadio().attributes('value')).toBe(mockValue);
+ });
+
+ it('emits when item is clicked', async () => {
+ findDiv().trigger('click');
+
+ expect(wrapper.emitted('input')).toEqual([[mockValue]]);
+ });
+
+ it.each(['input', 'change'])('emits radio "%s" event', (event) => {
+ findFormRadio().vm.$emit(event, mockValue2);
+
+ expect(wrapper.emitted(event)).toEqual([[mockValue2]]);
+ });
+
+ it('shows image', () => {
+ expect(findImg().attributes()).toMatchObject({
+ src: mockImg,
+ 'aria-hidden': 'true',
+ });
+ });
+
+ it('shows slot', () => {
+ createComponent({
+ slots: {
+ default: mockSlot,
+ },
+ });
+
+ expect(wrapper.html()).toContain(mockSlot);
+ });
+
+ describe('with no image', () => {
+ beforeEach(() => {
+ createComponent({
+ props: { value: mockValue, image: null },
+ });
+ });
+
+ it('shows no image', () => {
+ expect(findImg().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('when its not selectable', () => {
+ beforeEach(() => {
+ createComponent({
+ props: { value: null },
+ });
+ });
+
+ it('shows the item is clickable', () => {
+ expect(wrapper.classes('gl-cursor-pointer')).toBe(false);
+ });
+
+ it('does not emit when item is clicked', async () => {
+ findDiv().trigger('click');
+
+ expect(wrapper.emitted('input')).toBe(undefined);
+ });
+
+ it('does not show a radio option', () => {
+ expect(findFormRadio().exists()).toBe(false);
+ });
+
+ it('shows image', () => {
+ expect(findImg().attributes()).toMatchObject({
+ src: mockImg,
+ 'aria-hidden': 'true',
+ });
+ });
+
+ it('shows slot', () => {
+ createComponent({
+ slots: {
+ default: mockSlot,
+ },
+ });
+
+ expect(wrapper.html()).toContain(mockSlot);
+ });
+
+ describe('with no image', () => {
+ beforeEach(() => {
+ createComponent({
+ props: { value: null, image: null },
+ });
+ });
+
+ it('shows no image', () => {
+ expect(findImg().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('when selected', () => {
+ beforeEach(() => {
+ createComponent({
+ props: { checked: mockValue },
+ });
+ });
+
+ it('highlights the item', () => {
+ expect(wrapper.classes('gl-bg-blue-50')).toBe(true);
+ expect(wrapper.classes('gl-border-blue-500')).toBe(true);
+ });
+
+ it('shows radio option as selected', () => {
+ expect(findFormRadio().attributes('value')).toBe(mockValue);
+ expect(findFormRadio().props('checked')).toBe(mockValue);
+ });
+ });
+});
diff --git a/spec/migrations/remove_invalid_deploy_access_level_spec.rb b/spec/migrations/remove_invalid_deploy_access_level_spec.rb
new file mode 100644
index 00000000000..cc0f5679dda
--- /dev/null
+++ b/spec/migrations/remove_invalid_deploy_access_level_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe RemoveInvalidDeployAccessLevel, :migration, feature_category: :continuous_integration do
+ let(:users) { table(:users) }
+ let(:groups) { table(:namespaces) }
+ let(:protected_environments) { table(:protected_environments) }
+ let(:deploy_access_levels) { table(:protected_environment_deploy_access_levels) }
+
+ let(:user) { users.create!(email: 'email@email.com', name: 'foo', username: 'foo', projects_limit: 0) }
+ let(:group) { groups.create!(name: 'test-group', path: 'test-group') }
+ let(:pe) do
+ protected_environments.create!(name: 'test-pe', group_id: group.id)
+ end
+
+ let!(:invalid_access_level) do
+ deploy_access_levels.create!(
+ access_level: 40,
+ user_id: user.id,
+ group_id: group.id,
+ protected_environment_id: pe.id)
+ end
+
+ let!(:group_access_level) do
+ deploy_access_levels.create!(
+ group_id: group.id,
+ protected_environment_id: pe.id)
+ end
+
+ let!(:user_access_level) do
+ deploy_access_levels.create!(
+ user_id: user.id,
+ protected_environment_id: pe.id)
+ end
+
+ it 'removes invalid access_level entries' do
+ expect { migrate! }.to change {
+ deploy_access_levels.where(
+ protected_environment_id: pe.id,
+ access_level: nil).count
+ }.from(2).to(3)
+
+ expect(invalid_access_level.reload.access_level).to be_nil
+ end
+end
diff --git a/spec/models/work_items/type_spec.rb b/spec/models/work_items/type_spec.rb
index cf2e5d25756..65c6b22f5c2 100644
--- a/spec/models/work_items/type_spec.rb
+++ b/spec/models/work_items/type_spec.rb
@@ -146,4 +146,22 @@ RSpec.describe WorkItems::Type do
it { is_expected.to be_falsey }
end
end
+
+ describe '#default_issue?' do
+ context 'when work item type is default Issue' do
+ let(:work_item_type) { build(:work_item_type, name: described_class::TYPE_NAMES[:issue]) }
+
+ it 'returns true' do
+ expect(work_item_type.default_issue?).to be(true)
+ end
+ end
+
+ context 'when work item type is not Issue' do
+ let(:work_item_type) { build(:work_item_type) }
+
+ it 'returns false' do
+ expect(work_item_type.default_issue?).to be(false)
+ end
+ end
+ end
end
diff --git a/spec/requests/api/graphql/mutations/work_items/update_spec.rb b/spec/requests/api/graphql/mutations/work_items/update_spec.rb
index b33a394d023..271c2b917ad 100644
--- a/spec/requests/api/graphql/mutations/work_items/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/work_items/update_spec.rb
@@ -127,7 +127,9 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
let(:fields) do
<<~FIELDS
workItem {
+ title
description
+ state
widgets {
type
... on WorkItemWidgetDescription {
@@ -179,6 +181,9 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
nodes { id }
}
}
+ ... on WorkItemWidgetDescription {
+ description
+ }
}
}
errors
@@ -201,6 +206,12 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
let(:expected_labels) { [] }
it_behaves_like 'mutation updating work item labels'
+
+ context 'with quick action' do
+ let(:input) { { 'descriptionWidget' => { 'description' => "/remove_label ~\"#{existing_label.name}\"" } } }
+
+ it_behaves_like 'mutation updating work item labels'
+ end
end
context 'when only adding labels' do
@@ -208,6 +219,14 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
let(:expected_labels) { [label1, label2, existing_label] }
it_behaves_like 'mutation updating work item labels'
+
+ context 'with quick action' do
+ let(:input) do
+ { 'descriptionWidget' => { 'description' => "/labels ~\"#{label1.name}\" ~\"#{label2.name}\"" } }
+ end
+
+ it_behaves_like 'mutation updating work item labels'
+ end
end
context 'when adding and removing labels' do
@@ -216,10 +235,46 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
let(:expected_labels) { [label1, label2] }
it_behaves_like 'mutation updating work item labels'
+
+ context 'with quick action' do
+ let(:input) do
+ { 'descriptionWidget' => { 'description' =>
+ "/label ~\"#{label1.name}\" ~\"#{label2.name}\"\n/remove_label ~\"#{existing_label.name}\"" } }
+ end
+
+ it_behaves_like 'mutation updating work item labels'
+ end
+ end
+
+ context 'when the work item type does not support labels widget' do
+ let_it_be(:work_item) { create(:work_item, :task, project: project) }
+
+ let(:input) { { 'descriptionWidget' => { 'description' => "Updating labels.\n/labels ~\"#{label1.name}\"" } } }
+
+ before do
+ stub_const('::WorkItems::Type::WIDGETS_FOR_TYPE', { task: [::WorkItems::Widgets::Description] })
+ end
+
+ it 'ignores the quick action' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.not_to change(work_item.labels, :count)
+
+ expect(work_item.labels).to be_empty
+ expect(mutation_response['workItem']['widgets']).to include(
+ 'description' => "Updating labels.",
+ 'type' => 'DESCRIPTION'
+ )
+ expect(mutation_response['workItem']['widgets']).not_to include(
+ 'labels',
+ 'type' => 'LABELS'
+ )
+ end
end
end
- context 'with due and start date widget input' do
+ context 'with due and start date widget input', :freeze_time do
let(:start_date) { Date.today }
let(:due_date) { 1.week.from_now.to_date }
let(:fields) do
@@ -231,6 +286,9 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
startDate
dueDate
}
+ ... on WorkItemWidgetDescription {
+ description
+ }
}
}
errors
@@ -259,6 +317,80 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
)
end
+ context 'when using quick action' do
+ let(:due_date) { Date.today }
+
+ context 'when removing due date' do
+ let(:input) { { 'descriptionWidget' => { 'description' => "/remove_due_date" } } }
+
+ before do
+ work_item.update!(due_date: due_date)
+ end
+
+ it 'updates start and due date' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.to not_change(work_item, :start_date).and(
+ change(work_item, :due_date).from(due_date).to(nil)
+ )
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['workItem']['widgets']).to include({
+ 'startDate' => nil,
+ 'dueDate' => nil,
+ 'type' => 'START_AND_DUE_DATE'
+ })
+ end
+ end
+
+ context 'when setting due date' do
+ let(:input) { { 'descriptionWidget' => { 'description' => "/due today" } } }
+
+ it 'updates due date' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.to not_change(work_item, :start_date).and(
+ change(work_item, :due_date).from(nil).to(due_date)
+ )
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['workItem']['widgets']).to include({
+ 'startDate' => nil,
+ 'dueDate' => Date.today.to_s,
+ 'type' => 'START_AND_DUE_DATE'
+ })
+ end
+ end
+
+ context 'when the work item type does not support start and due date widget' do
+ let_it_be(:work_item) { create(:work_item, :task, project: project) }
+
+ let(:input) { { 'descriptionWidget' => { 'description' => "Updating due date.\n/due today" } } }
+
+ before do
+ stub_const('::WorkItems::Type::WIDGETS_FOR_TYPE', { task: [::WorkItems::Widgets::Description] })
+ end
+
+ it 'ignores the quick action' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.not_to change(work_item, :due_date)
+
+ expect(mutation_response['workItem']['widgets']).to include(
+ 'description' => "Updating due date.",
+ 'type' => 'DESCRIPTION'
+ )
+ expect(mutation_response['workItem']['widgets']).not_to include({
+ 'dueDate' => nil,
+ 'type' => 'START_AND_DUE_DATE'
+ })
+ end
+ end
+ end
+
context 'when provided input is invalid' do
let(:due_date) { 1.week.ago.to_date }
@@ -516,6 +648,9 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
}
}
}
+ ... on WorkItemWidgetDescription {
+ description
+ }
}
}
errors
@@ -544,6 +679,80 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
}
)
end
+
+ context 'when using quick action' do
+ context 'when assigning a user' do
+ let(:input) { { 'descriptionWidget' => { 'description' => "/assign @#{developer.username}" } } }
+
+ it 'updates the work item assignee' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.to change(work_item, :assignee_ids).from([]).to([developer.id])
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['workItem']['widgets']).to include(
+ {
+ 'type' => 'ASSIGNEES',
+ 'assignees' => {
+ 'nodes' => [
+ { 'id' => developer.to_global_id.to_s, 'username' => developer.username }
+ ]
+ }
+ }
+ )
+ end
+ end
+
+ context 'when unassigning a user' do
+ let(:input) { { 'descriptionWidget' => { 'description' => "/unassign @#{developer.username}" } } }
+
+ before do
+ work_item.update!(assignee_ids: [developer.id])
+ end
+
+ it 'updates the work item assignee' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.to change(work_item, :assignee_ids).from([developer.id]).to([])
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['workItem']['widgets']).to include(
+ 'type' => 'ASSIGNEES',
+ 'assignees' => {
+ 'nodes' => []
+ }
+ )
+ end
+ end
+ end
+
+ context 'when the work item type does not support the assignees widget' do
+ let_it_be(:work_item) { create(:work_item, :task, project: project) }
+
+ let(:input) do
+ { 'descriptionWidget' => { 'description' => "Updating assignee.\n/assign @#{developer.username}" } }
+ end
+
+ before do
+ stub_const('::WorkItems::Type::WIDGETS_FOR_TYPE', { task: [::WorkItems::Widgets::Description] })
+ end
+
+ it 'ignores the quick action' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.not_to change(work_item, :assignee_ids)
+
+ expect(mutation_response['workItem']['widgets']).to include({
+ 'description' => "Updating assignee.",
+ 'type' => 'DESCRIPTION'
+ }
+ )
+ expect(mutation_response['workItem']['widgets']).not_to include({ 'type' => 'ASSIGNEES' })
+ end
+ end
end
context 'when updating milestone' do
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index 7ab2046b6be..abb59ad7ebf 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -565,6 +565,36 @@ RSpec.describe Issues::CreateService do
end
context 'Quick actions' do
+ context 'as work item' do
+ let(:opts) do
+ {
+ title: "My work item",
+ work_item_type: work_item_type,
+ description: "/shrug"
+ }
+ end
+
+ context 'when work item type is not the default Issue' do
+ let(:work_item_type) { create(:work_item_type, namespace: project.namespace) }
+
+ it 'saves the work item without applying the quick action' do
+ expect(result).to be_success
+ expect(issue).to be_persisted
+ expect(issue.description).to eq("/shrug")
+ end
+ end
+
+ context 'when work item type is the default Issue' do
+ let(:work_item_type) { WorkItems::Type.default_by_type(:issue) }
+
+ it 'saves the work item and applies the quick action' do
+ expect(result).to be_success
+ expect(issue).to be_persisted
+ expect(issue.description).to eq(" ¯\\_(ツ)_/¯")
+ end
+ end
+ end
+
context 'with assignee, milestone, and contact in params and command' do
let_it_be(:contact) { create(:contact, group: group) }
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index 7fd09cc2779..f1859b2208c 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -1475,5 +1475,31 @@ RSpec.describe Issues::UpdateService, :mailer do
let(:existing_issue) { create(:issue, project: project) }
let(:issuable) { described_class.new(project: project, current_user: user, params: params).execute(existing_issue) }
end
+
+ context 'with quick actions' do
+ context 'as work item' do
+ let(:opts) { { description: "/shrug" } }
+
+ context 'when work item type is not the default Issue' do
+ let(:issue) { create(:work_item, :task, description: "") }
+
+ it 'does not apply the quick action' do
+ expect do
+ update_issue(opts)
+ end.to change(issue, :description).to("/shrug")
+ end
+ end
+
+ context 'when work item type is the default Issue' do
+ let(:issue) { create(:work_item, :issue, description: "") }
+
+ it 'does not apply the quick action' do
+ expect do
+ update_issue(opts)
+ end.to change(issue, :description).to(" ¯\\_(ツ)_/¯")
+ end
+ end
+ end
+ end
end
end
diff --git a/spec/support/shared_examples/graphql/mutations/work_items/update_description_widget_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/work_items/update_description_widget_shared_examples.rb
index f672ec7f5ac..2ec48aa405b 100644
--- a/spec/support/shared_examples/graphql/mutations/work_items/update_description_widget_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/mutations/work_items/update_description_widget_shared_examples.rb
@@ -31,4 +31,88 @@ RSpec.shared_examples 'update work item description widget' do
expect(mutation_response['errors']).to match_array(['Description error message'])
end
end
+
+ context 'when the edited description includes quick action(s)' do
+ let(:input) { { 'descriptionWidget' => { 'description' => new_description } } }
+
+ shared_examples 'quick action is applied' do
+ before do
+ post_graphql_mutation(mutation, current_user: current_user)
+ end
+
+ it 'applies the quick action(s)' do
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['workItem']).to include(expected_response)
+ end
+ end
+
+ context 'with /title quick action' do
+ it_behaves_like 'quick action is applied' do
+ let(:new_description) { "updated description\n/title updated title" }
+ let(:filtered_description) { "updated description" }
+
+ let(:expected_response) do
+ {
+ 'title' => 'updated title',
+ 'widgets' => include({
+ 'description' => filtered_description,
+ 'type' => 'DESCRIPTION'
+ })
+ }
+ end
+ end
+ end
+
+ context 'with /shrug, /tableflip and /cc quick action' do
+ it_behaves_like 'quick action is applied' do
+ let(:new_description) { "/tableflip updated description\n/shrug\n/cc @#{developer.username}" }
+ # note: \cc performs no action since 15.0
+ let(:filtered_description) { "updated description (╯°□°)╯︵ ┻━┻\n ¯\\_(ツ)_/¯\n/cc @#{developer.username}" }
+ let(:expected_response) do
+ {
+ 'widgets' => include({
+ 'description' => filtered_description,
+ 'type' => 'DESCRIPTION'
+ })
+ }
+ end
+ end
+ end
+
+ context 'with /close' do
+ it_behaves_like 'quick action is applied' do
+ let(:new_description) { "Resolved work item.\n/close" }
+ let(:filtered_description) { "Resolved work item." }
+ let(:expected_response) do
+ {
+ 'state' => 'CLOSED',
+ 'widgets' => include({
+ 'description' => filtered_description,
+ 'type' => 'DESCRIPTION'
+ })
+ }
+ end
+ end
+ end
+
+ context 'with /reopen' do
+ before do
+ work_item.close!
+ end
+
+ it_behaves_like 'quick action is applied' do
+ let(:new_description) { "Re-opening this work item.\n/reopen" }
+ let(:filtered_description) { "Re-opening this work item." }
+ let(:expected_response) do
+ {
+ 'state' => 'OPEN',
+ 'widgets' => include({
+ 'description' => filtered_description,
+ 'type' => 'DESCRIPTION'
+ })
+ }
+ end
+ end
+ end
+ end
end