Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-04-19 15:09:04 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-04-19 15:09:04 +0300
commitc6af94ea4ea649171ff930b6bf94c73a5d03edb9 (patch)
treeceef77238b3a275a3a32b4e9f982b6d2f27e0c6b
parent3257ae3af07a4ad026be3c868e74ff82866fc400 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.rubocop_manual_todo.yml27
-rw-r--r--Gemfile3
-rw-r--r--Gemfile.lock6
-rwxr-xr-xRakefile2
-rw-r--r--app/assets/javascripts/batch_comments/mixins/resolved_status.js8
-rw-r--r--app/assets/javascripts/invite_member/init_invite_member_modal.js5
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue76
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue46
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue66
-rw-r--r--app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue90
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue50
-rw-r--r--app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql5
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql13
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js10
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_notification.js29
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js5
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss1
-rw-r--r--app/assets/stylesheets/pages/runners.scss13
-rw-r--r--app/controllers/projects/issues_controller.rb1
-rw-r--r--app/models/project_feature.rb2
-rw-r--r--app/models/todo.rb14
-rw-r--r--app/services/todo_service.rb12
-rw-r--r--app/services/users/update_todo_count_cache_service.rb34
-rw-r--r--app/views/ci/variables/_index.html.haml2
-rw-r--r--app/views/devise/mailer/password_change.html.haml9
-rw-r--r--app/views/devise/mailer/password_change.text.erb8
-rw-r--r--app/views/devise/passwords/edit.html.haml16
-rw-r--r--app/views/devise/sessions/new.html.haml2
-rw-r--r--app/views/devise/sessions/two_factor.html.haml10
-rw-r--r--app/views/projects/pipelines/show.html.haml1
-rwxr-xr-xbin/sidekiq-cluster1
-rw-r--r--changelogs/unreleased/321100-centralize-invalid-ci-state-in-authoring-section.yml5
-rw-r--r--changelogs/unreleased/326665_enable_projects_post_creation_worker.yml5
-rw-r--r--changelogs/unreleased/Externalize-stings-in-password_change-html-haml.yml5
-rw-r--r--changelogs/unreleased/Externalize-strings-in-passwords-edit-html-haml.yml5
-rw-r--r--changelogs/unreleased/Externalize-strings-in-sessions-new-html-haml.yml5
-rw-r--r--changelogs/unreleased/Externalize-strings-in-sessions-two_factor-html-haml.yml5
-rw-r--r--changelogs/unreleased/bulk-update-user-todos-count-cache.yml5
-rw-r--r--changelogs/unreleased/issue-325831-make-searchQueryService-paramter-optional.yml5
-rw-r--r--changelogs/unreleased/mr-thread-comment-button.yml5
-rw-r--r--changelogs/unreleased/ntepluhina-assignees-feature-flag.yml5
-rw-r--r--changelogs/unreleased/remove_epics_index.yml5
-rw-r--r--changelogs/unreleased/tor-defect-single-quote-escapes.yml5
-rw-r--r--config/feature_flags/development/issue_assignees_widget.yml8
-rw-r--r--config/feature_flags/development/projects_post_creation_worker.yml2
-rw-r--r--config/metrics/license/20210216175602_installation_type.yml4
-rw-r--r--config/metrics/settings/20210216174829_smtp_server.yml (renamed from config/metrics/counts_all/20210216174829_smtp_server.yml)9
-rw-r--r--db/migrate/20210415144538_remove_index_epics_on_group_id_from_epics.rb17
-rw-r--r--db/schema_migrations/202104151445381
-rw-r--r--db/structure.sql2
-rw-r--r--doc/api/graphql/reference/index.md1
-rw-r--r--doc/development/usage_ping/dictionary.md4
-rw-r--r--doc/user/admin_area/custom_project_templates.md2
-rw-r--r--doc/user/group/custom_project_templates.md2
-rw-r--r--lib/api/concerns/packages/nuget_endpoints.rb2
-rw-r--r--lib/gitlab/graphql/authorize/connection_filter_extension.rb2
-rw-r--r--lib/gitlab/sidekiq_cluster/cli.rb14
-rw-r--r--lib/gitlab/sidekiq_config/cli_methods.rb90
-rw-r--r--lib/gitlab/sidekiq_config/worker_matcher.rb86
-rw-r--r--lib/tasks/brakeman.rake13
-rw-r--r--lib/tasks/gitlab/test.rake17
-rw-r--r--lib/tasks/test.rake13
-rw-r--r--locale/gitlab.pot50
-rw-r--r--spec/features/issues/issue_sidebar_spec.rb218
-rw-r--r--spec/features/issues/user_edits_issue_spec.rb180
-rw-r--r--spec/features/projects/settings/forked_project_settings_spec.rb3
-rw-r--r--spec/features/projects_spec.rb20
-rw-r--r--spec/frontend/notes/components/note_actions_spec.js16
-rw-r--r--spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js21
-rw-r--r--spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js24
-rw-r--r--spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js57
-rw-r--r--spec/frontend/pipelines/notification/pipeline_notification_spec.js79
-rw-r--r--spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js29
-rw-r--r--spec/graphql/features/authorization_spec.rb20
-rw-r--r--spec/graphql/resolvers/namespace_projects_resolver_spec.rb2
-rw-r--r--spec/knapsack_env.rb37
-rw-r--r--spec/lib/gitlab/sidekiq_cluster/cli_spec.rb2
-rw-r--r--spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb84
-rw-r--r--spec/lib/gitlab/sidekiq_config/worker_matcher_spec.rb129
-rw-r--r--spec/models/todo_spec.rb16
-rw-r--r--spec/requests/api/nuget_group_packages_spec.rb4
-rw-r--r--spec/services/todo_service_spec.rb18
-rw-r--r--spec/services/users/update_todo_count_cache_service_spec.rb61
-rw-r--r--spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb2
-rw-r--r--workhorse/internal/upload/rewrite.go10
86 files changed, 1343 insertions, 657 deletions
diff --git a/.rubocop_manual_todo.yml b/.rubocop_manual_todo.yml
index 185bbb36e60..c4e37e6c42f 100644
--- a/.rubocop_manual_todo.yml
+++ b/.rubocop_manual_todo.yml
@@ -2911,33 +2911,6 @@ Gitlab/NamespacedClass:
- 'spec/tasks/gitlab/task_helpers_spec.rb'
- 'spec/uploaders/object_storage_spec.rb'
-# WIP: https://gitlab.com/gitlab-org/gitlab/-/issues/322739
-Style/HashTransformation:
- Exclude:
- - 'ee/app/models/ee/ci/build.rb'
- - 'ee/app/models/productivity_analytics.rb'
- - 'ee/app/models/sca/license_compliance.rb'
- - 'ee/app/services/security/store_report_service.rb'
- - 'ee/lib/ee/gitlab/auth/ldap/sync/group.rb'
- - 'ee/lib/ee/gitlab/usage_data.rb'
- - 'ee/lib/gitlab/custom_file_templates.rb'
- - 'ee/spec/elastic_integration/global_search_spec.rb'
- - 'ee/spec/lib/ee/gitlab/application_context_spec.rb'
- - 'spec/lib/atlassian/jira_connect/serializers/pull_request_entity_spec.rb'
- - 'spec/lib/gitlab/ci/status/composite_spec.rb'
- - 'spec/lib/gitlab/conflict/file_spec.rb'
- - 'spec/lib/gitlab/import_export/project/tree_restorer_spec.rb'
- - 'spec/models/concerns/featurable_spec.rb'
- - 'spec/models/event_spec.rb'
- - 'spec/models/packages/dependency_spec.rb'
- - 'spec/requests/api/graphql/project/alert_management/alert/assignees_spec.rb'
- - 'spec/requests/api/graphql/project/alert_management/alert/notes_spec.rb'
- - 'spec/requests/api/graphql/project/alert_management/alert/todos_spec.rb'
- - 'spec/requests/api/projects_spec.rb'
- - 'spec/support/helpers/graphql_helpers.rb'
- - 'spec/support/import_export/project_tree_expectations.rb'
- - 'spec/support/shared_contexts/services/projects/container_repository/delete_tags_service_shared_context.rb'
-
Style/ClassEqualityComparison:
Exclude:
- spec/lib/peek/views/active_record_spec.rb
diff --git a/Gemfile b/Gemfile
index ca9a3443639..876bcfaabf9 100644
--- a/Gemfile
+++ b/Gemfile
@@ -342,7 +342,6 @@ group :metrics do
end
group :development do
- gem 'brakeman', '~> 4.10.0', require: false
gem 'lefthook', '~> 0.7.0', require: false
gem 'letter_opener_web', '~> 1.4.0'
@@ -383,7 +382,7 @@ group :development, :test do
gem 'benchmark-ips', '~> 2.3.0', require: false
- gem 'knapsack', '~> 1.17'
+ gem 'knapsack', '~> 1.21.1'
gem 'crystalball', '~> 0.7.0', require: false
gem 'simple_po_parser', '~> 1.1.2', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 4ad1c2420a0..19fad573b63 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -151,7 +151,6 @@ GEM
bootstrap_form (4.2.0)
actionpack (>= 5.0)
activemodel (>= 5.0)
- brakeman (4.10.1)
browser (4.2.0)
builder (3.2.4)
bullet (6.1.3)
@@ -672,7 +671,7 @@ GEM
kaminari-core (= 1.2.1)
kaminari-core (1.2.1)
kgio (2.11.3)
- knapsack (1.17.0)
+ knapsack (1.21.1)
rake
kramdown (2.3.1)
rexml
@@ -1369,7 +1368,6 @@ DEPENDENCIES
better_errors (~> 2.9.0)
bootsnap (~> 1.4.6)
bootstrap_form (~> 4.2.0)
- brakeman (~> 4.10.0)
browser (~> 4.2)
bullet (~> 6.1.3)
bundler-audit (~> 0.7.0.1)
@@ -1476,7 +1474,7 @@ DEPENDENCIES
json_schemer (~> 0.2.12)
jwt (~> 2.1.0)
kaminari (~> 1.0)
- knapsack (~> 1.17)
+ knapsack (~> 1.21.1)
kramdown (~> 2.3.1)
kubeclient (~> 4.9.1)
lefthook (~> 0.7.0)
diff --git a/Rakefile b/Rakefile
index 445542e5c00..eb2f158972d 100755
--- a/Rakefile
+++ b/Rakefile
@@ -4,6 +4,8 @@
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
+Rake::TaskManager.record_task_metadata = true
+
require File.expand_path('config/application', __dir__)
relative_url_conf = File.expand_path('config/initializers/relative_url', __dir__)
diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js
index 85bdf6b7a36..bec360e3b2e 100644
--- a/app/assets/javascripts/batch_comments/mixins/resolved_status.js
+++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js
@@ -50,12 +50,18 @@ export default {
return this.resolveDiscussion ? 'is-resolving-discussion' : 'is-unresolving-discussion';
},
resolveButtonTitle() {
+ const escapeParameters = false;
+
if (this.isDraft || this.discussionId) return this.resolvedStatusMessage;
let title = __('Resolve thread');
if (this.resolvedBy) {
- title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name });
+ title = sprintf(
+ __('Resolved by %{name}'),
+ { name: this.resolvedBy.name },
+ escapeParameters,
+ );
}
return title;
diff --git a/app/assets/javascripts/invite_member/init_invite_member_modal.js b/app/assets/javascripts/invite_member/init_invite_member_modal.js
index 108f636ee3e..a50d31c9e7a 100644
--- a/app/assets/javascripts/invite_member/init_invite_member_modal.js
+++ b/app/assets/javascripts/invite_member/init_invite_member_modal.js
@@ -5,10 +5,13 @@ import InviteMemberModal from './components/invite_member_modal.vue';
Vue.use(GlToast);
+const isAssigneesWidgetShown =
+ (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget;
+
export default function initInviteMembersModal() {
const el = document.querySelector('.js-invite-member-modal');
- if (!el || isInDesignPage() || isInIssuePage()) {
+ if (!el || isAssigneesWidgetShown) {
return false;
}
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index a25862a587b..a70bac94b71 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -390,7 +390,7 @@ export default {
<gl-button
:disabled="isDisabled"
category="primary"
- variant="success"
+ variant="confirm"
class="gl-mr-3"
data-qa-selector="start_review_button"
@click="handleAddToReview"
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
index d61136cda8d..455990f2791 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
@@ -1,29 +1,19 @@
<script>
-import { GlAlert, GlIcon } from '@gitlab/ui';
+import { GlIcon } from '@gitlab/ui';
import { uniqueId } from 'lodash';
-import { __, s__ } from '~/locale';
-import { DEFAULT, INVALID_CI_CONFIG } from '~/pipelines/constants';
+import { s__ } from '~/locale';
import EditorLite from '~/vue_shared/components/editor_lite.vue';
export default {
i18n: {
viewOnlyMessage: s__('Pipelines|Merged YAML is view only'),
},
- errorTexts: {
- [INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'),
- [DEFAULT]: __('An unknown error occurred.'),
- },
components: {
EditorLite,
- GlAlert,
GlIcon,
},
inject: ['ciConfigPath'],
props: {
- isValid: {
- type: Boolean,
- required: true,
- },
ciConfigData: {
type: Object,
required: true,
@@ -35,66 +25,30 @@ export default {
};
},
computed: {
- failure() {
- switch (this.failureType) {
- case INVALID_CI_CONFIG:
- return this.$options.errorTexts[INVALID_CI_CONFIG];
- default:
- return this.$options.errorTexts[DEFAULT];
- }
- },
fileGlobalId() {
return `${this.ciConfigPath}-${uniqueId()}`;
},
- hasError() {
- return this.failureType;
- },
mergedYaml() {
return this.ciConfigData.mergedYaml;
},
},
- watch: {
- ciConfigData: {
- immediate: true,
- handler() {
- if (!this.isValid) {
- this.reportFailure(INVALID_CI_CONFIG);
- } else if (this.hasError) {
- this.resetFailure();
- }
- },
- },
- },
- methods: {
- reportFailure(errorType) {
- this.failureType = errorType;
- },
- resetFailure() {
- this.failureType = null;
- },
- },
};
</script>
<template>
<div>
- <gl-alert v-if="hasError" variant="danger" :dismissible="false">
- {{ failure }}
- </gl-alert>
- <div v-else>
- <div class="gl-display-flex gl-align-items-center">
- <gl-icon :size="16" name="lock" class="gl-text-gray-500 gl-mr-3" />
- {{ $options.i18n.viewOnlyMessage }}
- </div>
- <div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1">
- <editor-lite
- ref="editor"
- :value="mergedYaml"
- :file-name="ciConfigPath"
- :file-global-id="fileGlobalId"
- :editor-options="{ readOnly: true }"
- v-on="$listeners"
- />
- </div>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-icon :size="16" name="lock" class="gl-text-gray-500 gl-mr-3" />
+ {{ $options.i18n.viewOnlyMessage }}
+ </div>
+ <div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1">
+ <editor-lite
+ ref="editor"
+ :value="mergedYaml"
+ :file-name="ciConfigPath"
+ :file-global-id="fileGlobalId"
+ :editor-options="{ readOnly: true }"
+ v-on="$listeners"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
index 760d395ff2c..5acb3355b23 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -1,11 +1,13 @@
<script>
-import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import { s__ } from '~/locale';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
CREATE_TAB,
+ EDITOR_APP_STATUS_EMPTY,
EDITOR_APP_STATUS_ERROR,
+ EDITOR_APP_STATUS_INVALID,
EDITOR_APP_STATUS_LOADING,
EDITOR_APP_STATUS_VALID,
LINT_TAB,
@@ -24,6 +26,17 @@ export default {
tabGraph: s__('Pipelines|Visualize'),
tabLint: s__('Pipelines|Lint'),
tabMergedYaml: s__('Pipelines|View merged YAML'),
+ empty: {
+ visualization: s__(
+ 'PipelineEditor|The pipeline visualization is displayed when the CI/CD configuration file has valid syntax.',
+ ),
+ lint: s__(
+ 'PipelineEditor|The CI/CD configuration is continuously validated. Errors and warnings are displayed when the CI/CD configuration file is not empty.',
+ ),
+ merge: s__(
+ 'PipelineEditor|The merged YAML view is displayed when the CI/CD configuration file has valid syntax.',
+ ),
+ },
},
errorTexts: {
loadMergedYaml: s__('Pipelines|Could not load merged YAML content'),
@@ -40,7 +53,6 @@ export default {
EditorTab,
GlAlert,
GlLoadingIcon,
- GlTab,
GlTabs,
PipelineGraph,
TextEditor,
@@ -66,6 +78,12 @@ export default {
// Not an invalid config and with `mergedYaml` data missing
return this.appStatus === EDITOR_APP_STATUS_ERROR;
},
+ isEmpty() {
+ return this.appStatus === EDITOR_APP_STATUS_EMPTY;
+ },
+ isInvalid() {
+ return this.appStatus === EDITOR_APP_STATUS_INVALID;
+ },
isValid() {
return this.appStatus === EDITOR_APP_STATUS_VALID;
},
@@ -91,9 +109,12 @@ export default {
>
<text-editor :value="ciFileContent" v-on="$listeners" />
</editor-tab>
- <gl-tab
+ <editor-tab
v-if="glFeatures.ciConfigVisualizationTab"
class="gl-mb-3"
+ :empty-message="$options.i18n.empty.visualization"
+ :is-empty="isEmpty"
+ :is-invalid="isInvalid"
:title="$options.i18n.tabGraph"
lazy
data-testid="visualization-tab"
@@ -101,9 +122,11 @@ export default {
>
<gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
<pipeline-graph v-else :pipeline-data="ciConfigData" />
- </gl-tab>
+ </editor-tab>
<editor-tab
class="gl-mb-3"
+ :empty-message="$options.i18n.empty.lint"
+ :is-empty="isEmpty"
:title="$options.i18n.tabLint"
data-testid="lint-tab"
@click="setCurrentTab($options.tabConstants.LINT_TAB)"
@@ -111,9 +134,13 @@ export default {
<gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
<ci-lint v-else :is-valid="isValid" :ci-config="ciConfigData" />
</editor-tab>
- <gl-tab
+ <editor-tab
v-if="glFeatures.ciConfigMergedTab"
class="gl-mb-3"
+ :empty-message="$options.i18n.empty.merge"
+ :keep-component-mounted="false"
+ :is-empty="isEmpty"
+ :is-invalid="isInvalid"
:title="$options.i18n.tabMergedYaml"
lazy
data-testid="merged-tab"
@@ -123,12 +150,7 @@ export default {
<gl-alert v-else-if="hasAppError" variant="danger" :dismissible="false">
{{ $options.errorTexts.loadMergedYaml }}
</gl-alert>
- <ci-config-merged-preview
- v-else
- :is-valid="isValid"
- :ci-config-data="ciConfigData"
- v-on="$listeners"
- />
- </gl-tab>
+ <ci-config-merged-preview v-else :ci-config-data="ciConfigData" v-on="$listeners" />
+ </editor-tab>
</gl-tabs>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue
index ce8ee7493fe..7c032441a04 100644
--- a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue
+++ b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue
@@ -1,6 +1,6 @@
<script>
-import { GlTab } from '@gitlab/ui';
-
+import { GlAlert, GlTab } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
/**
* Wrapper of <gl-tab> to optionally lazily render this tab's content
* when its shown **without dismounting after its hidden**.
@@ -10,10 +10,10 @@ import { GlTab } from '@gitlab/ui';
* API is the same as <gl-tab>, for example:
*
* <gl-tabs>
- * <editor-tab title="Tab 1" :lazy="true">
+ * <editor-tab title="Tab 1" lazy>
* lazily mounted content (gets mounted if this is first tab)
* </editor-tab>
- * <editor-tab title="Tab 2" :lazy="true">
+ * <editor-tab title="Tab 2" lazy>
* lazily mounted content
* </editor-tab>
* <editor-tab title="Tab 3">
@@ -25,10 +25,26 @@ import { GlTab } from '@gitlab/ui';
* so it's contents are not dismounted.
*
* lazy is "false" by default, as in <gl-tab>.
+ *
+ * It is also possible to pass the `isEmpty` and or `isInvalid` to let
+ * the tab component handle that state on its own. For example:
+ *
+ * * <gl-tabs>
+ * <editor-tab-with-status title="Tab 1" :is-empty="isEmpty" :is-invalid="isInvalid">
+ * ...
+ * </editor-tab-with-status>
+ * Will be the same as normal, except it will only render the slot component
+ * if the status is not empty and not invalid. In any of these 2 cases, it will render
+ * a generic component and avoid mounting whatever it received in the slot.
+ * </gl-tabs>
*/
export default {
+ i18n: {
+ invalid: __('Your CI/CD configuration syntax is invalid. View Lint tab for more details.'),
+ },
components: {
+ GlAlert,
GlTab,
// Use a small renderless component to know when the tab content mounts because:
// - gl-tab always gets mounted, even if lazy is `true`. See:
@@ -40,29 +56,63 @@ export default {
},
inheritAttrs: false,
props: {
+ emptyMessage: {
+ type: String,
+ required: false,
+ default: s__(
+ 'PipelineEditor|This tab will be usable when the CI/CD configuration file is populated with valid syntax.',
+ ),
+ },
+ isEmpty: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
+ isInvalid: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
lazy: {
type: Boolean,
required: false,
default: false,
},
+ keepComponentMounted: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
isLazy: this.lazy,
};
},
+ computed: {
+ slots() {
+ return Object.keys(this.$slots);
+ },
+ },
methods: {
onContentMounted() {
// When a child is first mounted make the entire tab
- // permanently mounted by setting 'lazy' to false.
- this.isLazy = false;
+ // permanently mounted by setting 'lazy' to false unless
+ // explicitly opted out.
+ if (this.keepComponentMounted) {
+ this.isLazy = false;
+ }
},
},
};
</script>
<template>
<gl-tab :lazy="isLazy" v-bind="$attrs" v-on="$listeners">
- <slot v-for="slot in Object.keys($slots)" :name="slot"></slot>
- <mount-spy @hook:mounted="onContentMounted" />
+ <gl-alert v-if="isEmpty" variant="tip">{{ emptyMessage }}</gl-alert>
+ <gl-alert v-else-if="isInvalid" variant="danger">{{ $options.i18n.invalid }}</gl-alert>
+ <template v-else>
+ <slot v-for="slot in slots" :name="slot"></slot>
+ <mount-spy @hook:mounted="onContentMounted" />
+ </template>
</gl-tab>
</template>
diff --git a/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue
new file mode 100644
index 00000000000..6982586ab12
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue
@@ -0,0 +1,90 @@
+<script>
+import { GlBanner, GlLink, GlSprintf } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import DismissPipelineNotification from '../../graphql/mutations/dismiss_pipeline_notification.graphql';
+import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql';
+
+const featureName = 'pipeline_needs_banner';
+const enumFeatureName = featureName.toUpperCase();
+
+export default {
+ i18n: {
+ title: __('View job dependencies in the pipeline graph!'),
+ description: __(
+ 'You can now group jobs in the pipeline graph based on which jobs are configured to run first, if you use the %{codeStart}needs:%{codeEnd} keyword to establish job dependencies in your CI/CD pipelines. %{linkStart}Learn how to speed up your pipeline with needs.%{linkEnd}',
+ ),
+ buttonText: __('Provide feedback'),
+ },
+ components: {
+ GlBanner,
+ GlLink,
+ GlSprintf,
+ },
+ apollo: {
+ callouts: {
+ query: getUserCallouts,
+ update(data) {
+ return data?.currentUser?.callouts?.nodes.map((c) => c.featureName);
+ },
+ error() {
+ this.hasError = true;
+ },
+ },
+ },
+ inject: ['dagDocPath'],
+ data() {
+ return {
+ callouts: [],
+ dismissedAlert: false,
+ hasError: false,
+ };
+ },
+ computed: {
+ showBanner() {
+ return (
+ !this.$apollo.queries.callouts?.loading &&
+ !this.hasError &&
+ !this.dismissedAlert &&
+ !this.callouts.includes(enumFeatureName)
+ );
+ },
+ },
+ methods: {
+ handleClose() {
+ this.dismissedAlert = true;
+ try {
+ this.$apollo.mutate({
+ mutation: DismissPipelineNotification,
+ variables: {
+ featureName,
+ },
+ });
+ } catch {
+ createFlash(__('There was a problem dismissing this notification.'));
+ }
+ },
+ },
+};
+</script>
+<template>
+ <gl-banner
+ v-if="showBanner"
+ :title="$options.i18n.title"
+ :button-text="$options.i18n.buttonText"
+ button-link="https://gitlab.com/gitlab-org/gitlab/-/issues/327688"
+ variant="introduction"
+ @close="handleClose"
+ >
+ <p>
+ <gl-sprintf :message="$options.i18n.description">
+ <template #link="{ content }">
+ <gl-link :href="dagDocPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-banner>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
index 1f8c4a9aa8b..3ba0d7d0120 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
@@ -1,8 +1,7 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale';
-import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
-import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants';
+import { DRAW_FAILURE, DEFAULT } from '../../constants';
import LinksLayer from '../graph_shared/links_layer.vue';
import JobPill from './job_pill.vue';
import StagePill from './stage_pill.vue';
@@ -21,10 +20,6 @@ export default {
errorTexts: {
[DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
[DEFAULT]: __('An unknown error occurred.'),
- [EMPTY_PIPELINE_DATA]: __(
- 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.',
- ),
- [INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'),
},
props: {
pipelineData: {
@@ -55,18 +50,6 @@ export default {
variant: 'danger',
dismissible: true,
};
- case EMPTY_PIPELINE_DATA:
- return {
- text: this.$options.errorTexts[EMPTY_PIPELINE_DATA],
- variant: 'tip',
- dismissible: false,
- };
- case INVALID_CI_CONFIG:
- return {
- text: this.$options.errorTexts[INVALID_CI_CONFIG],
- variant: 'danger',
- dismissible: false,
- };
default:
return {
text: this.$options.errorTexts[DEFAULT],
@@ -81,18 +64,6 @@ export default {
hasHighlightedJob() {
return Boolean(this.highlightedJob);
},
- hideGraph() {
- // We won't even try to render the graph with these condition
- // because it would cause additional errors down the line for the user
- // which is confusing.
- return this.isPipelineDataEmpty || this.isInvalidCiConfig;
- },
- isInvalidCiConfig() {
- return this.pipelineData?.status === CI_CONFIG_STATUS_INVALID;
- },
- isPipelineDataEmpty() {
- return !this.isInvalidCiConfig && this.pipelineStages.length === 0;
- },
pipelineStages() {
return this.pipelineData?.stages || [];
},
@@ -101,15 +72,9 @@ export default {
pipelineData: {
immediate: true,
handler() {
- if (this.isPipelineDataEmpty) {
- this.reportFailure(EMPTY_PIPELINE_DATA);
- } else if (this.isInvalidCiConfig) {
- this.reportFailure(INVALID_CI_CONFIG);
- } else {
- this.$nextTick(() => {
- this.computeGraphDimensions();
- });
- }
+ this.$nextTick(() => {
+ this.computeGraphDimensions();
+ });
},
},
},
@@ -172,12 +137,7 @@ export default {
>
{{ failure.text }}
</gl-alert>
- <div
- v-if="!hideGraph"
- :id="containerId"
- :ref="$options.CONTAINER_REF"
- data-testid="graph-container"
- >
+ <div :id="containerId" :ref="$options.CONTAINER_REF" data-testid="graph-container">
<links-layer
:pipeline-data="pipelineStages"
:pipeline-id="$options.PIPELINE_ID"
diff --git a/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql
new file mode 100644
index 00000000000..e4fd55a28be
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql
@@ -0,0 +1,5 @@
+mutation DismissPipelineNotification($featureName: String!) {
+ userCalloutCreate(input: { featureName: $featureName }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql
new file mode 100644
index 00000000000..12b391e41ac
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql
@@ -0,0 +1,13 @@
+query getUser {
+ currentUser {
+ id
+ __typename
+ callouts {
+ __typename
+ nodes {
+ __typename
+ featureName
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index cc53532b554..a2bc049c3c7 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -8,6 +8,7 @@ import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import createDagApp from './pipeline_details_dag';
import { createPipelinesDetailApp } from './pipeline_details_graph';
import { createPipelineHeaderApp } from './pipeline_details_header';
+import { createPipelineNotificationApp } from './pipeline_details_notification';
import { apolloProvider } from './pipeline_shared_client';
import createTestReportsStore from './stores/test_reports';
import { reportToSentry } from './utils';
@@ -18,6 +19,7 @@ const SELECTORS = {
PIPELINE_DETAILS: '.js-pipeline-details-vue',
PIPELINE_GRAPH: '#js-pipeline-graph-vue',
PIPELINE_HEADER: '#js-pipeline-header-vue',
+ PIPELINE_NOTIFICATION: '#js-pipeline-notification',
PIPELINE_TESTS: '#js-pipeline-tests-detail',
};
@@ -93,6 +95,14 @@ export default async function initPipelineDetailsBundle() {
Flash(__('An error occurred while loading a section of this page.'));
}
+ if (gon.features.pipelineGraphLayersView) {
+ try {
+ createPipelineNotificationApp(SELECTORS.PIPELINE_NOTIFICATION, apolloProvider);
+ } catch {
+ Flash(__('An error occurred while loading a section of this page.'));
+ }
+ }
+
if (canShowNewPipelineDetails) {
try {
createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset);
diff --git a/app/assets/javascripts/pipelines/pipeline_details_notification.js b/app/assets/javascripts/pipelines/pipeline_details_notification.js
new file mode 100644
index 00000000000..be234e8972d
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_notification.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import PipelineNotification from './components/notification/pipeline_notification.vue';
+
+Vue.use(VueApollo);
+
+export const createPipelineNotificationApp = (elSelector, apolloProvider) => {
+ const el = document.querySelector(elSelector);
+
+ if (!el) {
+ return;
+ }
+
+ const { dagDocPath } = el?.dataset;
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ PipelineNotification,
+ },
+ provide: {
+ dagDocPath,
+ },
+ apolloProvider,
+ render(createElement) {
+ return createElement('pipeline-notification');
+ },
+ });
+};
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 643a57bf6a8..1304e84814b 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -410,8 +410,11 @@ function mountCopyEmailComponent() {
});
}
+const isAssigneesWidgetShown =
+ (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget;
+
export function mountSidebar(mediator) {
- if (isInIssuePage() || isInDesignPage()) {
+ if (isAssigneesWidgetShown) {
mountAssigneesComponent();
} else {
mountAssigneesComponentDeprecated(mediator);
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index 4a15e0eb458..fa5ab590232 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -26,7 +26,6 @@
@import './pages/projects';
@import './pages/prometheus';
@import './pages/registry';
-@import './pages/runners';
@import './pages/search';
@import './pages/service_desk';
@import './pages/settings';
diff --git a/app/assets/stylesheets/pages/runners.scss b/app/assets/stylesheets/pages/runners.scss
deleted file mode 100644
index d01c9a9fd42..00000000000
--- a/app/assets/stylesheets/pages/runners.scss
+++ /dev/null
@@ -1,13 +0,0 @@
-.runner-state {
- padding: 6px 12px;
- margin-right: 10px;
- color: $white;
-
- &.runner-state-shared {
- background: $green-400;
- }
-
- &.runner-state-specific {
- background: $blue-400;
- }
-}
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 74d993fb198..cae5cc411bc 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -54,6 +54,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_to_gon_attributes(:features, real_time_feature_flag, real_time_enabled)
push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml)
record_experiment_user(:invite_members_version_b)
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index 09a20c3509f..15f6bedfc2e 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -20,7 +20,7 @@ class ProjectFeature < ApplicationRecord
container_registry
].freeze
- EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance]).freeze
+ EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance, :pages]).freeze
set_available_features(FEATURES)
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 6d6ee07cfaa..c8138587d83 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -152,6 +152,20 @@ class Todo < ApplicationRecord
def pluck_user_id
pluck(:user_id)
end
+
+ # Count todos grouped by user_id and state, using an UNION query
+ # so we can utilize the partial indexes for each state.
+ def count_grouped_by_user_id_and_state
+ grouped_count = select(:user_id, 'count(id) AS count').group(:user_id)
+
+ done = grouped_count.where(state: :done).select("'done' AS state")
+ pending = grouped_count.where(state: :pending).select("'pending' AS state")
+ union = unscoped.from_union([done, pending], remove_duplicates: false)
+
+ connection.select_all(union).each_with_object({}) do |row, counts|
+ counts[[row['user_id'], row['state']]] = row['count']
+ end
+ end
end
def resource_parent
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index 611d9daa2fe..e473a6dc594 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -47,7 +47,7 @@ class TodoService
yield target
- todo_users.each(&:update_todos_count_cache)
+ Users::UpdateTodoCountCacheService.new(todo_users).execute if todo_users.present?
end
# When we reassign an assignable object (issuable, alert) we should:
@@ -227,14 +227,16 @@ class TodoService
users_with_pending_todos = pending_todos(users, attributes).pluck_user_id
users.reject! { |user| users_with_pending_todos.include?(user.id) && Feature.disabled?(:multiple_todos, user) }
- users.map do |user|
+ todos = users.map do |user|
issue_type = attributes.delete(:issue_type)
track_todo_creation(user, issue_type)
- todo = Todo.create(attributes.merge(user_id: user.id))
- user.update_todos_count_cache
- todo
+ Todo.create(attributes.merge(user_id: user.id))
end
+
+ Users::UpdateTodoCountCacheService.new(users).execute
+
+ todos
end
def new_issuable(issuable, author)
diff --git a/app/services/users/update_todo_count_cache_service.rb b/app/services/users/update_todo_count_cache_service.rb
new file mode 100644
index 00000000000..03ab66bd64a
--- /dev/null
+++ b/app/services/users/update_todo_count_cache_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Users
+ class UpdateTodoCountCacheService < BaseService
+ QUERY_BATCH_SIZE = 10
+
+ attr_reader :users
+
+ # users - An array of User objects
+ def initialize(users)
+ @users = users
+ end
+
+ def execute
+ users.each_slice(QUERY_BATCH_SIZE) do |users_batch|
+ todo_counts = Todo.for_user(users_batch).count_grouped_by_user_id_and_state
+
+ users_batch.each do |user|
+ update_count_cache(user, todo_counts, :done)
+ update_count_cache(user, todo_counts, :pending)
+ end
+ end
+ end
+
+ private
+
+ def update_count_cache(user, todo_counts, state)
+ count = todo_counts.fetch([user.id, state.to_s], 0)
+ expiration_time = user.count_cache_validity_period
+
+ Rails.cache.write(['users', user.id, "todos_#{state}_count"], count, expires_in: expiration_time)
+ end
+ end
+end
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index cb6c82e6d77..f5d28adfa66 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -17,7 +17,7 @@
aws_tip_commands_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'run-aws-commands-from-gitlab-cicd'),
aws_tip_learn_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'aws'),
protected_environment_variables_link: help_page_path('ci/variables/README', anchor: 'protect-a-cicd-variable'),
- masked_environment_variables_link: help_page_path('ci/variables/README', anchor: 'mask-a-custom-variable'),
+ masked_environment_variables_link: help_page_path('ci/variables/README', anchor: 'mask-a-cicd-variable'),
} }
- if !@group && @project.group
diff --git a/app/views/devise/mailer/password_change.html.haml b/app/views/devise/mailer/password_change.html.haml
index 5ec515285f2..5c0219ea3ad 100644
--- a/app/views/devise/mailer/password_change.html.haml
+++ b/app/views/devise/mailer/password_change.html.haml
@@ -1,8 +1,5 @@
-= email_default_heading("Hello, #{@resource.name}!")
+= email_default_heading(_("Hello, %{name}!") % { name: @resource.name })
%p
- The password for your GitLab account on
- #{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url)}
- has successfully been changed.
+ = _('The password for your GitLab account on %{link_to_gitlab} has successfully been changed.').html_safe % { link_to_gitlab: link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url) }
%p
- If you did not initiate this change, please contact your administrator
- immediately.
+ = _('If you did not initiate this change, please contact your administrator immediately.')
diff --git a/app/views/devise/mailer/password_change.text.erb b/app/views/devise/mailer/password_change.text.erb
index 95923d9f8de..6a8128186f5 100644
--- a/app/views/devise/mailer/password_change.text.erb
+++ b/app/views/devise/mailer/password_change.text.erb
@@ -1,7 +1,5 @@
-Hello, <%= @resource.name %>!
+<%= _('Hello, %{name}!') % { name: @resource.name } %>
-The password for your GitLab account on <%= Gitlab.config.gitlab.url %>
-has successfully been changed.
+<%= _('The password for your GitLab account on %{gitlab_url} has successfully been changed.') % { gitlab_url: Gitlab.config.gitlab.url } %>
-If you did not initiate this change, please contact your administrator
-immediately.
+<%= _('If you did not initiate this change, please contact your administrator immediately.') %>
diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml
index 9c9d8f0b5c5..10c04423589 100644
--- a/app/views/devise/passwords/edit.html.haml
+++ b/app/views/devise/passwords/edit.html.haml
@@ -1,4 +1,4 @@
-= render 'devise/shared/tab_single', tab_title: 'Change your password'
+= render 'devise/shared/tab_single', tab_title: _('Change your password')
.login-box
.login-body
= form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors' }) do |f|
@@ -6,16 +6,16 @@
= render "devise/shared/error_messages", resource: resource
= f.hidden_field :reset_password_token
.form-group
- = f.label 'New password', for: "user_password"
- = f.password_field :password, class: "form-control gl-form-input top", required: true, title: 'This field is required', data: { qa_selector: 'password_field'}
+ = f.label _('New password'), for: "user_password"
+ = f.password_field :password, class: "form-control gl-form-input top", required: true, title: _('This field is required.'), data: { qa_selector: 'password_field'}
.form-group
- = f.label 'Confirm new password', for: "user_password_confirmation"
- = f.password_field :password_confirmation, class: "form-control gl-form-input bottom", title: 'This field is required', data: { qa_selector: 'password_confirmation_field' }, required: true
+ = f.label _('Confirm new password'), for: "user_password_confirmation"
+ = f.password_field :password_confirmation, class: "form-control gl-form-input bottom", title: _('This field is required.'), data: { qa_selector: 'password_confirmation_field' }, required: true
.clearfix
- = f.submit "Change your password", class: "gl-button btn btn-confirm", data: { qa_selector: 'change_password_button' }
+ = f.submit _("Change your password"), class: "gl-button btn btn-confirm", data: { qa_selector: 'change_password_button' }
.clearfix.prepend-top-20
%p
- %span.light Didn't receive a confirmation email?
- = link_to "Request a new one", new_confirmation_path(:user)
+ %span.light= _("Didn't receive a confirmation email?")
+ = link_to _("Request a new one"), new_confirmation_path(:user)
= render 'devise/shared/sign_in_link'
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index cce0a3b926e..74f3e3e7e34 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -13,7 +13,7 @@
-# Show a message if none of the mechanisms above are enabled
- if !password_authentication_enabled_for_web? && !ldap_sign_in_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?)
%div
- No authentication methods configured.
+ = _('No authentication methods configured.')
- if allow_signup?
%p.gl-mt-3
diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml
index 404484cfb93..29bcb3c158b 100644
--- a/app/views/devise/sessions/two_factor.html.haml
+++ b/app/views/devise/sessions/two_factor.html.haml
@@ -1,5 +1,5 @@
%div
- = render 'devise/shared/tab_single', tab_title: 'Two-Factor Authentication'
+ = render 'devise/shared/tab_single', tab_title: _('Two-Factor Authentication')
.login-box
.login-body
- if @user.two_factor_otp_enabled?
@@ -7,10 +7,10 @@
- resource_params = params[resource_name].presence || params
= f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0)
%div
- = f.label 'Two-Factor Authentication code', name: :otp_attempt
- = f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', title: 'This field is required.', data: { qa_selector: 'two_fa_code_field' }
- %p.form-text.text-muted.hint Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.
+ = f.label _('Two-Factor Authentication code'), name: :otp_attempt
+ = f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', title: _('This field is required.'), data: { qa_selector: 'two_fa_code_field' }
+ %p.form-text.text-muted.hint= _("Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.")
.prepend-top-20
- = f.submit "Verify code", class: "gl-button btn btn-confirm", data: { qa_selector: 'verify_code_button' }
+ = f.submit _("Verify code"), class: "gl-button btn btn-confirm", data: { qa_selector: 'verify_code_button' }
- if @user.two_factor_webauthn_u2f_enabled?
= render "authentication/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index 7c32d71078f..98b1c5adcb5 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -24,6 +24,7 @@
- lint_link_start = '<a href="%{url}">'.html_safe % { url: lint_link_url }
= s_('You can also test your %{gitlab_ci_yml} in %{lint_link_start}CI Lint%{lint_link_end}').html_safe % { gitlab_ci_yml: '.gitlab-ci.yml', lint_link_start: lint_link_start, lint_link_end: '</a>'.html_safe }
+ #js-pipeline-notification{ data: { dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs') } }
= render "projects/pipelines/with_tabs", pipeline: @pipeline, stages: @stages, pipeline_has_errors: pipeline_has_errors
.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json), metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: @project.namespace, project_id: @project, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline) } }
diff --git a/bin/sidekiq-cluster b/bin/sidekiq-cluster
index 2204a222b88..47f8e82d228 100755
--- a/bin/sidekiq-cluster
+++ b/bin/sidekiq-cluster
@@ -5,6 +5,7 @@ require 'optparse'
require_relative '../lib/gitlab'
require_relative '../lib/gitlab/utils'
require_relative '../lib/gitlab/sidekiq_config/cli_methods'
+require_relative '../lib/gitlab/sidekiq_config/worker_matcher'
require_relative '../lib/gitlab/sidekiq_cluster'
require_relative '../lib/gitlab/sidekiq_cluster/cli'
diff --git a/changelogs/unreleased/321100-centralize-invalid-ci-state-in-authoring-section.yml b/changelogs/unreleased/321100-centralize-invalid-ci-state-in-authoring-section.yml
new file mode 100644
index 00000000000..610532fc194
--- /dev/null
+++ b/changelogs/unreleased/321100-centralize-invalid-ci-state-in-authoring-section.yml
@@ -0,0 +1,5 @@
+---
+title: Centralize shared state in Authoring section
+merge_request: 58790
+author:
+type: changed
diff --git a/changelogs/unreleased/326665_enable_projects_post_creation_worker.yml b/changelogs/unreleased/326665_enable_projects_post_creation_worker.yml
new file mode 100644
index 00000000000..4c1424fdf11
--- /dev/null
+++ b/changelogs/unreleased/326665_enable_projects_post_creation_worker.yml
@@ -0,0 +1,5 @@
+---
+title: Create prometheus service asynchronously by default when creating a project
+merge_request: 59273
+author:
+type: changed
diff --git a/changelogs/unreleased/Externalize-stings-in-password_change-html-haml.yml b/changelogs/unreleased/Externalize-stings-in-password_change-html-haml.yml
new file mode 100644
index 00000000000..59e2604266c
--- /dev/null
+++ b/changelogs/unreleased/Externalize-stings-in-password_change-html-haml.yml
@@ -0,0 +1,5 @@
+---
+title: Externalise strings in password_change files
+merge_request: 58219
+author: nuwe1
+type: other
diff --git a/changelogs/unreleased/Externalize-strings-in-passwords-edit-html-haml.yml b/changelogs/unreleased/Externalize-strings-in-passwords-edit-html-haml.yml
new file mode 100644
index 00000000000..aa83ac87631
--- /dev/null
+++ b/changelogs/unreleased/Externalize-strings-in-passwords-edit-html-haml.yml
@@ -0,0 +1,5 @@
+---
+title: Externalize strings in passwords/edit.html.haml
+merge_request: 58233
+author: nuwe1
+type: other
diff --git a/changelogs/unreleased/Externalize-strings-in-sessions-new-html-haml.yml b/changelogs/unreleased/Externalize-strings-in-sessions-new-html-haml.yml
new file mode 100644
index 00000000000..dafa53996c2
--- /dev/null
+++ b/changelogs/unreleased/Externalize-strings-in-sessions-new-html-haml.yml
@@ -0,0 +1,5 @@
+---
+title: Externalise strings in sessions/new.html.haml
+merge_request: 58274
+author: nuwe1
+type: other
diff --git a/changelogs/unreleased/Externalize-strings-in-sessions-two_factor-html-haml.yml b/changelogs/unreleased/Externalize-strings-in-sessions-two_factor-html-haml.yml
new file mode 100644
index 00000000000..6bad42d3dc9
--- /dev/null
+++ b/changelogs/unreleased/Externalize-strings-in-sessions-two_factor-html-haml.yml
@@ -0,0 +1,5 @@
+---
+title: Externalize strings in sessions/two_factor.html.haml
+merge_request: 58275
+author: nuwe1
+type: other
diff --git a/changelogs/unreleased/bulk-update-user-todos-count-cache.yml b/changelogs/unreleased/bulk-update-user-todos-count-cache.yml
new file mode 100644
index 00000000000..5412f194752
--- /dev/null
+++ b/changelogs/unreleased/bulk-update-user-todos-count-cache.yml
@@ -0,0 +1,5 @@
+---
+title: Avoid N+1 query when updating todo count cache
+merge_request: 57622
+author:
+type: performance
diff --git a/changelogs/unreleased/issue-325831-make-searchQueryService-paramter-optional.yml b/changelogs/unreleased/issue-325831-make-searchQueryService-paramter-optional.yml
new file mode 100644
index 00000000000..77866a0acad
--- /dev/null
+++ b/changelogs/unreleased/issue-325831-make-searchQueryService-paramter-optional.yml
@@ -0,0 +1,5 @@
+---
+title: Make NuGet SearchQueryService q parameter optional
+merge_request: 57654
+author: Huzaifa Iftikhar @huzaifaiftikhar
+type: fixed
diff --git a/changelogs/unreleased/mr-thread-comment-button.yml b/changelogs/unreleased/mr-thread-comment-button.yml
new file mode 100644
index 00000000000..3ef6f72ce0b
--- /dev/null
+++ b/changelogs/unreleased/mr-thread-comment-button.yml
@@ -0,0 +1,5 @@
+---
+title: Migrate Start Review button on MRs to use confirm variant
+merge_request: 59523
+author:
+type: changed
diff --git a/changelogs/unreleased/ntepluhina-assignees-feature-flag.yml b/changelogs/unreleased/ntepluhina-assignees-feature-flag.yml
new file mode 100644
index 00000000000..756d942d494
--- /dev/null
+++ b/changelogs/unreleased/ntepluhina-assignees-feature-flag.yml
@@ -0,0 +1,5 @@
+---
+title: Added feature flag to show/hide assignees GraphQL widget
+merge_request: 59620
+author:
+type: fixed
diff --git a/changelogs/unreleased/remove_epics_index.yml b/changelogs/unreleased/remove_epics_index.yml
new file mode 100644
index 00000000000..da3b1691b66
--- /dev/null
+++ b/changelogs/unreleased/remove_epics_index.yml
@@ -0,0 +1,5 @@
+---
+title: Remove redundant index from epics
+merge_request: 59494
+author:
+type: other
diff --git a/changelogs/unreleased/tor-defect-single-quote-escapes.yml b/changelogs/unreleased/tor-defect-single-quote-escapes.yml
new file mode 100644
index 00000000000..29e8aa54506
--- /dev/null
+++ b/changelogs/unreleased/tor-defect-single-quote-escapes.yml
@@ -0,0 +1,5 @@
+---
+title: Fix character escaping in Resolved By tooltips
+merge_request: 59428
+author:
+type: fixed
diff --git a/config/feature_flags/development/issue_assignees_widget.yml b/config/feature_flags/development/issue_assignees_widget.yml
new file mode 100644
index 00000000000..5c9b7df941f
--- /dev/null
+++ b/config/feature_flags/development/issue_assignees_widget.yml
@@ -0,0 +1,8 @@
+---
+name: issue_assignees_widget
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59620/
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/328185
+milestone: '13.11'
+type: development
+group: group::project management
+default_enabled: false
diff --git a/config/feature_flags/development/projects_post_creation_worker.yml b/config/feature_flags/development/projects_post_creation_worker.yml
index a844dc2b091..5d07e71f907 100644
--- a/config/feature_flags/development/projects_post_creation_worker.yml
+++ b/config/feature_flags/development/projects_post_creation_worker.yml
@@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/326665
milestone: '13.11'
type: development
group: group::source code
-default_enabled: false
+default_enabled: true
diff --git a/config/metrics/license/20210216175602_installation_type.yml b/config/metrics/license/20210216175602_installation_type.yml
index 577d0d502b3..ae5f26ff0a2 100644
--- a/config/metrics/license/20210216175602_installation_type.yml
+++ b/config/metrics/license/20210216175602_installation_type.yml
@@ -8,7 +8,7 @@ product_category: collection
value_type: string
status: data_available
time_frame: none
-data_source:
+data_source: ruby
distribution:
- ce
- ee
@@ -16,4 +16,4 @@ tier:
- free
- premium
- ultimate
-skip_validation: true
+
diff --git a/config/metrics/counts_all/20210216174829_smtp_server.yml b/config/metrics/settings/20210216174829_smtp_server.yml
index b60db7728c4..afee13f5534 100644
--- a/config/metrics/counts_all/20210216174829_smtp_server.yml
+++ b/config/metrics/settings/20210216174829_smtp_server.yml
@@ -2,13 +2,13 @@
key_path: mail.smtp_server
description: The value of the SMTP server that is used
product_section: growth
-product_stage:
-product_group: group::acquisition
-product_category:
+product_stage: growth
+product_group: group::activation
+product_category: onboarding
value_type: number
status: data_available
time_frame: all
-data_source:
+data_source: ruby
distribution:
- ce
- ee
@@ -16,4 +16,3 @@ tier:
- free
- premium
- ultimate
-skip_validation: true
diff --git a/db/migrate/20210415144538_remove_index_epics_on_group_id_from_epics.rb b/db/migrate/20210415144538_remove_index_epics_on_group_id_from_epics.rb
new file mode 100644
index 00000000000..f691af4d8d2
--- /dev/null
+++ b/db/migrate/20210415144538_remove_index_epics_on_group_id_from_epics.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class RemoveIndexEpicsOnGroupIdFromEpics < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ INDEX_NAME = 'index_epics_on_group_id'
+
+ disable_ddl_transaction!
+
+ def up
+ remove_concurrent_index_by_name :epics, INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index :epics, :group_id, name: INDEX_NAME
+ end
+end
diff --git a/db/schema_migrations/20210415144538 b/db/schema_migrations/20210415144538
new file mode 100644
index 00000000000..6b8e0d78b65
--- /dev/null
+++ b/db/schema_migrations/20210415144538
@@ -0,0 +1 @@
+d237690af576fb5a85d984416dcca1936a140a10a9b6c968d3ff57419568fb8f \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index f6b52332062..4855d94dbfa 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -22603,8 +22603,6 @@ CREATE INDEX index_epics_on_due_date_sourcing_milestone_id ON epics USING btree
CREATE INDEX index_epics_on_end_date ON epics USING btree (end_date);
-CREATE INDEX index_epics_on_group_id ON epics USING btree (group_id);
-
CREATE UNIQUE INDEX index_epics_on_group_id_and_external_key ON epics USING btree (group_id, external_key) WHERE (external_key IS NOT NULL);
CREATE UNIQUE INDEX index_epics_on_group_id_and_iid ON epics USING btree (group_id, iid);
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index bffedb97d6d..08f47e7fceb 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -3695,6 +3695,7 @@ Represents an iteration object.
| `dueDate` | [`Time`](#time) | Timestamp of the iteration due date. |
| `id` | [`ID!`](#id) | ID of the iteration. |
| `iid` | [`ID!`](#id) | Internal ID of the iteration. |
+| `iterationCadence` | [`IterationCadence!`](#iterationcadence) | Cadence of the iteration. |
| `report` | [`TimeboxReport`](#timeboxreport) | Historically accurate report about the timebox. |
| `scopedPath` | [`String`](#string) | Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts. |
| `scopedUrl` | [`String`](#string) | Web URL of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts. |
diff --git a/doc/development/usage_ping/dictionary.md b/doc/development/usage_ping/dictionary.md
index 6e54870e6b6..cb53d088907 100644
--- a/doc/development/usage_ping/dictionary.md
+++ b/doc/development/usage_ping/dictionary.md
@@ -6768,9 +6768,9 @@ Tiers: `premium`, `ultimate`
The value of the SMTP server that is used
-[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/metrics/counts_all/20210216174829_smtp_server.yml)
+[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/metrics/settings/20210216174829_smtp_server.yml)
-Group: `group::acquisition`
+Group: `group::activation`
Status: `data_available`
diff --git a/doc/user/admin_area/custom_project_templates.md b/doc/user/admin_area/custom_project_templates.md
index 26551d828bf..b4b33df37bf 100644
--- a/doc/user/admin_area/custom_project_templates.md
+++ b/doc/user/admin_area/custom_project_templates.md
@@ -16,7 +16,7 @@ Every project directly under the group namespace will be
available to the user if they have access to them. For example:
- Public projects, in the group will be available to every signed-in user, if all enabled [project features](../project/settings/index.md#sharing-and-permissions)
- are set to **Everyone With Access**.
+ except for GitLab Pages are set to **Everyone With Access**.
- Private projects will be available only if the user is a member of the project.
Repository and database information that are copied over to each new project are
diff --git a/doc/user/group/custom_project_templates.md b/doc/user/group/custom_project_templates.md
index 813d2b8e265..016bda329b2 100644
--- a/doc/user/group/custom_project_templates.md
+++ b/doc/user/group/custom_project_templates.md
@@ -62,7 +62,7 @@ GitLab administrators can
Within this section, you can configure the group where all the custom project
templates are sourced. Every project _template_ directly under the group namespace is
-available to every signed-in user, if all enabled [project features](../project/settings/index.md#sharing-and-permissions) are set to **Everyone With Access**.
+available to every signed-in user, if all enabled [project features](../project/settings/index.md#sharing-and-permissions) except for GitLab Pages are set to **Everyone With Access**.
However, private projects will be available only if the user is a member of the project.
diff --git a/lib/api/concerns/packages/nuget_endpoints.rb b/lib/api/concerns/packages/nuget_endpoints.rb
index 53b778875fc..5364eeb1880 100644
--- a/lib/api/concerns/packages/nuget_endpoints.rb
+++ b/lib/api/concerns/packages/nuget_endpoints.rb
@@ -95,7 +95,7 @@ module API
# https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource
params do
- requires :q, type: String, desc: 'The search term'
+ optional :q, type: String, desc: 'The search term'
optional :skip, type: Integer, desc: 'The number of results to skip', default: 0, regexp: NON_NEGATIVE_INTEGER_REGEX
optional :take, type: Integer, desc: 'The number of results to return', default: Kaminari.config.default_per_page, regexp: POSITIVE_INTEGER_REGEX
optional :prerelease, type: ::Grape::API::Boolean, desc: 'Include prerelease versions', default: true
diff --git a/lib/gitlab/graphql/authorize/connection_filter_extension.rb b/lib/gitlab/graphql/authorize/connection_filter_extension.rb
index 20526e19c2a..c75510df3e3 100644
--- a/lib/gitlab/graphql/authorize/connection_filter_extension.rb
+++ b/lib/gitlab/graphql/authorize/connection_filter_extension.rb
@@ -37,6 +37,8 @@ module Gitlab
end
def after_resolve(value:, context:, **rest)
+ return value if value.is_a?(GraphQL::Execution::Execute::Skip)
+
if @field.connection?
redact_connection(value, context)
elsif @field.type.list?
diff --git a/lib/gitlab/sidekiq_cluster/cli.rb b/lib/gitlab/sidekiq_cluster/cli.rb
index e471517c50a..9490d543dd1 100644
--- a/lib/gitlab/sidekiq_cluster/cli.rb
+++ b/lib/gitlab/sidekiq_cluster/cli.rb
@@ -53,11 +53,11 @@ module Gitlab
'You cannot specify --queue-selector and --experimental-queue-selector together'
end
- all_queues = SidekiqConfig::CliMethods.all_queues(@rails_path)
- queue_names = SidekiqConfig::CliMethods.worker_queues(@rails_path)
+ worker_metadatas = SidekiqConfig::CliMethods.worker_metadatas(@rails_path)
+ worker_queues = SidekiqConfig::CliMethods.worker_queues(@rails_path)
- queue_groups = argv.map do |queues|
- next queue_names if queues == '*'
+ queue_groups = argv.map do |queues_or_query_string|
+ next worker_queues if queues_or_query_string == SidekiqConfig::WorkerMatcher::WILDCARD_MATCH
# When using the queue query syntax, we treat each queue group
# as a worker attribute query, and resolve the queues for the
@@ -65,14 +65,14 @@ module Gitlab
# Simplify with https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/646
if @queue_selector || @experimental_queue_selector
- SidekiqConfig::CliMethods.query_workers(queues, all_queues)
+ SidekiqConfig::CliMethods.query_queues(queues_or_query_string, worker_metadatas)
else
- SidekiqConfig::CliMethods.expand_queues(queues.split(','), queue_names)
+ SidekiqConfig::CliMethods.expand_queues(queues_or_query_string.split(','), worker_queues)
end
end
if @negate_queues
- queue_groups.map! { |queues| queue_names - queues }
+ queue_groups.map! { |queues| worker_queues - queues }
end
if queue_groups.all?(&:empty?)
diff --git a/lib/gitlab/sidekiq_config/cli_methods.rb b/lib/gitlab/sidekiq_config/cli_methods.rb
index a256632bc12..8eef15f9ccb 100644
--- a/lib/gitlab/sidekiq_config/cli_methods.rb
+++ b/lib/gitlab/sidekiq_config/cli_methods.rb
@@ -12,35 +12,19 @@ module Gitlab
# rubocop:disable Gitlab/ModuleWithInstanceVariables
extend self
+ # The file names are misleading. Those files contain the metadata of the
+ # workers. They should be renamed to all_workers instead.
+ # https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1018
QUEUE_CONFIG_PATHS = begin
result = %w[app/workers/all_queues.yml]
result << 'ee/app/workers/all_queues.yml' if Gitlab.ee?
result
end.freeze
- QUERY_OR_OPERATOR = '|'
- QUERY_AND_OPERATOR = '&'
- QUERY_CONCATENATE_OPERATOR = ','
- QUERY_TERM_REGEX = %r{^(\w+)(!?=)([\w:#{QUERY_CONCATENATE_OPERATOR}]+)}.freeze
+ def worker_metadatas(rails_path = Rails.root.to_s)
+ @worker_metadatas ||= {}
- QUERY_PREDICATES = {
- feature_category: :to_sym,
- has_external_dependencies: lambda { |value| value == 'true' },
- name: :to_s,
- resource_boundary: :to_sym,
- tags: :to_sym,
- urgency: :to_sym
- }.freeze
-
- QueryError = Class.new(StandardError)
- InvalidTerm = Class.new(QueryError)
- UnknownOperator = Class.new(QueryError)
- UnknownPredicate = Class.new(QueryError)
-
- def all_queues(rails_path = Rails.root.to_s)
- @worker_queues ||= {}
-
- @worker_queues[rails_path] ||= QUEUE_CONFIG_PATHS.flat_map do |path|
+ @worker_metadatas[rails_path] ||= QUEUE_CONFIG_PATHS.flat_map do |path|
full_path = File.join(rails_path, path)
File.exist?(full_path) ? YAML.load_file(full_path) : []
@@ -49,7 +33,7 @@ module Gitlab
# rubocop:enable Gitlab/ModuleWithInstanceVariables
def worker_queues(rails_path = Rails.root.to_s)
- worker_names(all_queues(rails_path))
+ worker_names(worker_metadatas(rails_path))
end
def expand_queues(queues, all_queues = self.worker_queues)
@@ -62,13 +46,18 @@ module Gitlab
end
end
- def query_workers(query_string, queues)
- worker_names(queues.select(&query_string_to_lambda(query_string)))
+ def query_queues(query_string, worker_metadatas)
+ matcher = SidekiqConfig::WorkerMatcher.new(query_string)
+ selected_metadatas = worker_metadatas.select do |worker_metadata|
+ matcher.match?(worker_metadata)
+ end
+
+ worker_names(selected_metadatas)
end
def clear_memoization!
- if instance_variable_defined?('@worker_queues')
- remove_instance_variable('@worker_queues')
+ if instance_variable_defined?('@worker_metadatas')
+ remove_instance_variable('@worker_metadatas')
end
end
@@ -77,53 +66,6 @@ module Gitlab
def worker_names(workers)
workers.map { |queue| queue[:name] }
end
-
- def query_string_to_lambda(query_string)
- or_clauses = query_string.split(QUERY_OR_OPERATOR).map do |and_clauses_string|
- and_clauses_predicates = and_clauses_string.split(QUERY_AND_OPERATOR).map do |term|
- predicate_for_term(term)
- end
-
- lambda { |worker| and_clauses_predicates.all? { |predicate| predicate.call(worker) } }
- end
-
- lambda { |worker| or_clauses.any? { |predicate| predicate.call(worker) } }
- end
-
- def predicate_for_term(term)
- match = term.match(QUERY_TERM_REGEX)
-
- raise InvalidTerm.new("Invalid term: #{term}") unless match
-
- _, lhs, op, rhs = *match
-
- predicate_for_op(op, predicate_factory(lhs, rhs.split(QUERY_CONCATENATE_OPERATOR)))
- end
-
- def predicate_for_op(op, predicate)
- case op
- when '='
- predicate
- when '!='
- lambda { |worker| !predicate.call(worker) }
- else
- # This is unreachable because InvalidTerm will be raised instead, but
- # keeping it allows to guard against that changing in future.
- raise UnknownOperator.new("Unknown operator: #{op}")
- end
- end
-
- def predicate_factory(lhs, values)
- values_block = QUERY_PREDICATES[lhs.to_sym]
-
- raise UnknownPredicate.new("Unknown predicate: #{lhs}") unless values_block
-
- lambda do |queue|
- comparator = Array(queue[lhs.to_sym]).to_set
-
- values.map(&values_block).to_set.intersect?(comparator)
- end
- end
end
end
end
diff --git a/lib/gitlab/sidekiq_config/worker_matcher.rb b/lib/gitlab/sidekiq_config/worker_matcher.rb
new file mode 100644
index 00000000000..fe5ac10c65a
--- /dev/null
+++ b/lib/gitlab/sidekiq_config/worker_matcher.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module SidekiqConfig
+ class WorkerMatcher
+ WILDCARD_MATCH = '*'
+ QUERY_OR_OPERATOR = '|'
+ QUERY_AND_OPERATOR = '&'
+ QUERY_CONCATENATE_OPERATOR = ','
+ QUERY_TERM_REGEX = %r{^(\w+)(!?=)([\w:#{QUERY_CONCATENATE_OPERATOR}]+)}.freeze
+
+ QUERY_PREDICATES = {
+ feature_category: :to_sym,
+ has_external_dependencies: lambda { |value| value == 'true' },
+ name: :to_s,
+ resource_boundary: :to_sym,
+ tags: :to_sym,
+ urgency: :to_sym
+ }.freeze
+
+ QueryError = Class.new(StandardError)
+ InvalidTerm = Class.new(QueryError)
+ UnknownOperator = Class.new(QueryError)
+ UnknownPredicate = Class.new(QueryError)
+
+ def initialize(query_string)
+ @match_lambda = query_string_to_lambda(query_string)
+ end
+
+ def match?(worker_metadata)
+ @match_lambda.call(worker_metadata)
+ end
+
+ private
+
+ def query_string_to_lambda(query_string)
+ return lambda { |_worker| true } if query_string.strip == WILDCARD_MATCH
+
+ or_clauses = query_string.split(QUERY_OR_OPERATOR).map do |and_clauses_string|
+ and_clauses_predicates = and_clauses_string.split(QUERY_AND_OPERATOR).map do |term|
+ predicate_for_term(term)
+ end
+
+ lambda { |worker| and_clauses_predicates.all? { |predicate| predicate.call(worker) } }
+ end
+
+ lambda { |worker| or_clauses.any? { |predicate| predicate.call(worker) } }
+ end
+
+ def predicate_for_term(term)
+ match = term.match(QUERY_TERM_REGEX)
+
+ raise InvalidTerm.new("Invalid term: #{term}") unless match
+
+ _, lhs, op, rhs = *match
+
+ predicate_for_op(op, predicate_factory(lhs, rhs.split(QUERY_CONCATENATE_OPERATOR)))
+ end
+
+ def predicate_for_op(op, predicate)
+ case op
+ when '='
+ predicate
+ when '!='
+ lambda { |worker| !predicate.call(worker) }
+ else
+ # This is unreachable because InvalidTerm will be raised instead, but
+ # keeping it allows to guard against that changing in future.
+ raise UnknownOperator.new("Unknown operator: #{op}")
+ end
+ end
+
+ def predicate_factory(lhs, values)
+ values_block = QUERY_PREDICATES[lhs.to_sym]
+
+ raise UnknownPredicate.new("Unknown predicate: #{lhs}") unless values_block
+
+ lambda do |queue|
+ comparator = Array(queue[lhs.to_sym]).to_set
+
+ values.map(&values_block).to_set.intersect?(comparator)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/tasks/brakeman.rake b/lib/tasks/brakeman.rake
deleted file mode 100644
index 44d2071751f..00000000000
--- a/lib/tasks/brakeman.rake
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-desc 'Security check via brakeman'
-task :brakeman do
- # We get 0 warnings at level 'w3' but we would like to reach 'w2'. Merge
- # requests are welcome!
- if system(*%w(brakeman --no-progress --skip-files lib/backup/repository.rb -w3 -z))
- puts 'Security check succeed'
- else
- puts 'Security check failed'
- exit 1
- end
-end
diff --git a/lib/tasks/gitlab/test.rake b/lib/tasks/gitlab/test.rake
deleted file mode 100644
index a83ba69bc75..00000000000
--- a/lib/tasks/gitlab/test.rake
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-namespace :gitlab do
- desc "GitLab | Run all tests"
- task :test do
- cmds = [
- %w(rake brakeman),
- %w(rake rubocop),
- %w(rake spec),
- %w(rake karma)
- ]
-
- cmds.each do |cmd|
- system({ 'RAILS_ENV' => 'test', 'force' => 'yes' }, *cmd) || raise("#{cmd} failed!")
- end
- end
-end
diff --git a/lib/tasks/test.rake b/lib/tasks/test.rake
index b24817468c6..c4eb9450b31 100644
--- a/lib/tasks/test.rake
+++ b/lib/tasks/test.rake
@@ -2,7 +2,16 @@
Rake::Task["test"].clear
-desc "GitLab | Run all tests"
+desc "GitLab | List rake tasks for tests"
task :test do
- Rake::Task["gitlab:test"].invoke
+ puts "Running the full GitLab test suite takes significant time to pass. We recommend using one of the following spec tasks:\n\n"
+
+ spec_tasks = Rake::Task.tasks.select { |t| t.name.start_with?('spec:') }
+ longest_task_name = spec_tasks.map { |t| t.name.size }.max
+
+ spec_tasks.each do |task|
+ puts "#{"%-#{longest_task_name}s" % task.name} | #{task.full_comment}"
+ end
+
+ puts "\nLearn more at https://docs.gitlab.com/ee/development/rake_tasks.html#run-tests."
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index b6200d61b69..cd3e66e6cea 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -8305,6 +8305,9 @@ msgstr ""
msgid "Confirm"
msgstr ""
+msgid "Confirm new password"
+msgstr ""
+
msgid "Confirm your account"
msgstr ""
@@ -11220,6 +11223,9 @@ msgstr ""
msgid "DevopsReport|Score"
msgstr ""
+msgid "Didn't receive a confirmation email?"
+msgstr ""
+
msgid "Diff content limits"
msgstr ""
@@ -16130,6 +16136,9 @@ msgstr ""
msgid "If you add %{codeStart}needs%{codeEnd} to jobs in your pipeline you'll be able to view the %{codeStart}needs%{codeEnd} relationships between jobs in this tab as a %{linkStart}Directed Acyclic Graph (DAG)%{linkEnd}."
msgstr ""
+msgid "If you did not initiate this change, please contact your administrator immediately."
+msgstr ""
+
msgid "If you did not recently sign in, you should immediately %{password_link_start}change your password%{password_link_end}."
msgstr ""
@@ -23150,6 +23159,18 @@ msgstr ""
msgid "PipelineCharts|Total:"
msgstr ""
+msgid "PipelineEditor|The CI/CD configuration is continuously validated. Errors and warnings are displayed when the CI/CD configuration file is not empty."
+msgstr ""
+
+msgid "PipelineEditor|The merged YAML view is displayed when the CI/CD configuration file has valid syntax."
+msgstr ""
+
+msgid "PipelineEditor|The pipeline visualization is displayed when the CI/CD configuration file has valid syntax."
+msgstr ""
+
+msgid "PipelineEditor|This tab will be usable when the CI/CD configuration file is populated with valid syntax."
+msgstr ""
+
msgid "PipelineScheduleIntervalPattern|Custom (%{linkStart}Cron syntax%{linkEnd})"
msgstr ""
@@ -25700,6 +25721,9 @@ msgstr ""
msgid "Protocol"
msgstr ""
+msgid "Provide feedback"
+msgstr ""
+
msgid "Provider"
msgstr ""
@@ -26789,6 +26813,9 @@ msgstr ""
msgid "Request Access"
msgstr ""
+msgid "Request a new one"
+msgstr ""
+
msgid "Request details"
msgstr ""
@@ -31272,6 +31299,12 @@ msgstr ""
msgid "The password for the Jenkins server."
msgstr ""
+msgid "The password for your GitLab account on %{gitlab_url} has successfully been changed."
+msgstr ""
+
+msgid "The password for your GitLab account on %{link_to_gitlab} has successfully been changed."
+msgstr ""
+
msgid "The phase of the development lifecycle."
msgstr ""
@@ -31425,9 +31458,6 @@ msgstr ""
msgid "The value of the provided variable exceeds the %{count} character limit"
msgstr ""
-msgid "The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax."
-msgstr ""
-
msgid "The vulnerability is known, and has not been remediated or mitigated, but is considered to be an acceptable business risk."
msgstr ""
@@ -31563,6 +31593,9 @@ msgstr ""
msgid "There was a problem communicating with your device."
msgstr ""
+msgid "There was a problem dismissing this notification."
+msgstr ""
+
msgid "There was a problem fetching branches."
msgstr ""
@@ -34497,6 +34530,9 @@ msgstr ""
msgid "Verify SAML Configuration"
msgstr ""
+msgid "Verify code"
+msgstr ""
+
msgid "Verify configuration"
msgstr ""
@@ -34588,6 +34624,9 @@ msgstr ""
msgid "View job"
msgstr ""
+msgid "View job dependencies in the pipeline graph!"
+msgstr ""
+
msgid "View job log"
msgstr ""
@@ -35778,6 +35817,9 @@ msgstr ""
msgid "You can now export your security dashboard to a CSV report."
msgstr ""
+msgid "You can now group jobs in the pipeline graph based on which jobs are configured to run first, if you use the %{codeStart}needs:%{codeEnd} keyword to establish job dependencies in your CI/CD pipelines. %{linkStart}Learn how to speed up your pipeline with needs.%{linkEnd}"
+msgstr ""
+
msgid "You can now submit a merge request to get this change into the original branch."
msgstr ""
@@ -36165,7 +36207,7 @@ msgstr ""
msgid "Your %{strong}%{plan_name}%{strong_close} subscription will expire on %{strong}%{expires_on}%{strong_close}. After that, you will not be able to create issues or merge requests as well as many other features."
msgstr ""
-msgid "Your CI configuration file is invalid."
+msgid "Your CI/CD configuration syntax is invalid. View Lint tab for more details."
msgstr ""
msgid "Your CSV export has started. It will be emailed to %{email} when complete."
diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb
index d3191af5d96..04b4caa52fe 100644
--- a/spec/features/issues/issue_sidebar_spec.rb
+++ b/spec/features/issues/issue_sidebar_spec.rb
@@ -30,116 +30,198 @@ RSpec.describe 'Issue Sidebar' do
let(:user2) { create(:user) }
let(:issue2) { create(:issue, project: project, author: user2) }
- context 'when a privileged user can invite' do
- it 'shows a link for inviting members and launches invite modal' do
- project.add_maintainer(user)
- visit_issue(project, issue2)
+ context 'when GraphQL assignees widget feature flag is disabled' do
+ before do
+ stub_feature_flags(issue_assignees_widget: false)
+ end
+
+ include_examples 'issuable invite members experiments' do
+ let(:issuable_path) { project_issue_path(project, issue2) }
+ end
- open_assignees_dropdown
+ context 'when user is a developer' do
+ before do
+ project.add_developer(user)
+ visit_issue(project, issue2)
+
+ find('.block.assignee .edit-link').click
+ wait_for_requests
+ end
- page.within '.dropdown-menu-user' do
- expect(page).to have_link('Invite members')
- expect(page).to have_selector('[data-track-event="click_invite_members"]')
- expect(page).to have_selector('[data-track-label="edit_assignee"]')
+ it 'shows author in assignee dropdown' do
+ page.within '.dropdown-menu-user' do
+ expect(page).to have_content(user2.name)
+ end
end
- click_link 'Invite members'
+ it 'shows author when filtering assignee dropdown' do
+ page.within '.dropdown-menu-user' do
+ find('.dropdown-input-field').set(user2.name)
- expect(page).to have_content("You're inviting members to the")
- end
- end
+ wait_for_requests
- context 'when invite_members_version_b experiment is enabled' do
- before do
- stub_experiment_for_subject(invite_members_version_b: true)
- end
+ expect(page).to have_content(user2.name)
+ end
+ end
+
+ it 'assigns yourself' do
+ find('.block.assignee .dropdown-menu-toggle').click
- it 'shows a link for inviting members and follows through to modal' do
- project.add_developer(user)
- visit_issue(project, issue2)
+ click_button 'assign yourself'
- open_assignees_dropdown
+ wait_for_requests
+
+ find('.block.assignee .edit-link').click
- page.within '.dropdown-menu-user' do
- expect(page).to have_link('Invite members', href: '#')
- expect(page).to have_selector('[data-track-event="click_invite_members_version_b"]')
- expect(page).to have_selector('[data-track-label="edit_assignee"]')
+ page.within '.dropdown-menu-user' do
+ expect(page.find('.dropdown-header')).to be_visible
+ expect(page.find('.dropdown-menu-user-link.is-active')).to have_content(user.name)
+ end
end
- click_link 'Invite members'
+ it 'keeps your filtered term after filtering and dismissing the dropdown' do
+ find('.dropdown-input-field').set(user2.name)
- expect(page).to have_content("Oops, this feature isn't ready yet")
- end
- end
+ wait_for_requests
+
+ page.within '.dropdown-menu-user' do
+ expect(page).not_to have_content 'Unassigned'
+ click_link user2.name
+ end
- context 'when invite_members_version_b experiment is disabled' do
- it 'shows author in assignee dropdown and no invite link' do
- project.add_developer(user)
- visit_issue(project, issue2)
+ find('.js-right-sidebar').click
+ find('.block.assignee .edit-link').click
- open_assignees_dropdown
+ expect(page.all('.dropdown-menu-user li').length).to eq(1)
+ expect(find('.dropdown-input-field').value).to eq(user2.name)
+ end
+
+ it 'shows label text as "Apply" when assignees are changed' do
+ project.add_developer(user)
+ visit_issue(project, issue2)
+
+ find('.block.assignee .edit-link').click
+ wait_for_requests
- page.within '.dropdown-menu-user' do
- expect(page).not_to have_link('Invite members')
+ click_on 'Unassigned'
+
+ expect(page).to have_link('Apply')
end
end
end
- context 'when user is a developer' do
- before do
- project.add_developer(user)
- visit_issue(project, issue2)
- end
+ context 'when GraphQL assignees widget feature flag is enabled' do
+ context 'when a privileged user can invite' do
+ it 'shows a link for inviting members and launches invite modal' do
+ project.add_maintainer(user)
+ visit_issue(project, issue2)
+
+ open_assignees_dropdown
- it 'shows author in assignee dropdown' do
- open_assignees_dropdown
+ page.within '.dropdown-menu-user' do
+ expect(page).to have_link('Invite members')
+ expect(page).to have_selector('[data-track-event="click_invite_members"]')
+ expect(page).to have_selector('[data-track-label="edit_assignee"]')
+ end
+
+ click_link 'Invite members'
- page.within '.dropdown-menu-user' do
- expect(page).to have_content(user2.name)
+ expect(page).to have_content("You're inviting members to the")
end
end
- it 'shows author when filtering assignee dropdown' do
- open_assignees_dropdown
+ context 'when invite_members_version_b experiment is enabled' do
+ before do
+ stub_experiment_for_subject(invite_members_version_b: true)
+ end
+
+ it 'shows a link for inviting members and follows through to modal' do
+ project.add_developer(user)
+ visit_issue(project, issue2)
- page.within '.dropdown-menu-user' do
- find('.js-dropdown-input-field').find('input').set(user2.name)
+ open_assignees_dropdown
- wait_for_requests
+ page.within '.dropdown-menu-user' do
+ expect(page).to have_link('Invite members', href: '#')
+ expect(page).to have_selector('[data-track-event="click_invite_members_version_b"]')
+ expect(page).to have_selector('[data-track-label="edit_assignee"]')
+ end
- expect(page).to have_content(user2.name)
+ click_link 'Invite members'
+
+ expect(page).to have_content("Oops, this feature isn't ready yet")
end
end
- it 'assigns yourself' do
- click_button 'assign yourself'
- wait_for_requests
+ context 'when invite_members_version_b experiment is disabled' do
+ it 'shows author in assignee dropdown and no invite link' do
+ project.add_developer(user)
+ visit_issue(project, issue2)
+
+ open_assignees_dropdown
- page.within '.assignee' do
- expect(page).to have_content(user.name)
+ page.within '.dropdown-menu-user' do
+ expect(page).not_to have_link('Invite members')
+ end
end
end
- it 'keeps your filtered term after filtering and dismissing the dropdown' do
- open_assignees_dropdown
+ context 'when user is a developer' do
+ before do
+ project.add_developer(user)
+ visit_issue(project, issue2)
+ end
- find('.js-dropdown-input-field').find('input').set(user2.name)
- wait_for_requests
+ it 'shows author in assignee dropdown' do
+ open_assignees_dropdown
- page.within '.dropdown-menu-user' do
- expect(page).not_to have_content 'Unassigned'
- click_link user2.name
+ page.within '.dropdown-menu-user' do
+ expect(page).to have_content(user2.name)
+ end
end
- find('.js-right-sidebar').click
+ it 'shows author when filtering assignee dropdown' do
+ open_assignees_dropdown
+
+ page.within '.dropdown-menu-user' do
+ find('.js-dropdown-input-field').find('input').set(user2.name)
+
+ wait_for_requests
- open_assignees_dropdown
+ expect(page).to have_content(user2.name)
+ end
+ end
+
+ it 'assigns yourself' do
+ click_button 'assign yourself'
+ wait_for_requests
- page.within('.assignee') do
- expect(page.all('[data-testid="selected-participant"]').length).to eq(1)
+ page.within '.assignee' do
+ expect(page).to have_content(user.name)
+ end
end
- expect(find('.js-dropdown-input-field').find('input').value).to eq(user2.name)
+ it 'keeps your filtered term after filtering and dismissing the dropdown' do
+ open_assignees_dropdown
+
+ find('.js-dropdown-input-field').find('input').set(user2.name)
+ wait_for_requests
+
+ page.within '.dropdown-menu-user' do
+ expect(page).not_to have_content 'Unassigned'
+ click_link user2.name
+ end
+
+ find('.js-right-sidebar').click
+
+ open_assignees_dropdown
+
+ page.within('.assignee') do
+ expect(page.all('[data-testid="selected-participant"]').length).to eq(1)
+ end
+
+ expect(find('.js-dropdown-input-field').find('input').value).to eq(user2.name)
+ end
end
end
end
diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb
index 676484b5c09..1bbb96ff479 100644
--- a/spec/features/issues/user_edits_issue_spec.rb
+++ b/spec/features/issues/user_edits_issue_spec.rb
@@ -167,79 +167,165 @@ RSpec.describe "Issues > User edits issue", :js do
end
describe 'update assignee' do
- context 'by authorized user' do
- it 'allows user to select unassigned' do
- visit project_issue_path(project, issue)
+ context 'when GraphQL assignees widget feature flag is disabled' do
+ before do
+ stub_feature_flags(issue_assignees_widget: false)
+ end
- page.within('.assignee') do
- expect(page).to have_content "#{user.name}"
+ context 'by authorized user' do
+ def close_dropdown_menu_if_visible
+ find('.dropdown-menu-toggle', visible: :all).tap do |toggle|
+ toggle.click if toggle.visible?
+ end
+ end
- click_button('Edit')
- wait_for_requests
+ it 'allows user to select unassigned' do
+ visit project_issue_path(project, issue)
- find('[data-testid="unassign"]').click
- find('[data-testid="title"]').click
- wait_for_requests
+ page.within('.assignee') do
+ expect(page).to have_content "#{user.name}"
+
+ click_link 'Edit'
+ click_link 'Unassigned'
+ first('.title').click
- expect(page).to have_content 'None - assign yourself'
+ expect(page).to have_content 'None - assign yourself'
+ end
end
- end
- it 'allows user to select an assignee' do
- issue2 = create(:issue, project: project, author: user)
- visit project_issue_path(project, issue2)
+ it 'allows user to select an assignee' do
+ issue2 = create(:issue, project: project, author: user)
+ visit project_issue_path(project, issue2)
- page.within('.assignee') do
- expect(page).to have_content "None"
- click_button('Edit')
- wait_for_requests
+ page.within('.assignee') do
+ expect(page).to have_content "None"
+ end
+
+ page.within '.assignee' do
+ click_link 'Edit'
+ end
+
+ page.within '.dropdown-menu-user' do
+ click_link user.name
+ end
+
+ page.within('.assignee') do
+ expect(page).to have_content user.name
+ end
+ end
+
+ it 'allows user to unselect themselves' do
+ issue2 = create(:issue, project: project, author: user, assignees: [user])
+
+ visit project_issue_path(project, issue2)
+
+ page.within '.assignee' do
+ expect(page).to have_content user.name
+
+ click_link 'Edit'
+ click_link user.name
+
+ close_dropdown_menu_if_visible
+
+ page.within '.value .assign-yourself' do
+ expect(page).to have_content "None"
+ end
+ end
end
+ end
- page.within '.dropdown-menu-user' do
- click_link user.name
+ context 'by unauthorized user' do
+ let(:guest) { create(:user) }
+
+ before do
+ project.add_guest(guest)
end
- page.within('.assignee') do
- find('[data-testid="title"]').click
- wait_for_requests
+ it 'shows assignee text' do
+ sign_out(:user)
+ sign_in(guest)
- expect(page).to have_content user.name
+ visit project_issue_path(project, issue)
+ expect(page).to have_content issue.assignees.first.name
end
end
+ end
- it 'allows user to unselect themselves' do
- issue2 = create(:issue, project: project, author: user, assignees: [user])
+ context 'when GraphQL assignees widget feature flag is enabled' do
+ context 'by authorized user' do
+ it 'allows user to select unassigned' do
+ visit project_issue_path(project, issue)
- visit project_issue_path(project, issue2)
+ page.within('.assignee') do
+ expect(page).to have_content "#{user.name}"
- page.within '.assignee' do
- expect(page).to have_content user.name
+ click_button('Edit')
+ wait_for_requests
- click_button('Edit')
- wait_for_requests
- click_link user.name
+ find('[data-testid="unassign"]').click
+ find('[data-testid="title"]').click
+ wait_for_requests
- find('[data-testid="title"]').click
- wait_for_requests
+ expect(page).to have_content 'None - assign yourself'
+ end
+ end
- expect(page).to have_content "None"
+ it 'allows user to select an assignee' do
+ issue2 = create(:issue, project: project, author: user)
+ visit project_issue_path(project, issue2)
+
+ page.within('.assignee') do
+ expect(page).to have_content "None"
+ click_button('Edit')
+ wait_for_requests
+ end
+
+ page.within '.dropdown-menu-user' do
+ click_link user.name
+ end
+
+ page.within('.assignee') do
+ find('[data-testid="title"]').click
+ wait_for_requests
+
+ expect(page).to have_content user.name
+ end
end
- end
- end
- context 'by unauthorized user' do
- let(:guest) { create(:user) }
+ it 'allows user to unselect themselves' do
+ issue2 = create(:issue, project: project, author: user, assignees: [user])
- before do
- project.add_guest(guest)
+ visit project_issue_path(project, issue2)
+
+ page.within '.assignee' do
+ expect(page).to have_content user.name
+
+ click_button('Edit')
+ wait_for_requests
+ click_link user.name
+
+ find('[data-testid="title"]').click
+ wait_for_requests
+
+ expect(page).to have_content "None"
+ end
+ end
end
- it 'shows assignee text' do
- sign_out(:user)
- sign_in(guest)
+ context 'by unauthorized user' do
+ let(:guest) { create(:user) }
- visit project_issue_path(project, issue)
- expect(page).to have_content issue.assignees.first.name
+ before do
+ project.add_guest(guest)
+ end
+
+ it 'shows assignee text' do
+ sign_out(:user)
+ sign_in(guest)
+
+ visit project_issue_path(project, issue)
+ expect(page).to have_content issue.assignees.first.name
+ end
end
end
end
diff --git a/spec/features/projects/settings/forked_project_settings_spec.rb b/spec/features/projects/settings/forked_project_settings_spec.rb
index e98db890b02..a84516e19f9 100644
--- a/spec/features/projects/settings/forked_project_settings_spec.rb
+++ b/spec/features/projects/settings/forked_project_settings_spec.rb
@@ -25,7 +25,8 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do
fill_in('confirm_name_input', with: forked_project.name)
click_button('Confirm')
- expect(page).to have_content('The fork relationship has been removed.')
+ wait_for_requests
+
expect(forked_project.reload.forked?).to be_falsy
end
end
diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb
index 85da1db354a..c18b0f2688b 100644
--- a/spec/features/projects_spec.rb
+++ b/spec/features/projects_spec.rb
@@ -174,26 +174,6 @@ RSpec.describe 'Project' do
end
end
- describe 'remove forked relationship', :js do
- let(:user) { create(:user) }
- let(:project) { fork_project(create(:project, :public), user, namespace: user.namespace) }
-
- before do
- sign_in user
- visit edit_project_path(project)
- end
-
- it 'removes fork', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/327817' do
- expect(page).to have_content 'Remove fork relationship'
-
- remove_with_confirm('Remove fork relationship', project.path)
-
- expect(page).to have_content 'The fork relationship has been removed.'
- expect(project.reload.forked?).to be_falsey
- expect(page).not_to have_content 'Remove fork relationship'
- end
- end
-
describe 'showing information about source of a project fork' do
let(:user) { create(:user) }
let(:base_project) { create(:project, :public, :repository) }
diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js
index cc41088e21e..ecce854b00a 100644
--- a/spec/frontend/notes/components/note_actions_spec.js
+++ b/spec/frontend/notes/components/note_actions_spec.js
@@ -151,6 +151,22 @@ describe('noteActions', () => {
const assignUserButton = wrapper.find('[data-testid="assign-user"]');
expect(assignUserButton.exists()).toBe(false);
});
+
+ it('should render the correct (unescaped) name in the Resolved By tooltip', () => {
+ const complexUnescapedName = 'This is a Ǝ\'𝞓\'E "cat"?';
+ wrapper = mountNoteActions({
+ ...props,
+ canResolve: true,
+ isResolving: false,
+ isResolved: true,
+ resolvedBy: {
+ name: complexUnescapedName,
+ },
+ });
+
+ const { resolveButton } = wrapper.vm.$refs;
+ expect(resolveButton.$el.getAttribute('title')).toBe(`Resolved by ${complexUnescapedName}`);
+ });
});
});
diff --git a/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js b/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js
index ebc02a64dc7..fb191fccb0d 100644
--- a/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js
+++ b/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js
@@ -1,9 +1,8 @@
-import { GlAlert, GlIcon } from '@gitlab/ui';
+import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { EDITOR_READY_EVENT } from '~/editor/constants';
import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue';
-import { INVALID_CI_CONFIG } from '~/pipelines/constants';
import { mockLintResponse, mockCiConfigPath } from '../../mock_data';
describe('Text editor component', () => {
@@ -32,7 +31,6 @@ describe('Text editor component', () => {
});
};
- const findAlert = () => wrapper.findComponent(GlAlert);
const findIcon = () => wrapper.findComponent(GlIcon);
const findEditor = () => wrapper.findComponent(MockEditorLite);
@@ -40,24 +38,9 @@ describe('Text editor component', () => {
wrapper.destroy();
});
- describe('when status is invalid', () => {
- beforeEach(() => {
- createComponent({ props: { isValid: false } });
- });
-
- it('show an error message', () => {
- expect(findAlert().exists()).toBe(true);
- expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[INVALID_CI_CONFIG]);
- });
-
- it('hides the editor', () => {
- expect(findEditor().exists()).toBe(false);
- });
- });
-
describe('when status is valid', () => {
beforeEach(() => {
- createComponent({ props: { isValid: true } });
+ createComponent();
});
it('shows an information message that the section is not editable', () => {
diff --git a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js
index f59239b59f5..eba853180cd 100644
--- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js
+++ b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js
@@ -4,9 +4,12 @@ import { nextTick } from 'vue';
import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue';
import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue';
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
+import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue';
import {
+ EDITOR_APP_STATUS_EMPTY,
EDITOR_APP_STATUS_ERROR,
EDITOR_APP_STATUS_LOADING,
+ EDITOR_APP_STATUS_INVALID,
EDITOR_APP_STATUS_VALID,
} from '~/pipeline_editor/constants';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
@@ -44,6 +47,7 @@ describe('Pipeline editor tabs component', () => {
provide: { ...mockProvide, ...provide },
stubs: {
TextEditor: MockTextEditor,
+ EditorTab,
},
});
};
@@ -192,4 +196,24 @@ describe('Pipeline editor tabs component', () => {
});
});
});
+
+ describe('show tab content based on status', () => {
+ it.each`
+ appStatus | editor | viz | lint | merged
+ ${undefined} | ${true} | ${true} | ${true} | ${true}
+ ${EDITOR_APP_STATUS_EMPTY} | ${true} | ${false} | ${false} | ${false}
+ ${EDITOR_APP_STATUS_INVALID} | ${true} | ${false} | ${true} | ${false}
+ ${EDITOR_APP_STATUS_VALID} | ${true} | ${true} | ${true} | ${true}
+ `(
+ 'when status is $appStatus, we show - editor:$editor | viz:$viz | lint:$lint | merged:$merged ',
+ ({ appStatus, editor, viz, lint, merged }) => {
+ createComponent({ appStatus });
+
+ expect(findTextEditor().exists()).toBe(editor);
+ expect(findPipelineGraph().exists()).toBe(viz);
+ expect(findCiLint().exists()).toBe(lint);
+ expect(findMergedPreview().exists()).toBe(merged);
+ },
+ );
+ });
});
diff --git a/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js b/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js
index 291468c5229..8def83d578b 100644
--- a/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js
+++ b/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js
@@ -1,12 +1,15 @@
-import { GlTabs } from '@gitlab/ui';
+import { GlAlert, GlTabs } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
-
import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue';
const mockContent1 = 'MOCK CONTENT 1';
const mockContent2 = 'MOCK CONTENT 2';
+const MockEditorLite = {
+ template: '<div>EDITOR</div>',
+};
+
describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
let wrapper;
let mockChildMounted = jest.fn();
@@ -37,22 +40,34 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
`,
};
- const createWrapper = () => {
+ const createMockedWrapper = () => {
wrapper = mount(MockTabbedContent);
};
+ const createWrapper = ({ props } = {}) => {
+ wrapper = mount(EditorTab, {
+ propsData: props,
+ slots: {
+ default: MockEditorLite,
+ },
+ });
+ };
+
+ const findSlotComponent = () => wrapper.findComponent(MockEditorLite);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+
beforeEach(() => {
mockChildMounted = jest.fn();
});
it('tabs are mounted lazily', async () => {
- createWrapper();
+ createMockedWrapper();
expect(mockChildMounted).toHaveBeenCalledTimes(0);
});
it('first tab is only mounted after nextTick', async () => {
- createWrapper();
+ createMockedWrapper();
await nextTick();
@@ -60,6 +75,36 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
expect(mockChildMounted).toHaveBeenCalledWith(mockContent1);
});
+ describe('showing the tab content depending on `isEmpty` and `isInvalid`', () => {
+ it.each`
+ isEmpty | isInvalid | showSlotComponent | text
+ ${undefined} | ${undefined} | ${true} | ${'renders'}
+ ${false} | ${false} | ${true} | ${'renders'}
+ ${undefined} | ${true} | ${false} | ${'hides'}
+ ${true} | ${false} | ${false} | ${'hides'}
+ ${false} | ${true} | ${false} | ${'hides'}
+ `(
+ '$text the slot component when isEmpty:$isEmpty and isInvalid:$isInvalid',
+ ({ isEmpty, isInvalid, showSlotComponent }) => {
+ createWrapper({
+ props: { isEmpty, isInvalid },
+ });
+ expect(findSlotComponent().exists()).toBe(showSlotComponent);
+ expect(findAlert().exists()).toBe(!showSlotComponent);
+ },
+ );
+
+ it('can have a custom empty message', () => {
+ const text = 'my custom alert message';
+ createWrapper({ props: { isEmpty: true, emptyMessage: text } });
+
+ const alert = findAlert();
+
+ expect(alert.exists()).toBe(true);
+ expect(alert.text()).toBe(text);
+ });
+ });
+
describe('user interaction', () => {
const clickTab = async (testid) => {
wrapper.find(`[data-testid="${testid}"]`).trigger('click');
@@ -67,7 +112,7 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
};
beforeEach(() => {
- createWrapper();
+ createMockedWrapper();
});
it('mounts a tab once after selecting it', async () => {
diff --git a/spec/frontend/pipelines/notification/pipeline_notification_spec.js b/spec/frontend/pipelines/notification/pipeline_notification_spec.js
new file mode 100644
index 00000000000..79aa337ba9d
--- /dev/null
+++ b/spec/frontend/pipelines/notification/pipeline_notification_spec.js
@@ -0,0 +1,79 @@
+import { GlBanner } from '@gitlab/ui';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import PipelineNotification from '~/pipelines/components/notification/pipeline_notification.vue';
+import getUserCallouts from '~/pipelines/graphql/queries/get_user_callouts.query.graphql';
+
+describe('Pipeline notification', () => {
+ const localVue = createLocalVue();
+
+ let wrapper;
+ const dagDocPath = 'my/dag/path';
+
+ const createWrapper = (apolloProvider) => {
+ return shallowMount(PipelineNotification, {
+ localVue,
+ provide: {
+ dagDocPath,
+ },
+ apolloProvider,
+ });
+ };
+
+ const createWrapperWithApollo = async ({ callouts = [], isLoading = false } = {}) => {
+ localVue.use(VueApollo);
+
+ const mappedCallouts = callouts.map((callout) => {
+ return { featureName: callout, __typename: 'UserCallout' };
+ });
+
+ const mockCalloutsResponse = {
+ data: {
+ currentUser: {
+ id: 45,
+ __typename: 'User',
+ callouts: {
+ id: 5,
+ __typename: 'UserCalloutConnection',
+ nodes: mappedCallouts,
+ },
+ },
+ },
+ };
+ const getUserCalloutsHandler = jest.fn().mockResolvedValue(mockCalloutsResponse);
+ const requestHandlers = [[getUserCallouts, getUserCalloutsHandler]];
+
+ const apolloWrapper = createWrapper(createMockApollo(requestHandlers));
+ if (!isLoading) {
+ await nextTick();
+ }
+
+ return apolloWrapper;
+ };
+
+ const findBanner = () => wrapper.findComponent(GlBanner);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('shows the banner if the user has never seen it', async () => {
+ wrapper = await createWrapperWithApollo({ callouts: ['random'] });
+
+ expect(findBanner().exists()).toBe(true);
+ });
+
+ it('does not show the banner while the user callout query is loading', async () => {
+ wrapper = await createWrapperWithApollo({ callouts: ['random'], isLoading: true });
+
+ expect(findBanner().exists()).toBe(false);
+ });
+
+ it('does not show the banner if the user has previously dismissed it', async () => {
+ wrapper = await createWrapperWithApollo({ callouts: ['pipeline_needs_banner'.toUpperCase()] });
+
+ expect(findBanner().exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js
index 6deec06e344..258f2bda829 100644
--- a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js
+++ b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js
@@ -1,12 +1,12 @@
import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { CI_CONFIG_STATUS_INVALID, CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants';
+import { CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants';
import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue';
-import { DRAW_FAILURE, EMPTY_PIPELINE_DATA, INVALID_CI_CONFIG } from '~/pipelines/constants';
+import { DRAW_FAILURE } from '~/pipelines/constants';
import { invalidNeedsData, pipelineData, singleStageData } from './mock_data';
describe('pipeline graph component', () => {
@@ -42,31 +42,6 @@ describe('pipeline graph component', () => {
wrapper.destroy();
});
- describe('with no data', () => {
- beforeEach(() => {
- wrapper = createComponent({ pipelineData: {} });
- });
-
- it('does not render the graph', () => {
- expect(wrapper.text()).toBe(wrapper.vm.$options.errorTexts[EMPTY_PIPELINE_DATA]);
- expect(findPipelineGraph().exists()).toBe(false);
- expect(findAllStagePills()).toHaveLength(0);
- expect(findAllJobPills()).toHaveLength(0);
- });
- });
-
- describe('with `INVALID` status', () => {
- beforeEach(() => {
- wrapper = createComponent({ pipelineData: { status: CI_CONFIG_STATUS_INVALID } });
- });
-
- it('renders an error message and does not render the graph', () => {
- expect(findAlert().exists()).toBe(true);
- expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[INVALID_CI_CONFIG]);
- expect(findPipelineGraph().exists()).toBe(false);
- });
- });
-
describe('with `VALID` status', () => {
beforeEach(() => {
wrapper = createComponent({
diff --git a/spec/graphql/features/authorization_spec.rb b/spec/graphql/features/authorization_spec.rb
index d2a6b91d1c2..64e423e2bf8 100644
--- a/spec/graphql/features/authorization_spec.rb
+++ b/spec/graphql/features/authorization_spec.rb
@@ -376,6 +376,26 @@ RSpec.describe 'DeclarativePolicy authorization in GraphQL ' do
end
end
+ describe 'Authorization on GraphQL::Execution::Execute::SKIP' do
+ let(:type) do
+ type_factory do |type|
+ type.authorize permission_single
+ end
+ end
+
+ let(:query_type) do
+ query_factory do |query|
+ query.field :item, [type], null: true, resolver: new_resolver(GraphQL::Execution::Execute::SKIP)
+ end
+ end
+
+ it 'skips redaction' do
+ expect(Ability).not_to receive(:allowed?)
+
+ result
+ end
+ end
+
private
def permit(*permissions)
diff --git a/spec/graphql/resolvers/namespace_projects_resolver_spec.rb b/spec/graphql/resolvers/namespace_projects_resolver_spec.rb
index 147a02e1d79..618d012bd6d 100644
--- a/spec/graphql/resolvers/namespace_projects_resolver_spec.rb
+++ b/spec/graphql/resolvers/namespace_projects_resolver_spec.rb
@@ -112,7 +112,7 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do
subject(:projects) { resolve_projects(args) }
let(:include_subgroups) { false }
- let(:project_3) { create(:project, name: 'Project', path: 'project', namespace: namespace) }
+ let!(:project_3) { create(:project, name: 'Project', path: 'project', namespace: namespace) }
context 'when ids is provided' do
let(:ids) { [project_3.to_global_id.to_s] }
diff --git a/spec/knapsack_env.rb b/spec/knapsack_env.rb
index 7dc1a43d644..727d18f32e2 100644
--- a/spec/knapsack_env.rb
+++ b/spec/knapsack_env.rb
@@ -3,44 +3,9 @@
require 'knapsack'
module KnapsackEnv
- class RSpecContextAdapter < Knapsack::Adapters::RSpecAdapter
- def bind_time_tracker
- ::RSpec.configure do |config|
- # Original version starts timer in `config.prepend_before(:each) do`
- # https://github.com/KnapsackPro/knapsack/blob/v1.17.0/lib/knapsack/adapters/rspec_adapter.rb#L9
- config.prepend_before(:context) do
- Knapsack.tracker.start_timer
- end
-
- # Original version is `config.prepend_before(:each) do`
- # https://github.com/KnapsackPro/knapsack/blob/v1.17.0/lib/knapsack/adapters/rspec_adapter.rb#L9
- config.prepend_before(:each) do # rubocop:disable RSpec/HookArgument
- current_example_group =
- if ::RSpec.respond_to?(:current_example)
- ::RSpec.current_example.metadata[:example_group]
- else
- example.metadata
- end
-
- Knapsack.tracker.test_path = Knapsack::Adapters::RSpecAdapter.test_path(current_example_group)
- end
-
- # Original version stops timer in `config.append_after(:each) do`
- # https://github.com/KnapsackPro/knapsack/blob/v1.17.0/lib/knapsack/adapters/rspec_adapter.rb#L20
- config.append_after(:context) do
- Knapsack.tracker.stop_timer
- end
-
- config.after(:suite) do
- Knapsack.logger.info(Knapsack::Presenter.global_time)
- end
- end
- end
- end
-
def self.configure!
return unless ENV['CI'] && ENV['KNAPSACK_GENERATE_REPORT'] && !ENV['NO_KNAPSACK']
- RSpecContextAdapter.bind
+ Knapsack::Adapters::RSpecAdapter.bind
end
end
diff --git a/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb b/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb
index 74834fb9014..43cbe71dd6b 100644
--- a/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb
+++ b/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb
@@ -214,7 +214,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do
expect(Gitlab::SidekiqCluster).not_to receive(:start)
expect { cli.run(%W(#{flag} unknown_field=chatops)) }
- .to raise_error(Gitlab::SidekiqConfig::CliMethods::QueryError)
+ .to raise_error(Gitlab::SidekiqConfig::WorkerMatcher::QueryError)
end
end
end
diff --git a/spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb b/spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb
index 01e7c06249a..bc63289a344 100644
--- a/spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb
+++ b/spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rspec-parameterized'
RSpec.describe Gitlab::SidekiqConfig::CliMethods do
let(:dummy_root) { '/tmp/' }
@@ -122,10 +121,8 @@ RSpec.describe Gitlab::SidekiqConfig::CliMethods do
end
end
- describe '.query_workers' do
- using RSpec::Parameterized::TableSyntax
-
- let(:queues) do
+ describe '.query_queues' do
+ let(:worker_metadatas) do
[
{
name: 'a',
@@ -162,79 +159,16 @@ RSpec.describe Gitlab::SidekiqConfig::CliMethods do
]
end
- context 'with valid input' do
- where(:query, :selected_queues) do
- # feature_category
- 'feature_category=category_a' | %w(a a:2)
- 'feature_category=category_a,category_c' | %w(a a:2 c)
- 'feature_category=category_a|feature_category=category_c' | %w(a a:2 c)
- 'feature_category!=category_a' | %w(b c)
-
- # has_external_dependencies
- 'has_external_dependencies=true' | %w(b)
- 'has_external_dependencies=false' | %w(a a:2 c)
- 'has_external_dependencies=true,false' | %w(a a:2 b c)
- 'has_external_dependencies=true|has_external_dependencies=false' | %w(a a:2 b c)
- 'has_external_dependencies!=true' | %w(a a:2 c)
-
- # urgency
- 'urgency=high' | %w(a:2 b)
- 'urgency=low' | %w(a)
- 'urgency=high,low,throttled' | %w(a a:2 b c)
- 'urgency=low|urgency=throttled' | %w(a c)
- 'urgency!=high' | %w(a c)
-
- # name
- 'name=a' | %w(a)
- 'name=a,b' | %w(a b)
- 'name=a,a:2|name=b' | %w(a a:2 b)
- 'name!=a,a:2' | %w(b c)
-
- # resource_boundary
- 'resource_boundary=memory' | %w(b c)
- 'resource_boundary=memory,cpu' | %w(a b c)
- 'resource_boundary=memory|resource_boundary=cpu' | %w(a b c)
- 'resource_boundary!=memory,cpu' | %w(a:2)
-
- # tags
- 'tags=no_disk_io' | %w(a b)
- 'tags=no_disk_io,git_access' | %w(a a:2 b)
- 'tags=no_disk_io|tags=git_access' | %w(a a:2 b)
- 'tags=no_disk_io&tags=git_access' | %w(a)
- 'tags!=no_disk_io' | %w(a:2 c)
- 'tags!=no_disk_io,git_access' | %w(c)
- 'tags=unknown_tag' | []
- 'tags!=no_disk_io' | %w(a:2 c)
- 'tags!=no_disk_io,git_access' | %w(c)
- 'tags!=unknown_tag' | %w(a a:2 b c)
-
- # combinations
- 'feature_category=category_a&urgency=high' | %w(a:2)
- 'feature_category=category_a&urgency=high|feature_category=category_c' | %w(a:2 c)
- end
+ let(:worker_matcher) { double(:WorkerMatcher) }
+ let(:query) { 'feature_category=category_a,category_c' }
- with_them do
- it do
- expect(described_class.query_workers(query, queues))
- .to match_array(selected_queues)
- end
- end
+ before do
+ allow(::Gitlab::SidekiqConfig::WorkerMatcher).to receive(:new).with(query).and_return(worker_matcher)
+ allow(worker_matcher).to receive(:match?).and_return(true, true, false, true)
end
- context 'with invalid input' do
- where(:query, :error) do
- 'feature_category="category_a"' | described_class::InvalidTerm
- 'feature_category=' | described_class::InvalidTerm
- 'feature_category~category_a' | described_class::InvalidTerm
- 'worker_name=a' | described_class::UnknownPredicate
- end
-
- with_them do
- it do
- expect { described_class.query_workers(query, queues) }
- .to raise_error(error)
- end
- end
+ it 'returns the queue names of matched workers' do
+ expect(described_class.query_queues(query, worker_metadatas)).to match(%w(a a:2 c))
end
end
end
diff --git a/spec/lib/gitlab/sidekiq_config/worker_matcher_spec.rb b/spec/lib/gitlab/sidekiq_config/worker_matcher_spec.rb
new file mode 100644
index 00000000000..75e9c8c100b
--- /dev/null
+++ b/spec/lib/gitlab/sidekiq_config/worker_matcher_spec.rb
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'rspec-parameterized'
+
+RSpec.describe Gitlab::SidekiqConfig::WorkerMatcher do
+ describe '#match?' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:worker_metadatas) do
+ [
+ {
+ name: 'a',
+ feature_category: :category_a,
+ has_external_dependencies: false,
+ urgency: :low,
+ resource_boundary: :cpu,
+ tags: [:no_disk_io, :git_access]
+ },
+ {
+ name: 'a:2',
+ feature_category: :category_a,
+ has_external_dependencies: false,
+ urgency: :high,
+ resource_boundary: :none,
+ tags: [:git_access]
+ },
+ {
+ name: 'b',
+ feature_category: :category_b,
+ has_external_dependencies: true,
+ urgency: :high,
+ resource_boundary: :memory,
+ tags: [:no_disk_io]
+ },
+ {
+ name: 'c',
+ feature_category: :category_c,
+ has_external_dependencies: false,
+ urgency: :throttled,
+ resource_boundary: :memory,
+ tags: []
+ }
+ ]
+ end
+
+ context 'with valid input' do
+ where(:query, :expected_metadatas) do
+ # feature_category
+ 'feature_category=category_a' | %w(a a:2)
+ 'feature_category=category_a,category_c' | %w(a a:2 c)
+ 'feature_category=category_a|feature_category=category_c' | %w(a a:2 c)
+ 'feature_category!=category_a' | %w(b c)
+
+ # has_external_dependencies
+ 'has_external_dependencies=true' | %w(b)
+ 'has_external_dependencies=false' | %w(a a:2 c)
+ 'has_external_dependencies=true,false' | %w(a a:2 b c)
+ 'has_external_dependencies=true|has_external_dependencies=false' | %w(a a:2 b c)
+ 'has_external_dependencies!=true' | %w(a a:2 c)
+
+ # urgency
+ 'urgency=high' | %w(a:2 b)
+ 'urgency=low' | %w(a)
+ 'urgency=high,low,throttled' | %w(a a:2 b c)
+ 'urgency=low|urgency=throttled' | %w(a c)
+ 'urgency!=high' | %w(a c)
+
+ # name
+ 'name=a' | %w(a)
+ 'name=a,b' | %w(a b)
+ 'name=a,a:2|name=b' | %w(a a:2 b)
+ 'name!=a,a:2' | %w(b c)
+
+ # resource_boundary
+ 'resource_boundary=memory' | %w(b c)
+ 'resource_boundary=memory,cpu' | %w(a b c)
+ 'resource_boundary=memory|resource_boundary=cpu' | %w(a b c)
+ 'resource_boundary!=memory,cpu' | %w(a:2)
+
+ # tags
+ 'tags=no_disk_io' | %w(a b)
+ 'tags=no_disk_io,git_access' | %w(a a:2 b)
+ 'tags=no_disk_io|tags=git_access' | %w(a a:2 b)
+ 'tags=no_disk_io&tags=git_access' | %w(a)
+ 'tags!=no_disk_io' | %w(a:2 c)
+ 'tags!=no_disk_io,git_access' | %w(c)
+ 'tags=unknown_tag' | []
+ 'tags!=no_disk_io' | %w(a:2 c)
+ 'tags!=no_disk_io,git_access' | %w(c)
+ 'tags!=unknown_tag' | %w(a a:2 b c)
+
+ # combinations
+ 'feature_category=category_a&urgency=high' | %w(a:2)
+ 'feature_category=category_a&urgency=high|feature_category=category_c' | %w(a:2 c)
+
+ # Match all
+ '*' | %w(a a:2 b c)
+ end
+
+ with_them do
+ it do
+ matched_metadatas = worker_metadatas.select do |metadata|
+ described_class.new(query).match?(metadata)
+ end
+ expect(matched_metadatas.map { |m| m[:name] }).to match_array(expected_metadatas)
+ end
+ end
+ end
+
+ context 'with invalid input' do
+ where(:query, :error) do
+ 'feature_category="category_a"' | described_class::InvalidTerm
+ 'feature_category=' | described_class::InvalidTerm
+ 'feature_category~category_a' | described_class::InvalidTerm
+ 'worker_name=a' | described_class::UnknownPredicate
+ end
+
+ with_them do
+ it do
+ worker_metadatas.each do |metadata|
+ expect { described_class.new(query).match?(metadata) }
+ .to raise_error(error)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb
index a7d26c40909..c4146b347d7 100644
--- a/spec/models/todo_spec.rb
+++ b/spec/models/todo_spec.rb
@@ -376,6 +376,22 @@ RSpec.describe Todo do
end
end
+ describe '.group_by_user_id_and_state' do
+ let_it_be(:user1) { create(:user) }
+ let_it_be(:user2) { create(:user) }
+
+ before do
+ create(:todo, user: user1, state: :pending)
+ create(:todo, user: user1, state: :pending)
+ create(:todo, user: user1, state: :done)
+ create(:todo, user: user2, state: :pending)
+ end
+
+ specify do
+ expect(Todo.count_grouped_by_user_id_and_state).to eq({ [user1.id, "done"] => 1, [user1.id, "pending"] => 2, [user2.id, "pending"] => 1 })
+ end
+ end
+
describe '.any_for_target?' do
it 'returns true if there are todos for a given target' do
todo = create(:todo)
diff --git a/spec/requests/api/nuget_group_packages_spec.rb b/spec/requests/api/nuget_group_packages_spec.rb
index 1cc12d0af2e..aefbc89dc3b 100644
--- a/spec/requests/api/nuget_group_packages_spec.rb
+++ b/spec/requests/api/nuget_group_packages_spec.rb
@@ -69,7 +69,7 @@ RSpec.describe API::NugetGroupPackages do
let(:take) { 26 }
let(:skip) { 0 }
let(:include_prereleases) { true }
- let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases } }
+ let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases }.compact }
subject { get api(url), headers: {}}
@@ -145,7 +145,7 @@ RSpec.describe API::NugetGroupPackages do
let(:take) { 26 }
let(:skip) { 0 }
let(:include_prereleases) { false }
- let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases } }
+ let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases }.compact }
let(:url) { "/groups/#{group.id}/-/packages/nuget/query?#{query_parameters.to_query}" }
it_behaves_like 'returning response status', :forbidden
diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb
index 227f39abd0b..59f936509df 100644
--- a/spec/services/todo_service_spec.rb
+++ b/spec/services/todo_service_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe TodoService do
+ include AfterNextHelpers
+
let_it_be(:project) { create(:project, :repository) }
let_it_be(:author) { create(:user) }
let_it_be(:assignee) { create(:user) }
@@ -343,19 +345,19 @@ RSpec.describe TodoService do
describe '#destroy_target' do
it 'refreshes the todos count cache for users with todos on the target' do
- create(:todo, target: issue, user: john_doe, author: john_doe, project: issue.project)
+ create(:todo, state: :pending, target: issue, user: john_doe, author: john_doe, project: issue.project)
- expect_any_instance_of(User).to receive(:update_todos_count_cache).and_call_original
+ expect_next(Users::UpdateTodoCountCacheService, [john_doe]).to receive(:execute)
- service.destroy_target(issue) { }
+ service.destroy_target(issue) { issue.destroy! }
end
it 'does not refresh the todos count cache for users with only done todos on the target' do
create(:todo, :done, target: issue, user: john_doe, author: john_doe, project: issue.project)
- expect_any_instance_of(User).not_to receive(:update_todos_count_cache)
+ expect(Users::UpdateTodoCountCacheService).not_to receive(:new)
- service.destroy_target(issue) { }
+ service.destroy_target(issue) { issue.destroy! }
end
it 'yields the target to the caller' do
@@ -1099,13 +1101,9 @@ RSpec.describe TodoService do
it 'updates cached counts when a todo is created' do
issue = create(:issue, project: project, assignees: [john_doe], author: author)
- expect(john_doe.todos_pending_count).to eq(0)
- expect(john_doe).to receive(:update_todos_count_cache).and_call_original
+ expect_next(Users::UpdateTodoCountCacheService, [john_doe]).to receive(:execute)
service.new_issue(issue, author)
-
- expect(Todo.where(user_id: john_doe.id, state: :pending).count).to eq 1
- expect(john_doe.todos_pending_count).to eq(1)
end
shared_examples 'updating todos state' do |state, new_state, new_resolved_by = nil|
diff --git a/spec/services/users/update_todo_count_cache_service_spec.rb b/spec/services/users/update_todo_count_cache_service_spec.rb
new file mode 100644
index 00000000000..3e3618b1291
--- /dev/null
+++ b/spec/services/users/update_todo_count_cache_service_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Users::UpdateTodoCountCacheService do
+ describe '#execute' do
+ let_it_be(:user1) { create(:user) }
+ let_it_be(:user2) { create(:user) }
+
+ let_it_be(:todo1) { create(:todo, user: user1, state: :done) }
+ let_it_be(:todo2) { create(:todo, user: user1, state: :done) }
+ let_it_be(:todo3) { create(:todo, user: user1, state: :pending) }
+ let_it_be(:todo4) { create(:todo, user: user2, state: :done) }
+ let_it_be(:todo5) { create(:todo, user: user2, state: :pending) }
+ let_it_be(:todo6) { create(:todo, user: user2, state: :pending) }
+
+ it 'updates the todos_counts for users', :use_clean_rails_memory_store_caching do
+ Rails.cache.write(['users', user1.id, 'todos_done_count'], 0)
+ Rails.cache.write(['users', user1.id, 'todos_pending_count'], 0)
+ Rails.cache.write(['users', user2.id, 'todos_done_count'], 0)
+ Rails.cache.write(['users', user2.id, 'todos_pending_count'], 0)
+
+ expect { described_class.new([user1, user2]).execute }
+ .to change(user1, :todos_done_count).from(0).to(2)
+ .and change(user1, :todos_pending_count).from(0).to(1)
+ .and change(user2, :todos_done_count).from(0).to(1)
+ .and change(user2, :todos_pending_count).from(0).to(2)
+
+ Todo.delete_all
+
+ expect { described_class.new([user1, user2]).execute }
+ .to change(user1, :todos_done_count).from(2).to(0)
+ .and change(user1, :todos_pending_count).from(1).to(0)
+ .and change(user2, :todos_done_count).from(1).to(0)
+ .and change(user2, :todos_pending_count).from(2).to(0)
+ end
+
+ it 'avoids N+1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new { described_class.new([user1]).execute }.count
+
+ expect { described_class.new([user1, user2]).execute }.not_to exceed_query_limit(control_count)
+ end
+
+ it 'executes one query per batch of users' do
+ stub_const("#{described_class}::QUERY_BATCH_SIZE", 1)
+
+ expect(ActiveRecord::QueryRecorder.new { described_class.new([user1]).execute }.count).to eq(1)
+ expect(ActiveRecord::QueryRecorder.new { described_class.new([user1, user2]).execute }.count).to eq(2)
+ end
+
+ it 'sets the cache expire time to the users count_cache_validity_period' do
+ allow(user1).to receive(:count_cache_validity_period).and_return(1.minute)
+ allow(user2).to receive(:count_cache_validity_period).and_return(1.hour)
+
+ expect(Rails.cache).to receive(:write).with(['users', user1.id, anything], anything, expires_in: 1.minute).twice
+ expect(Rails.cache).to receive(:write).with(['users', user2.id, anything], anything, expires_in: 1.hour).twice
+
+ described_class.new([user1, user2]).execute
+ end
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb b/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb
index cdb9af31dca..db70bc75c63 100644
--- a/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb
@@ -225,7 +225,7 @@ RSpec.shared_examples 'handling nuget search requests' do |anonymous_requests_ex
let(:take) { 26 }
let(:skip) { 0 }
let(:include_prereleases) { true }
- let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases } }
+ let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases }.compact }
subject { get api(url) }
diff --git a/workhorse/internal/upload/rewrite.go b/workhorse/internal/upload/rewrite.go
index 29c3e54f4f2..85063d65c1b 100644
--- a/workhorse/internal/upload/rewrite.go
+++ b/workhorse/internal/upload/rewrite.go
@@ -192,7 +192,10 @@ func handleExifUpload(ctx context.Context, r io.Reader, filename string, imageTy
return nil, err
}
- tmpfile.Seek(0, io.SeekStart)
+ if _, err := tmpfile.Seek(0, io.SeekStart); err != nil {
+ return nil, err
+ }
+
isValidType := false
switch imageType {
case exif.TypeJPEG:
@@ -201,7 +204,10 @@ func handleExifUpload(ctx context.Context, r io.Reader, filename string, imageTy
isValidType = isTIFF(tmpfile)
}
- tmpfile.Seek(0, io.SeekStart)
+ if _, err := tmpfile.Seek(0, io.SeekStart); err != nil {
+ return nil, err
+ }
+
if !isValidType {
log.WithContextFields(ctx, log.Fields{
"filename": filename,