Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
3257ae3af0
commit
c6af94ea4e
|
@ -2911,33 +2911,6 @@ Gitlab/NamespacedClass:
|
||||||
- 'spec/tasks/gitlab/task_helpers_spec.rb'
|
- 'spec/tasks/gitlab/task_helpers_spec.rb'
|
||||||
- 'spec/uploaders/object_storage_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:
|
Style/ClassEqualityComparison:
|
||||||
Exclude:
|
Exclude:
|
||||||
- spec/lib/peek/views/active_record_spec.rb
|
- spec/lib/peek/views/active_record_spec.rb
|
||||||
|
|
3
Gemfile
3
Gemfile
|
@ -342,7 +342,6 @@ group :metrics do
|
||||||
end
|
end
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
gem 'brakeman', '~> 4.10.0', require: false
|
|
||||||
gem 'lefthook', '~> 0.7.0', require: false
|
gem 'lefthook', '~> 0.7.0', require: false
|
||||||
|
|
||||||
gem 'letter_opener_web', '~> 1.4.0'
|
gem 'letter_opener_web', '~> 1.4.0'
|
||||||
|
@ -383,7 +382,7 @@ group :development, :test do
|
||||||
|
|
||||||
gem 'benchmark-ips', '~> 2.3.0', require: false
|
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 'crystalball', '~> 0.7.0', require: false
|
||||||
|
|
||||||
gem 'simple_po_parser', '~> 1.1.2', require: false
|
gem 'simple_po_parser', '~> 1.1.2', require: false
|
||||||
|
|
|
@ -151,7 +151,6 @@ GEM
|
||||||
bootstrap_form (4.2.0)
|
bootstrap_form (4.2.0)
|
||||||
actionpack (>= 5.0)
|
actionpack (>= 5.0)
|
||||||
activemodel (>= 5.0)
|
activemodel (>= 5.0)
|
||||||
brakeman (4.10.1)
|
|
||||||
browser (4.2.0)
|
browser (4.2.0)
|
||||||
builder (3.2.4)
|
builder (3.2.4)
|
||||||
bullet (6.1.3)
|
bullet (6.1.3)
|
||||||
|
@ -672,7 +671,7 @@ GEM
|
||||||
kaminari-core (= 1.2.1)
|
kaminari-core (= 1.2.1)
|
||||||
kaminari-core (1.2.1)
|
kaminari-core (1.2.1)
|
||||||
kgio (2.11.3)
|
kgio (2.11.3)
|
||||||
knapsack (1.17.0)
|
knapsack (1.21.1)
|
||||||
rake
|
rake
|
||||||
kramdown (2.3.1)
|
kramdown (2.3.1)
|
||||||
rexml
|
rexml
|
||||||
|
@ -1369,7 +1368,6 @@ DEPENDENCIES
|
||||||
better_errors (~> 2.9.0)
|
better_errors (~> 2.9.0)
|
||||||
bootsnap (~> 1.4.6)
|
bootsnap (~> 1.4.6)
|
||||||
bootstrap_form (~> 4.2.0)
|
bootstrap_form (~> 4.2.0)
|
||||||
brakeman (~> 4.10.0)
|
|
||||||
browser (~> 4.2)
|
browser (~> 4.2)
|
||||||
bullet (~> 6.1.3)
|
bullet (~> 6.1.3)
|
||||||
bundler-audit (~> 0.7.0.1)
|
bundler-audit (~> 0.7.0.1)
|
||||||
|
@ -1476,7 +1474,7 @@ DEPENDENCIES
|
||||||
json_schemer (~> 0.2.12)
|
json_schemer (~> 0.2.12)
|
||||||
jwt (~> 2.1.0)
|
jwt (~> 2.1.0)
|
||||||
kaminari (~> 1.0)
|
kaminari (~> 1.0)
|
||||||
knapsack (~> 1.17)
|
knapsack (~> 1.21.1)
|
||||||
kramdown (~> 2.3.1)
|
kramdown (~> 2.3.1)
|
||||||
kubeclient (~> 4.9.1)
|
kubeclient (~> 4.9.1)
|
||||||
lefthook (~> 0.7.0)
|
lefthook (~> 0.7.0)
|
||||||
|
|
2
Rakefile
2
Rakefile
|
@ -4,6 +4,8 @@
|
||||||
# Add your own tasks in files placed in lib/tasks ending in .rake,
|
# 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.
|
# 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__)
|
require File.expand_path('config/application', __dir__)
|
||||||
|
|
||||||
relative_url_conf = File.expand_path('config/initializers/relative_url', __dir__)
|
relative_url_conf = File.expand_path('config/initializers/relative_url', __dir__)
|
||||||
|
|
|
@ -50,12 +50,18 @@ export default {
|
||||||
return this.resolveDiscussion ? 'is-resolving-discussion' : 'is-unresolving-discussion';
|
return this.resolveDiscussion ? 'is-resolving-discussion' : 'is-unresolving-discussion';
|
||||||
},
|
},
|
||||||
resolveButtonTitle() {
|
resolveButtonTitle() {
|
||||||
|
const escapeParameters = false;
|
||||||
|
|
||||||
if (this.isDraft || this.discussionId) return this.resolvedStatusMessage;
|
if (this.isDraft || this.discussionId) return this.resolvedStatusMessage;
|
||||||
|
|
||||||
let title = __('Resolve thread');
|
let title = __('Resolve thread');
|
||||||
|
|
||||||
if (this.resolvedBy) {
|
if (this.resolvedBy) {
|
||||||
title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name });
|
title = sprintf(
|
||||||
|
__('Resolved by %{name}'),
|
||||||
|
{ name: this.resolvedBy.name },
|
||||||
|
escapeParameters,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return title;
|
return title;
|
||||||
|
|
|
@ -5,10 +5,13 @@ import InviteMemberModal from './components/invite_member_modal.vue';
|
||||||
|
|
||||||
Vue.use(GlToast);
|
Vue.use(GlToast);
|
||||||
|
|
||||||
|
const isAssigneesWidgetShown =
|
||||||
|
(isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget;
|
||||||
|
|
||||||
export default function initInviteMembersModal() {
|
export default function initInviteMembersModal() {
|
||||||
const el = document.querySelector('.js-invite-member-modal');
|
const el = document.querySelector('.js-invite-member-modal');
|
||||||
|
|
||||||
if (!el || isInDesignPage() || isInIssuePage()) {
|
if (!el || isAssigneesWidgetShown) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -390,7 +390,7 @@ export default {
|
||||||
<gl-button
|
<gl-button
|
||||||
:disabled="isDisabled"
|
:disabled="isDisabled"
|
||||||
category="primary"
|
category="primary"
|
||||||
variant="success"
|
variant="confirm"
|
||||||
class="gl-mr-3"
|
class="gl-mr-3"
|
||||||
data-qa-selector="start_review_button"
|
data-qa-selector="start_review_button"
|
||||||
@click="handleAddToReview"
|
@click="handleAddToReview"
|
||||||
|
|
|
@ -1,29 +1,19 @@
|
||||||
<script>
|
<script>
|
||||||
import { GlAlert, GlIcon } from '@gitlab/ui';
|
import { GlIcon } from '@gitlab/ui';
|
||||||
import { uniqueId } from 'lodash';
|
import { uniqueId } from 'lodash';
|
||||||
import { __, s__ } from '~/locale';
|
import { s__ } from '~/locale';
|
||||||
import { DEFAULT, INVALID_CI_CONFIG } from '~/pipelines/constants';
|
|
||||||
import EditorLite from '~/vue_shared/components/editor_lite.vue';
|
import EditorLite from '~/vue_shared/components/editor_lite.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
i18n: {
|
i18n: {
|
||||||
viewOnlyMessage: s__('Pipelines|Merged YAML is view only'),
|
viewOnlyMessage: s__('Pipelines|Merged YAML is view only'),
|
||||||
},
|
},
|
||||||
errorTexts: {
|
|
||||||
[INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'),
|
|
||||||
[DEFAULT]: __('An unknown error occurred.'),
|
|
||||||
},
|
|
||||||
components: {
|
components: {
|
||||||
EditorLite,
|
EditorLite,
|
||||||
GlAlert,
|
|
||||||
GlIcon,
|
GlIcon,
|
||||||
},
|
},
|
||||||
inject: ['ciConfigPath'],
|
inject: ['ciConfigPath'],
|
||||||
props: {
|
props: {
|
||||||
isValid: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
ciConfigData: {
|
ciConfigData: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -35,66 +25,30 @@ export default {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
failure() {
|
|
||||||
switch (this.failureType) {
|
|
||||||
case INVALID_CI_CONFIG:
|
|
||||||
return this.$options.errorTexts[INVALID_CI_CONFIG];
|
|
||||||
default:
|
|
||||||
return this.$options.errorTexts[DEFAULT];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fileGlobalId() {
|
fileGlobalId() {
|
||||||
return `${this.ciConfigPath}-${uniqueId()}`;
|
return `${this.ciConfigPath}-${uniqueId()}`;
|
||||||
},
|
},
|
||||||
hasError() {
|
|
||||||
return this.failureType;
|
|
||||||
},
|
|
||||||
mergedYaml() {
|
mergedYaml() {
|
||||||
return this.ciConfigData.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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<gl-alert v-if="hasError" variant="danger" :dismissible="false">
|
<div class="gl-display-flex gl-align-items-center">
|
||||||
{{ failure }}
|
<gl-icon :size="16" name="lock" class="gl-text-gray-500 gl-mr-3" />
|
||||||
</gl-alert>
|
{{ $options.i18n.viewOnlyMessage }}
|
||||||
<div v-else>
|
</div>
|
||||||
<div class="gl-display-flex gl-align-items-center">
|
<div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1">
|
||||||
<gl-icon :size="16" name="lock" class="gl-text-gray-500 gl-mr-3" />
|
<editor-lite
|
||||||
{{ $options.i18n.viewOnlyMessage }}
|
ref="editor"
|
||||||
</div>
|
:value="mergedYaml"
|
||||||
<div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1">
|
:file-name="ciConfigPath"
|
||||||
<editor-lite
|
:file-global-id="fileGlobalId"
|
||||||
ref="editor"
|
:editor-options="{ readOnly: true }"
|
||||||
:value="mergedYaml"
|
v-on="$listeners"
|
||||||
:file-name="ciConfigPath"
|
/>
|
||||||
:file-global-id="fileGlobalId"
|
|
||||||
:editor-options="{ readOnly: true }"
|
|
||||||
v-on="$listeners"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
<script>
|
<script>
|
||||||
import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui';
|
import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui';
|
||||||
import { s__ } from '~/locale';
|
import { s__ } from '~/locale';
|
||||||
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
|
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
|
||||||
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||||
import {
|
import {
|
||||||
CREATE_TAB,
|
CREATE_TAB,
|
||||||
|
EDITOR_APP_STATUS_EMPTY,
|
||||||
EDITOR_APP_STATUS_ERROR,
|
EDITOR_APP_STATUS_ERROR,
|
||||||
|
EDITOR_APP_STATUS_INVALID,
|
||||||
EDITOR_APP_STATUS_LOADING,
|
EDITOR_APP_STATUS_LOADING,
|
||||||
EDITOR_APP_STATUS_VALID,
|
EDITOR_APP_STATUS_VALID,
|
||||||
LINT_TAB,
|
LINT_TAB,
|
||||||
|
@ -24,6 +26,17 @@ export default {
|
||||||
tabGraph: s__('Pipelines|Visualize'),
|
tabGraph: s__('Pipelines|Visualize'),
|
||||||
tabLint: s__('Pipelines|Lint'),
|
tabLint: s__('Pipelines|Lint'),
|
||||||
tabMergedYaml: s__('Pipelines|View merged YAML'),
|
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: {
|
errorTexts: {
|
||||||
loadMergedYaml: s__('Pipelines|Could not load merged YAML content'),
|
loadMergedYaml: s__('Pipelines|Could not load merged YAML content'),
|
||||||
|
@ -40,7 +53,6 @@ export default {
|
||||||
EditorTab,
|
EditorTab,
|
||||||
GlAlert,
|
GlAlert,
|
||||||
GlLoadingIcon,
|
GlLoadingIcon,
|
||||||
GlTab,
|
|
||||||
GlTabs,
|
GlTabs,
|
||||||
PipelineGraph,
|
PipelineGraph,
|
||||||
TextEditor,
|
TextEditor,
|
||||||
|
@ -66,6 +78,12 @@ export default {
|
||||||
// Not an invalid config and with `mergedYaml` data missing
|
// Not an invalid config and with `mergedYaml` data missing
|
||||||
return this.appStatus === EDITOR_APP_STATUS_ERROR;
|
return this.appStatus === EDITOR_APP_STATUS_ERROR;
|
||||||
},
|
},
|
||||||
|
isEmpty() {
|
||||||
|
return this.appStatus === EDITOR_APP_STATUS_EMPTY;
|
||||||
|
},
|
||||||
|
isInvalid() {
|
||||||
|
return this.appStatus === EDITOR_APP_STATUS_INVALID;
|
||||||
|
},
|
||||||
isValid() {
|
isValid() {
|
||||||
return this.appStatus === EDITOR_APP_STATUS_VALID;
|
return this.appStatus === EDITOR_APP_STATUS_VALID;
|
||||||
},
|
},
|
||||||
|
@ -91,9 +109,12 @@ export default {
|
||||||
>
|
>
|
||||||
<text-editor :value="ciFileContent" v-on="$listeners" />
|
<text-editor :value="ciFileContent" v-on="$listeners" />
|
||||||
</editor-tab>
|
</editor-tab>
|
||||||
<gl-tab
|
<editor-tab
|
||||||
v-if="glFeatures.ciConfigVisualizationTab"
|
v-if="glFeatures.ciConfigVisualizationTab"
|
||||||
class="gl-mb-3"
|
class="gl-mb-3"
|
||||||
|
:empty-message="$options.i18n.empty.visualization"
|
||||||
|
:is-empty="isEmpty"
|
||||||
|
:is-invalid="isInvalid"
|
||||||
:title="$options.i18n.tabGraph"
|
:title="$options.i18n.tabGraph"
|
||||||
lazy
|
lazy
|
||||||
data-testid="visualization-tab"
|
data-testid="visualization-tab"
|
||||||
|
@ -101,9 +122,11 @@ export default {
|
||||||
>
|
>
|
||||||
<gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
|
<gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
|
||||||
<pipeline-graph v-else :pipeline-data="ciConfigData" />
|
<pipeline-graph v-else :pipeline-data="ciConfigData" />
|
||||||
</gl-tab>
|
</editor-tab>
|
||||||
<editor-tab
|
<editor-tab
|
||||||
class="gl-mb-3"
|
class="gl-mb-3"
|
||||||
|
:empty-message="$options.i18n.empty.lint"
|
||||||
|
:is-empty="isEmpty"
|
||||||
:title="$options.i18n.tabLint"
|
:title="$options.i18n.tabLint"
|
||||||
data-testid="lint-tab"
|
data-testid="lint-tab"
|
||||||
@click="setCurrentTab($options.tabConstants.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" />
|
<gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
|
||||||
<ci-lint v-else :is-valid="isValid" :ci-config="ciConfigData" />
|
<ci-lint v-else :is-valid="isValid" :ci-config="ciConfigData" />
|
||||||
</editor-tab>
|
</editor-tab>
|
||||||
<gl-tab
|
<editor-tab
|
||||||
v-if="glFeatures.ciConfigMergedTab"
|
v-if="glFeatures.ciConfigMergedTab"
|
||||||
class="gl-mb-3"
|
class="gl-mb-3"
|
||||||
|
:empty-message="$options.i18n.empty.merge"
|
||||||
|
:keep-component-mounted="false"
|
||||||
|
:is-empty="isEmpty"
|
||||||
|
:is-invalid="isInvalid"
|
||||||
:title="$options.i18n.tabMergedYaml"
|
:title="$options.i18n.tabMergedYaml"
|
||||||
lazy
|
lazy
|
||||||
data-testid="merged-tab"
|
data-testid="merged-tab"
|
||||||
|
@ -123,12 +150,7 @@ export default {
|
||||||
<gl-alert v-else-if="hasAppError" variant="danger" :dismissible="false">
|
<gl-alert v-else-if="hasAppError" variant="danger" :dismissible="false">
|
||||||
{{ $options.errorTexts.loadMergedYaml }}
|
{{ $options.errorTexts.loadMergedYaml }}
|
||||||
</gl-alert>
|
</gl-alert>
|
||||||
<ci-config-merged-preview
|
<ci-config-merged-preview v-else :ci-config-data="ciConfigData" v-on="$listeners" />
|
||||||
v-else
|
</editor-tab>
|
||||||
:is-valid="isValid"
|
|
||||||
:ci-config-data="ciConfigData"
|
|
||||||
v-on="$listeners"
|
|
||||||
/>
|
|
||||||
</gl-tab>
|
|
||||||
</gl-tabs>
|
</gl-tabs>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script>
|
<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
|
* Wrapper of <gl-tab> to optionally lazily render this tab's content
|
||||||
* when its shown **without dismounting after its hidden**.
|
* 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:
|
* API is the same as <gl-tab>, for example:
|
||||||
*
|
*
|
||||||
* <gl-tabs>
|
* <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)
|
* lazily mounted content (gets mounted if this is first tab)
|
||||||
* </editor-tab>
|
* </editor-tab>
|
||||||
* <editor-tab title="Tab 2" :lazy="true">
|
* <editor-tab title="Tab 2" lazy>
|
||||||
* lazily mounted content
|
* lazily mounted content
|
||||||
* </editor-tab>
|
* </editor-tab>
|
||||||
* <editor-tab title="Tab 3">
|
* <editor-tab title="Tab 3">
|
||||||
|
@ -25,10 +25,26 @@ import { GlTab } from '@gitlab/ui';
|
||||||
* so it's contents are not dismounted.
|
* so it's contents are not dismounted.
|
||||||
*
|
*
|
||||||
* lazy is "false" by default, as in <gl-tab>.
|
* 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 {
|
export default {
|
||||||
|
i18n: {
|
||||||
|
invalid: __('Your CI/CD configuration syntax is invalid. View Lint tab for more details.'),
|
||||||
|
},
|
||||||
components: {
|
components: {
|
||||||
|
GlAlert,
|
||||||
GlTab,
|
GlTab,
|
||||||
// Use a small renderless component to know when the tab content mounts because:
|
// Use a small renderless component to know when the tab content mounts because:
|
||||||
// - gl-tab always gets mounted, even if lazy is `true`. See:
|
// - gl-tab always gets mounted, even if lazy is `true`. See:
|
||||||
|
@ -40,29 +56,63 @@ export default {
|
||||||
},
|
},
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
props: {
|
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: {
|
lazy: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
keepComponentMounted: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isLazy: this.lazy,
|
isLazy: this.lazy,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
slots() {
|
||||||
|
return Object.keys(this.$slots);
|
||||||
|
},
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onContentMounted() {
|
onContentMounted() {
|
||||||
// When a child is first mounted make the entire tab
|
// When a child is first mounted make the entire tab
|
||||||
// permanently mounted by setting 'lazy' to false.
|
// permanently mounted by setting 'lazy' to false unless
|
||||||
this.isLazy = false;
|
// explicitly opted out.
|
||||||
|
if (this.keepComponentMounted) {
|
||||||
|
this.isLazy = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<gl-tab :lazy="isLazy" v-bind="$attrs" v-on="$listeners">
|
<gl-tab :lazy="isLazy" v-bind="$attrs" v-on="$listeners">
|
||||||
<slot v-for="slot in Object.keys($slots)" :name="slot"></slot>
|
<gl-alert v-if="isEmpty" variant="tip">{{ emptyMessage }}</gl-alert>
|
||||||
<mount-spy @hook:mounted="onContentMounted" />
|
<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>
|
</gl-tab>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -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>
|
|
@ -1,8 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { GlAlert } from '@gitlab/ui';
|
import { GlAlert } from '@gitlab/ui';
|
||||||
import { __ } from '~/locale';
|
import { __ } from '~/locale';
|
||||||
import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
|
import { DRAW_FAILURE, DEFAULT } from '../../constants';
|
||||||
import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants';
|
|
||||||
import LinksLayer from '../graph_shared/links_layer.vue';
|
import LinksLayer from '../graph_shared/links_layer.vue';
|
||||||
import JobPill from './job_pill.vue';
|
import JobPill from './job_pill.vue';
|
||||||
import StagePill from './stage_pill.vue';
|
import StagePill from './stage_pill.vue';
|
||||||
|
@ -21,10 +20,6 @@ export default {
|
||||||
errorTexts: {
|
errorTexts: {
|
||||||
[DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
|
[DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
|
||||||
[DEFAULT]: __('An unknown error occurred.'),
|
[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: {
|
props: {
|
||||||
pipelineData: {
|
pipelineData: {
|
||||||
|
@ -55,18 +50,6 @@ export default {
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
dismissible: true,
|
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:
|
default:
|
||||||
return {
|
return {
|
||||||
text: this.$options.errorTexts[DEFAULT],
|
text: this.$options.errorTexts[DEFAULT],
|
||||||
|
@ -81,18 +64,6 @@ export default {
|
||||||
hasHighlightedJob() {
|
hasHighlightedJob() {
|
||||||
return Boolean(this.highlightedJob);
|
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() {
|
pipelineStages() {
|
||||||
return this.pipelineData?.stages || [];
|
return this.pipelineData?.stages || [];
|
||||||
},
|
},
|
||||||
|
@ -101,15 +72,9 @@ export default {
|
||||||
pipelineData: {
|
pipelineData: {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
handler() {
|
handler() {
|
||||||
if (this.isPipelineDataEmpty) {
|
this.$nextTick(() => {
|
||||||
this.reportFailure(EMPTY_PIPELINE_DATA);
|
this.computeGraphDimensions();
|
||||||
} else if (this.isInvalidCiConfig) {
|
});
|
||||||
this.reportFailure(INVALID_CI_CONFIG);
|
|
||||||
} else {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.computeGraphDimensions();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -172,12 +137,7 @@ export default {
|
||||||
>
|
>
|
||||||
{{ failure.text }}
|
{{ failure.text }}
|
||||||
</gl-alert>
|
</gl-alert>
|
||||||
<div
|
<div :id="containerId" :ref="$options.CONTAINER_REF" data-testid="graph-container">
|
||||||
v-if="!hideGraph"
|
|
||||||
:id="containerId"
|
|
||||||
:ref="$options.CONTAINER_REF"
|
|
||||||
data-testid="graph-container"
|
|
||||||
>
|
|
||||||
<links-layer
|
<links-layer
|
||||||
:pipeline-data="pipelineStages"
|
:pipeline-data="pipelineStages"
|
||||||
:pipeline-id="$options.PIPELINE_ID"
|
:pipeline-id="$options.PIPELINE_ID"
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
mutation DismissPipelineNotification($featureName: String!) {
|
||||||
|
userCalloutCreate(input: { featureName: $featureName }) {
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
query getUser {
|
||||||
|
currentUser {
|
||||||
|
id
|
||||||
|
__typename
|
||||||
|
callouts {
|
||||||
|
__typename
|
||||||
|
nodes {
|
||||||
|
__typename
|
||||||
|
featureName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
|
||||||
import createDagApp from './pipeline_details_dag';
|
import createDagApp from './pipeline_details_dag';
|
||||||
import { createPipelinesDetailApp } from './pipeline_details_graph';
|
import { createPipelinesDetailApp } from './pipeline_details_graph';
|
||||||
import { createPipelineHeaderApp } from './pipeline_details_header';
|
import { createPipelineHeaderApp } from './pipeline_details_header';
|
||||||
|
import { createPipelineNotificationApp } from './pipeline_details_notification';
|
||||||
import { apolloProvider } from './pipeline_shared_client';
|
import { apolloProvider } from './pipeline_shared_client';
|
||||||
import createTestReportsStore from './stores/test_reports';
|
import createTestReportsStore from './stores/test_reports';
|
||||||
import { reportToSentry } from './utils';
|
import { reportToSentry } from './utils';
|
||||||
|
@ -18,6 +19,7 @@ const SELECTORS = {
|
||||||
PIPELINE_DETAILS: '.js-pipeline-details-vue',
|
PIPELINE_DETAILS: '.js-pipeline-details-vue',
|
||||||
PIPELINE_GRAPH: '#js-pipeline-graph-vue',
|
PIPELINE_GRAPH: '#js-pipeline-graph-vue',
|
||||||
PIPELINE_HEADER: '#js-pipeline-header-vue',
|
PIPELINE_HEADER: '#js-pipeline-header-vue',
|
||||||
|
PIPELINE_NOTIFICATION: '#js-pipeline-notification',
|
||||||
PIPELINE_TESTS: '#js-pipeline-tests-detail',
|
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.'));
|
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) {
|
if (canShowNewPipelineDetails) {
|
||||||
try {
|
try {
|
||||||
createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset);
|
createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset);
|
||||||
|
|
|
@ -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');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
|
@ -410,8 +410,11 @@ function mountCopyEmailComponent() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAssigneesWidgetShown =
|
||||||
|
(isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget;
|
||||||
|
|
||||||
export function mountSidebar(mediator) {
|
export function mountSidebar(mediator) {
|
||||||
if (isInIssuePage() || isInDesignPage()) {
|
if (isAssigneesWidgetShown) {
|
||||||
mountAssigneesComponent();
|
mountAssigneesComponent();
|
||||||
} else {
|
} else {
|
||||||
mountAssigneesComponentDeprecated(mediator);
|
mountAssigneesComponentDeprecated(mediator);
|
||||||
|
|
|
@ -26,7 +26,6 @@
|
||||||
@import './pages/projects';
|
@import './pages/projects';
|
||||||
@import './pages/prometheus';
|
@import './pages/prometheus';
|
||||||
@import './pages/registry';
|
@import './pages/registry';
|
||||||
@import './pages/runners';
|
|
||||||
@import './pages/search';
|
@import './pages/search';
|
||||||
@import './pages/service_desk';
|
@import './pages/service_desk';
|
||||||
@import './pages/settings';
|
@import './pages/settings';
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -54,6 +54,7 @@ class Projects::IssuesController < Projects::ApplicationController
|
||||||
|
|
||||||
push_to_gon_attributes(:features, real_time_feature_flag, real_time_enabled)
|
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(:confidential_notes, @project, default_enabled: :yaml)
|
||||||
|
push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml)
|
||||||
|
|
||||||
record_experiment_user(:invite_members_version_b)
|
record_experiment_user(:invite_members_version_b)
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ class ProjectFeature < ApplicationRecord
|
||||||
container_registry
|
container_registry
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance]).freeze
|
EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance, :pages]).freeze
|
||||||
|
|
||||||
set_available_features(FEATURES)
|
set_available_features(FEATURES)
|
||||||
|
|
||||||
|
|
|
@ -152,6 +152,20 @@ class Todo < ApplicationRecord
|
||||||
def pluck_user_id
|
def pluck_user_id
|
||||||
pluck(:user_id)
|
pluck(:user_id)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
def resource_parent
|
def resource_parent
|
||||||
|
|
|
@ -47,7 +47,7 @@ class TodoService
|
||||||
|
|
||||||
yield target
|
yield target
|
||||||
|
|
||||||
todo_users.each(&:update_todos_count_cache)
|
Users::UpdateTodoCountCacheService.new(todo_users).execute if todo_users.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
# When we reassign an assignable object (issuable, alert) we should:
|
# 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_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.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)
|
issue_type = attributes.delete(:issue_type)
|
||||||
track_todo_creation(user, issue_type)
|
track_todo_creation(user, issue_type)
|
||||||
|
|
||||||
todo = Todo.create(attributes.merge(user_id: user.id))
|
Todo.create(attributes.merge(user_id: user.id))
|
||||||
user.update_todos_count_cache
|
|
||||||
todo
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Users::UpdateTodoCountCacheService.new(users).execute
|
||||||
|
|
||||||
|
todos
|
||||||
end
|
end
|
||||||
|
|
||||||
def new_issuable(issuable, author)
|
def new_issuable(issuable, author)
|
||||||
|
|
|
@ -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
|
|
@ -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_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'),
|
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'),
|
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
|
- if !@group && @project.group
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
= email_default_heading("Hello, #{@resource.name}!")
|
= email_default_heading(_("Hello, %{name}!") % { name: @resource.name })
|
||||||
%p
|
%p
|
||||||
The password for your GitLab account on
|
= _('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) }
|
||||||
#{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url)}
|
|
||||||
has successfully been changed.
|
|
||||||
%p
|
%p
|
||||||
If you did not initiate this change, please contact your administrator
|
= _('If you did not initiate this change, please contact your administrator immediately.')
|
||||||
immediately.
|
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
Hello, <%= @resource.name %>!
|
<%= _('Hello, %{name}!') % { name: @resource.name } %>
|
||||||
|
|
||||||
The password for your GitLab account on <%= Gitlab.config.gitlab.url %>
|
<%= _('The password for your GitLab account on %{gitlab_url} has successfully been changed.') % { gitlab_url: Gitlab.config.gitlab.url } %>
|
||||||
has successfully been changed.
|
|
||||||
|
|
||||||
If you did not initiate this change, please contact your administrator
|
<%= _('If you did not initiate this change, please contact your administrator immediately.') %>
|
||||||
immediately.
|
|
||||||
|
|
|
@ -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-box
|
||||||
.login-body
|
.login-body
|
||||||
= form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors' }) do |f|
|
= 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
|
= render "devise/shared/error_messages", resource: resource
|
||||||
= f.hidden_field :reset_password_token
|
= f.hidden_field :reset_password_token
|
||||||
.form-group
|
.form-group
|
||||||
= f.label 'New password', for: "user_password"
|
= 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.password_field :password, class: "form-control gl-form-input top", required: true, title: _('This field is required.'), data: { qa_selector: 'password_field'}
|
||||||
.form-group
|
.form-group
|
||||||
= f.label 'Confirm new password', for: "user_password_confirmation"
|
= 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.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
|
.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
|
.clearfix.prepend-top-20
|
||||||
%p
|
%p
|
||||||
%span.light Didn't receive a confirmation email?
|
%span.light= _("Didn't receive a confirmation email?")
|
||||||
= link_to "Request a new one", new_confirmation_path(:user)
|
= link_to _("Request a new one"), new_confirmation_path(:user)
|
||||||
= render 'devise/shared/sign_in_link'
|
= render 'devise/shared/sign_in_link'
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
-# Show a message if none of the mechanisms above are enabled
|
-# 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?)
|
- if !password_authentication_enabled_for_web? && !ldap_sign_in_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?)
|
||||||
%div
|
%div
|
||||||
No authentication methods configured.
|
= _('No authentication methods configured.')
|
||||||
|
|
||||||
- if allow_signup?
|
- if allow_signup?
|
||||||
%p.gl-mt-3
|
%p.gl-mt-3
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
%div
|
%div
|
||||||
= render 'devise/shared/tab_single', tab_title: 'Two-Factor Authentication'
|
= render 'devise/shared/tab_single', tab_title: _('Two-Factor Authentication')
|
||||||
.login-box
|
.login-box
|
||||||
.login-body
|
.login-body
|
||||||
- if @user.two_factor_otp_enabled?
|
- if @user.two_factor_otp_enabled?
|
||||||
|
@ -7,10 +7,10 @@
|
||||||
- resource_params = params[resource_name].presence || params
|
- resource_params = params[resource_name].presence || params
|
||||||
= f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0)
|
= f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0)
|
||||||
%div
|
%div
|
||||||
= f.label 'Two-Factor Authentication code', name: :otp_attempt
|
= 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' }
|
= 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.
|
%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
|
.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?
|
- 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
|
= render "authentication/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
- lint_link_start = '<a href="%{url}">'.html_safe % { url: lint_link_url }
|
- 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 }
|
= 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
|
= 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) } }
|
.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) } }
|
||||||
|
|
|
@ -5,6 +5,7 @@ require 'optparse'
|
||||||
require_relative '../lib/gitlab'
|
require_relative '../lib/gitlab'
|
||||||
require_relative '../lib/gitlab/utils'
|
require_relative '../lib/gitlab/utils'
|
||||||
require_relative '../lib/gitlab/sidekiq_config/cli_methods'
|
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'
|
||||||
require_relative '../lib/gitlab/sidekiq_cluster/cli'
|
require_relative '../lib/gitlab/sidekiq_cluster/cli'
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Centralize shared state in Authoring section
|
||||||
|
merge_request: 58790
|
||||||
|
author:
|
||||||
|
type: changed
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Create prometheus service asynchronously by default when creating a project
|
||||||
|
merge_request: 59273
|
||||||
|
author:
|
||||||
|
type: changed
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Externalise strings in password_change files
|
||||||
|
merge_request: 58219
|
||||||
|
author: nuwe1
|
||||||
|
type: other
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Externalize strings in passwords/edit.html.haml
|
||||||
|
merge_request: 58233
|
||||||
|
author: nuwe1
|
||||||
|
type: other
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Externalise strings in sessions/new.html.haml
|
||||||
|
merge_request: 58274
|
||||||
|
author: nuwe1
|
||||||
|
type: other
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Externalize strings in sessions/two_factor.html.haml
|
||||||
|
merge_request: 58275
|
||||||
|
author: nuwe1
|
||||||
|
type: other
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Avoid N+1 query when updating todo count cache
|
||||||
|
merge_request: 57622
|
||||||
|
author:
|
||||||
|
type: performance
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Make NuGet SearchQueryService q parameter optional
|
||||||
|
merge_request: 57654
|
||||||
|
author: Huzaifa Iftikhar @huzaifaiftikhar
|
||||||
|
type: fixed
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Migrate Start Review button on MRs to use confirm variant
|
||||||
|
merge_request: 59523
|
||||||
|
author:
|
||||||
|
type: changed
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Added feature flag to show/hide assignees GraphQL widget
|
||||||
|
merge_request: 59620
|
||||||
|
author:
|
||||||
|
type: fixed
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Remove redundant index from epics
|
||||||
|
merge_request: 59494
|
||||||
|
author:
|
||||||
|
type: other
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Fix character escaping in Resolved By tooltips
|
||||||
|
merge_request: 59428
|
||||||
|
author:
|
||||||
|
type: fixed
|
|
@ -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
|
|
@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/326665
|
||||||
milestone: '13.11'
|
milestone: '13.11'
|
||||||
type: development
|
type: development
|
||||||
group: group::source code
|
group: group::source code
|
||||||
default_enabled: false
|
default_enabled: true
|
||||||
|
|
|
@ -8,7 +8,7 @@ product_category: collection
|
||||||
value_type: string
|
value_type: string
|
||||||
status: data_available
|
status: data_available
|
||||||
time_frame: none
|
time_frame: none
|
||||||
data_source:
|
data_source: ruby
|
||||||
distribution:
|
distribution:
|
||||||
- ce
|
- ce
|
||||||
- ee
|
- ee
|
||||||
|
@ -16,4 +16,4 @@ tier:
|
||||||
- free
|
- free
|
||||||
- premium
|
- premium
|
||||||
- ultimate
|
- ultimate
|
||||||
skip_validation: true
|
|
||||||
|
|
|
@ -2,13 +2,13 @@
|
||||||
key_path: mail.smtp_server
|
key_path: mail.smtp_server
|
||||||
description: The value of the SMTP server that is used
|
description: The value of the SMTP server that is used
|
||||||
product_section: growth
|
product_section: growth
|
||||||
product_stage:
|
product_stage: growth
|
||||||
product_group: group::acquisition
|
product_group: group::activation
|
||||||
product_category:
|
product_category: onboarding
|
||||||
value_type: number
|
value_type: number
|
||||||
status: data_available
|
status: data_available
|
||||||
time_frame: all
|
time_frame: all
|
||||||
data_source:
|
data_source: ruby
|
||||||
distribution:
|
distribution:
|
||||||
- ce
|
- ce
|
||||||
- ee
|
- ee
|
||||||
|
@ -16,4 +16,3 @@ tier:
|
||||||
- free
|
- free
|
||||||
- premium
|
- premium
|
||||||
- ultimate
|
- ultimate
|
||||||
skip_validation: true
|
|
|
@ -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
|
|
@ -0,0 +1 @@
|
||||||
|
d237690af576fb5a85d984416dcca1936a140a10a9b6c968d3ff57419568fb8f
|
|
@ -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_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_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);
|
CREATE UNIQUE INDEX index_epics_on_group_id_and_iid ON epics USING btree (group_id, iid);
|
||||||
|
|
|
@ -3695,6 +3695,7 @@ Represents an iteration object.
|
||||||
| `dueDate` | [`Time`](#time) | Timestamp of the iteration due date. |
|
| `dueDate` | [`Time`](#time) | Timestamp of the iteration due date. |
|
||||||
| `id` | [`ID!`](#id) | ID of the iteration. |
|
| `id` | [`ID!`](#id) | ID of the iteration. |
|
||||||
| `iid` | [`ID!`](#id) | Internal 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. |
|
| `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. |
|
| `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. |
|
| `scopedUrl` | [`String`](#string) | Web URL of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts. |
|
||||||
|
|
|
@ -6768,9 +6768,9 @@ Tiers: `premium`, `ultimate`
|
||||||
|
|
||||||
The value of the SMTP server that is used
|
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`
|
Status: `data_available`
|
||||||
|
|
||||||
|
|
|
@ -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:
|
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)
|
- 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.
|
- 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
|
Repository and database information that are copied over to each new project are
|
||||||
|
|
|
@ -62,7 +62,7 @@ GitLab administrators can
|
||||||
|
|
||||||
Within this section, you can configure the group where all the custom project
|
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
|
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.
|
However, private projects will be available only if the user is a member of the project.
|
||||||
|
|
||||||
|
|
|
@ -95,7 +95,7 @@ module API
|
||||||
|
|
||||||
# https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource
|
# https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource
|
||||||
params do
|
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 :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 :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
|
optional :prerelease, type: ::Grape::API::Boolean, desc: 'Include prerelease versions', default: true
|
||||||
|
|
|
@ -37,6 +37,8 @@ module Gitlab
|
||||||
end
|
end
|
||||||
|
|
||||||
def after_resolve(value:, context:, **rest)
|
def after_resolve(value:, context:, **rest)
|
||||||
|
return value if value.is_a?(GraphQL::Execution::Execute::Skip)
|
||||||
|
|
||||||
if @field.connection?
|
if @field.connection?
|
||||||
redact_connection(value, context)
|
redact_connection(value, context)
|
||||||
elsif @field.type.list?
|
elsif @field.type.list?
|
||||||
|
|
|
@ -53,11 +53,11 @@ module Gitlab
|
||||||
'You cannot specify --queue-selector and --experimental-queue-selector together'
|
'You cannot specify --queue-selector and --experimental-queue-selector together'
|
||||||
end
|
end
|
||||||
|
|
||||||
all_queues = SidekiqConfig::CliMethods.all_queues(@rails_path)
|
worker_metadatas = SidekiqConfig::CliMethods.worker_metadatas(@rails_path)
|
||||||
queue_names = SidekiqConfig::CliMethods.worker_queues(@rails_path)
|
worker_queues = SidekiqConfig::CliMethods.worker_queues(@rails_path)
|
||||||
|
|
||||||
queue_groups = argv.map do |queues|
|
queue_groups = argv.map do |queues_or_query_string|
|
||||||
next queue_names if queues == '*'
|
next worker_queues if queues_or_query_string == SidekiqConfig::WorkerMatcher::WILDCARD_MATCH
|
||||||
|
|
||||||
# When using the queue query syntax, we treat each queue group
|
# When using the queue query syntax, we treat each queue group
|
||||||
# as a worker attribute query, and resolve the queues for the
|
# 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
|
# Simplify with https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/646
|
||||||
if @queue_selector || @experimental_queue_selector
|
if @queue_selector || @experimental_queue_selector
|
||||||
SidekiqConfig::CliMethods.query_workers(queues, all_queues)
|
SidekiqConfig::CliMethods.query_queues(queues_or_query_string, worker_metadatas)
|
||||||
else
|
else
|
||||||
SidekiqConfig::CliMethods.expand_queues(queues.split(','), queue_names)
|
SidekiqConfig::CliMethods.expand_queues(queues_or_query_string.split(','), worker_queues)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if @negate_queues
|
if @negate_queues
|
||||||
queue_groups.map! { |queues| queue_names - queues }
|
queue_groups.map! { |queues| worker_queues - queues }
|
||||||
end
|
end
|
||||||
|
|
||||||
if queue_groups.all?(&:empty?)
|
if queue_groups.all?(&:empty?)
|
||||||
|
|
|
@ -12,35 +12,19 @@ module Gitlab
|
||||||
# rubocop:disable Gitlab/ModuleWithInstanceVariables
|
# rubocop:disable Gitlab/ModuleWithInstanceVariables
|
||||||
extend self
|
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
|
QUEUE_CONFIG_PATHS = begin
|
||||||
result = %w[app/workers/all_queues.yml]
|
result = %w[app/workers/all_queues.yml]
|
||||||
result << 'ee/app/workers/all_queues.yml' if Gitlab.ee?
|
result << 'ee/app/workers/all_queues.yml' if Gitlab.ee?
|
||||||
result
|
result
|
||||||
end.freeze
|
end.freeze
|
||||||
|
|
||||||
QUERY_OR_OPERATOR = '|'
|
def worker_metadatas(rails_path = Rails.root.to_s)
|
||||||
QUERY_AND_OPERATOR = '&'
|
@worker_metadatas ||= {}
|
||||||
QUERY_CONCATENATE_OPERATOR = ','
|
|
||||||
QUERY_TERM_REGEX = %r{^(\w+)(!?=)([\w:#{QUERY_CONCATENATE_OPERATOR}]+)}.freeze
|
|
||||||
|
|
||||||
QUERY_PREDICATES = {
|
@worker_metadatas[rails_path] ||= QUEUE_CONFIG_PATHS.flat_map do |path|
|
||||||
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|
|
|
||||||
full_path = File.join(rails_path, path)
|
full_path = File.join(rails_path, path)
|
||||||
|
|
||||||
File.exist?(full_path) ? YAML.load_file(full_path) : []
|
File.exist?(full_path) ? YAML.load_file(full_path) : []
|
||||||
|
@ -49,7 +33,7 @@ module Gitlab
|
||||||
# rubocop:enable Gitlab/ModuleWithInstanceVariables
|
# rubocop:enable Gitlab/ModuleWithInstanceVariables
|
||||||
|
|
||||||
def worker_queues(rails_path = Rails.root.to_s)
|
def worker_queues(rails_path = Rails.root.to_s)
|
||||||
worker_names(all_queues(rails_path))
|
worker_names(worker_metadatas(rails_path))
|
||||||
end
|
end
|
||||||
|
|
||||||
def expand_queues(queues, all_queues = self.worker_queues)
|
def expand_queues(queues, all_queues = self.worker_queues)
|
||||||
|
@ -62,13 +46,18 @@ module Gitlab
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def query_workers(query_string, queues)
|
def query_queues(query_string, worker_metadatas)
|
||||||
worker_names(queues.select(&query_string_to_lambda(query_string)))
|
matcher = SidekiqConfig::WorkerMatcher.new(query_string)
|
||||||
|
selected_metadatas = worker_metadatas.select do |worker_metadata|
|
||||||
|
matcher.match?(worker_metadata)
|
||||||
|
end
|
||||||
|
|
||||||
|
worker_names(selected_metadatas)
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear_memoization!
|
def clear_memoization!
|
||||||
if instance_variable_defined?('@worker_queues')
|
if instance_variable_defined?('@worker_metadatas')
|
||||||
remove_instance_variable('@worker_queues')
|
remove_instance_variable('@worker_metadatas')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -77,53 +66,6 @@ module Gitlab
|
||||||
def worker_names(workers)
|
def worker_names(workers)
|
||||||
workers.map { |queue| queue[:name] }
|
workers.map { |queue| queue[:name] }
|
||||||
end
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -2,7 +2,16 @@
|
||||||
|
|
||||||
Rake::Task["test"].clear
|
Rake::Task["test"].clear
|
||||||
|
|
||||||
desc "GitLab | Run all tests"
|
desc "GitLab | List rake tasks for tests"
|
||||||
task :test do
|
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
|
end
|
||||||
|
|
|
@ -8305,6 +8305,9 @@ msgstr ""
|
||||||
msgid "Confirm"
|
msgid "Confirm"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Confirm new password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Confirm your account"
|
msgid "Confirm your account"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -11220,6 +11223,9 @@ msgstr ""
|
||||||
msgid "DevopsReport|Score"
|
msgid "DevopsReport|Score"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Didn't receive a confirmation email?"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Diff content limits"
|
msgid "Diff content limits"
|
||||||
msgstr ""
|
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}."
|
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 ""
|
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}."
|
msgid "If you did not recently sign in, you should immediately %{password_link_start}change your password%{password_link_end}."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -23150,6 +23159,18 @@ msgstr ""
|
||||||
msgid "PipelineCharts|Total:"
|
msgid "PipelineCharts|Total:"
|
||||||
msgstr ""
|
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})"
|
msgid "PipelineScheduleIntervalPattern|Custom (%{linkStart}Cron syntax%{linkEnd})"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -25700,6 +25721,9 @@ msgstr ""
|
||||||
msgid "Protocol"
|
msgid "Protocol"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Provide feedback"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Provider"
|
msgid "Provider"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -26789,6 +26813,9 @@ msgstr ""
|
||||||
msgid "Request Access"
|
msgid "Request Access"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Request a new one"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Request details"
|
msgid "Request details"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -31272,6 +31299,12 @@ msgstr ""
|
||||||
msgid "The password for the Jenkins server."
|
msgid "The password for the Jenkins server."
|
||||||
msgstr ""
|
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."
|
msgid "The phase of the development lifecycle."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -31425,9 +31458,6 @@ msgstr ""
|
||||||
msgid "The value of the provided variable exceeds the %{count} character limit"
|
msgid "The value of the provided variable exceeds the %{count} character limit"
|
||||||
msgstr ""
|
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."
|
msgid "The vulnerability is known, and has not been remediated or mitigated, but is considered to be an acceptable business risk."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -31563,6 +31593,9 @@ msgstr ""
|
||||||
msgid "There was a problem communicating with your device."
|
msgid "There was a problem communicating with your device."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "There was a problem dismissing this notification."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "There was a problem fetching branches."
|
msgid "There was a problem fetching branches."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -34497,6 +34530,9 @@ msgstr ""
|
||||||
msgid "Verify SAML Configuration"
|
msgid "Verify SAML Configuration"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Verify code"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Verify configuration"
|
msgid "Verify configuration"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -34588,6 +34624,9 @@ msgstr ""
|
||||||
msgid "View job"
|
msgid "View job"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "View job dependencies in the pipeline graph!"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "View job log"
|
msgid "View job log"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -35778,6 +35817,9 @@ msgstr ""
|
||||||
msgid "You can now export your security dashboard to a CSV report."
|
msgid "You can now export your security dashboard to a CSV report."
|
||||||
msgstr ""
|
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."
|
msgid "You can now submit a merge request to get this change into the original branch."
|
||||||
msgstr ""
|
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."
|
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 ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Your CI configuration file is invalid."
|
msgid "Your CI/CD configuration syntax is invalid. View Lint tab for more details."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Your CSV export has started. It will be emailed to %{email} when complete."
|
msgid "Your CSV export has started. It will be emailed to %{email} when complete."
|
||||||
|
|
|
@ -30,116 +30,198 @@ RSpec.describe 'Issue Sidebar' do
|
||||||
let(:user2) { create(:user) }
|
let(:user2) { create(:user) }
|
||||||
let(:issue2) { create(:issue, project: project, author: user2) }
|
let(:issue2) { create(:issue, project: project, author: user2) }
|
||||||
|
|
||||||
context 'when a privileged user can invite' do
|
context 'when GraphQL assignees widget feature flag is disabled' do
|
||||||
it 'shows a link for inviting members and launches invite modal' do
|
|
||||||
project.add_maintainer(user)
|
|
||||||
visit_issue(project, issue2)
|
|
||||||
|
|
||||||
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'
|
|
||||||
|
|
||||||
expect(page).to have_content("You're inviting members to the")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when invite_members_version_b experiment is enabled' do
|
|
||||||
before do
|
before do
|
||||||
stub_experiment_for_subject(invite_members_version_b: true)
|
stub_feature_flags(issue_assignees_widget: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'shows a link for inviting members and follows through to modal' do
|
include_examples 'issuable invite members experiments' do
|
||||||
project.add_developer(user)
|
let(:issuable_path) { project_issue_path(project, issue2) }
|
||||||
visit_issue(project, issue2)
|
end
|
||||||
|
|
||||||
open_assignees_dropdown
|
context 'when user is a developer' do
|
||||||
|
before do
|
||||||
|
project.add_developer(user)
|
||||||
|
visit_issue(project, issue2)
|
||||||
|
|
||||||
page.within '.dropdown-menu-user' do
|
find('.block.assignee .edit-link').click
|
||||||
expect(page).to have_link('Invite members', href: '#')
|
wait_for_requests
|
||||||
expect(page).to have_selector('[data-track-event="click_invite_members_version_b"]')
|
|
||||||
expect(page).to have_selector('[data-track-label="edit_assignee"]')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
click_link 'Invite members'
|
it 'shows author in assignee dropdown' do
|
||||||
|
page.within '.dropdown-menu-user' do
|
||||||
expect(page).to have_content("Oops, this feature isn't ready yet")
|
expect(page).to have_content(user2.name)
|
||||||
end
|
end
|
||||||
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)
|
|
||||||
|
|
||||||
open_assignees_dropdown
|
|
||||||
|
|
||||||
page.within '.dropdown-menu-user' do
|
|
||||||
expect(page).not_to have_link('Invite members')
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when user is a developer' do
|
it 'shows author when filtering assignee dropdown' do
|
||||||
before do
|
page.within '.dropdown-menu-user' do
|
||||||
project.add_developer(user)
|
find('.dropdown-input-field').set(user2.name)
|
||||||
visit_issue(project, issue2)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'shows author in assignee dropdown' do
|
wait_for_requests
|
||||||
open_assignees_dropdown
|
|
||||||
|
|
||||||
page.within '.dropdown-menu-user' do
|
expect(page).to have_content(user2.name)
|
||||||
expect(page).to have_content(user2.name)
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
it 'shows author when filtering assignee dropdown' do
|
it 'assigns yourself' do
|
||||||
open_assignees_dropdown
|
find('.block.assignee .dropdown-menu-toggle').click
|
||||||
|
|
||||||
page.within '.dropdown-menu-user' do
|
click_button 'assign yourself'
|
||||||
find('.js-dropdown-input-field').find('input').set(user2.name)
|
|
||||||
|
|
||||||
wait_for_requests
|
wait_for_requests
|
||||||
|
|
||||||
expect(page).to have_content(user2.name)
|
find('.block.assignee .edit-link').click
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
it 'keeps your filtered term after filtering and dismissing the dropdown' do
|
||||||
|
find('.dropdown-input-field').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
|
||||||
|
find('.block.assignee .edit-link').click
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
click_on 'Unassigned'
|
||||||
|
|
||||||
|
expect(page).to have_link('Apply')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
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
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
expect(page).to have_content("You're inviting members to the")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'assigns yourself' do
|
context 'when invite_members_version_b experiment is enabled' do
|
||||||
click_button 'assign yourself'
|
before do
|
||||||
wait_for_requests
|
stub_experiment_for_subject(invite_members_version_b: true)
|
||||||
|
end
|
||||||
|
|
||||||
page.within '.assignee' do
|
it 'shows a link for inviting members and follows through to modal' do
|
||||||
expect(page).to have_content(user.name)
|
project.add_developer(user)
|
||||||
|
visit_issue(project, issue2)
|
||||||
|
|
||||||
|
open_assignees_dropdown
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
click_link 'Invite members'
|
||||||
|
|
||||||
|
expect(page).to have_content("Oops, this feature isn't ready yet")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'keeps your filtered term after filtering and dismissing the dropdown' do
|
context 'when invite_members_version_b experiment is disabled' do
|
||||||
open_assignees_dropdown
|
it 'shows author in assignee dropdown and no invite link' do
|
||||||
|
project.add_developer(user)
|
||||||
|
visit_issue(project, issue2)
|
||||||
|
|
||||||
find('.js-dropdown-input-field').find('input').set(user2.name)
|
open_assignees_dropdown
|
||||||
wait_for_requests
|
|
||||||
|
|
||||||
page.within '.dropdown-menu-user' do
|
page.within '.dropdown-menu-user' do
|
||||||
expect(page).not_to have_content 'Unassigned'
|
expect(page).not_to have_link('Invite members')
|
||||||
click_link user2.name
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is a developer' do
|
||||||
|
before do
|
||||||
|
project.add_developer(user)
|
||||||
|
visit_issue(project, issue2)
|
||||||
end
|
end
|
||||||
|
|
||||||
find('.js-right-sidebar').click
|
it 'shows author in assignee dropdown' do
|
||||||
|
open_assignees_dropdown
|
||||||
|
|
||||||
open_assignees_dropdown
|
page.within '.dropdown-menu-user' do
|
||||||
|
expect(page).to have_content(user2.name)
|
||||||
page.within('.assignee') do
|
end
|
||||||
expect(page.all('[data-testid="selected-participant"]').length).to eq(1)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
expect(find('.js-dropdown-input-field').find('input').value).to eq(user2.name)
|
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
|
||||||
|
|
||||||
|
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).to have_content(user.name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -167,79 +167,165 @@ RSpec.describe "Issues > User edits issue", :js do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'update assignee' do
|
describe 'update assignee' do
|
||||||
context 'by authorized user' do
|
context 'when GraphQL assignees widget feature flag is disabled' do
|
||||||
it 'allows user to select unassigned' do
|
before do
|
||||||
visit project_issue_path(project, issue)
|
stub_feature_flags(issue_assignees_widget: false)
|
||||||
|
end
|
||||||
|
|
||||||
page.within('.assignee') do
|
context 'by authorized user' do
|
||||||
expect(page).to have_content "#{user.name}"
|
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')
|
it 'allows user to select unassigned' do
|
||||||
wait_for_requests
|
visit project_issue_path(project, issue)
|
||||||
|
|
||||||
find('[data-testid="unassign"]').click
|
page.within('.assignee') do
|
||||||
find('[data-testid="title"]').click
|
expect(page).to have_content "#{user.name}"
|
||||||
wait_for_requests
|
|
||||||
|
|
||||||
expect(page).to have_content 'None - assign yourself'
|
click_link 'Edit'
|
||||||
|
click_link 'Unassigned'
|
||||||
|
first('.title').click
|
||||||
|
|
||||||
|
expect(page).to have_content 'None - assign yourself'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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"
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'allows user to select an assignee' do
|
context 'by unauthorized user' do
|
||||||
issue2 = create(:issue, project: project, author: user)
|
let(:guest) { create(:user) }
|
||||||
visit project_issue_path(project, issue2)
|
|
||||||
|
|
||||||
page.within('.assignee') do
|
before do
|
||||||
expect(page).to have_content "None"
|
project.add_guest(guest)
|
||||||
click_button('Edit')
|
|
||||||
wait_for_requests
|
|
||||||
end
|
end
|
||||||
|
|
||||||
page.within '.dropdown-menu-user' do
|
it 'shows assignee text' do
|
||||||
click_link user.name
|
sign_out(:user)
|
||||||
end
|
sign_in(guest)
|
||||||
|
|
||||||
page.within('.assignee') do
|
visit project_issue_path(project, issue)
|
||||||
find('[data-testid="title"]').click
|
expect(page).to have_content issue.assignees.first.name
|
||||||
wait_for_requests
|
|
||||||
|
|
||||||
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_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
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'by unauthorized user' do
|
context 'when GraphQL assignees widget feature flag is enabled' do
|
||||||
let(:guest) { create(:user) }
|
context 'by authorized user' do
|
||||||
|
it 'allows user to select unassigned' do
|
||||||
|
visit project_issue_path(project, issue)
|
||||||
|
|
||||||
before do
|
page.within('.assignee') do
|
||||||
project.add_guest(guest)
|
expect(page).to have_content "#{user.name}"
|
||||||
|
|
||||||
|
click_button('Edit')
|
||||||
|
wait_for_requests
|
||||||
|
|
||||||
|
find('[data-testid="unassign"]').click
|
||||||
|
find('[data-testid="title"]').click
|
||||||
|
wait_for_requests
|
||||||
|
|
||||||
|
expect(page).to have_content 'None - assign yourself'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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_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
|
end
|
||||||
|
|
||||||
it 'shows assignee text' do
|
context 'by unauthorized user' do
|
||||||
sign_out(:user)
|
let(:guest) { create(:user) }
|
||||||
sign_in(guest)
|
|
||||||
|
|
||||||
visit project_issue_path(project, issue)
|
before do
|
||||||
expect(page).to have_content issue.assignees.first.name
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -25,7 +25,8 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do
|
||||||
fill_in('confirm_name_input', with: forked_project.name)
|
fill_in('confirm_name_input', with: forked_project.name)
|
||||||
click_button('Confirm')
|
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
|
expect(forked_project.reload.forked?).to be_falsy
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -174,26 +174,6 @@ RSpec.describe 'Project' do
|
||||||
end
|
end
|
||||||
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
|
describe 'showing information about source of a project fork' do
|
||||||
let(:user) { create(:user) }
|
let(:user) { create(:user) }
|
||||||
let(:base_project) { create(:project, :public, :repository) }
|
let(:base_project) { create(:project, :public, :repository) }
|
||||||
|
|
|
@ -151,6 +151,22 @@ describe('noteActions', () => {
|
||||||
const assignUserButton = wrapper.find('[data-testid="assign-user"]');
|
const assignUserButton = wrapper.find('[data-testid="assign-user"]');
|
||||||
expect(assignUserButton.exists()).toBe(false);
|
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}`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import { GlAlert, GlIcon } from '@gitlab/ui';
|
import { GlIcon } from '@gitlab/ui';
|
||||||
import { shallowMount } from '@vue/test-utils';
|
import { shallowMount } from '@vue/test-utils';
|
||||||
|
|
||||||
import { EDITOR_READY_EVENT } from '~/editor/constants';
|
import { EDITOR_READY_EVENT } from '~/editor/constants';
|
||||||
import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue';
|
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';
|
import { mockLintResponse, mockCiConfigPath } from '../../mock_data';
|
||||||
|
|
||||||
describe('Text editor component', () => {
|
describe('Text editor component', () => {
|
||||||
|
@ -32,7 +31,6 @@ describe('Text editor component', () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const findAlert = () => wrapper.findComponent(GlAlert);
|
|
||||||
const findIcon = () => wrapper.findComponent(GlIcon);
|
const findIcon = () => wrapper.findComponent(GlIcon);
|
||||||
const findEditor = () => wrapper.findComponent(MockEditorLite);
|
const findEditor = () => wrapper.findComponent(MockEditorLite);
|
||||||
|
|
||||||
|
@ -40,24 +38,9 @@ describe('Text editor component', () => {
|
||||||
wrapper.destroy();
|
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', () => {
|
describe('when status is valid', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createComponent({ props: { isValid: true } });
|
createComponent();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows an information message that the section is not editable', () => {
|
it('shows an information message that the section is not editable', () => {
|
||||||
|
|
|
@ -4,9 +4,12 @@ import { nextTick } from 'vue';
|
||||||
import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue';
|
import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue';
|
||||||
import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue';
|
import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue';
|
||||||
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
|
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
|
||||||
|
import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue';
|
||||||
import {
|
import {
|
||||||
|
EDITOR_APP_STATUS_EMPTY,
|
||||||
EDITOR_APP_STATUS_ERROR,
|
EDITOR_APP_STATUS_ERROR,
|
||||||
EDITOR_APP_STATUS_LOADING,
|
EDITOR_APP_STATUS_LOADING,
|
||||||
|
EDITOR_APP_STATUS_INVALID,
|
||||||
EDITOR_APP_STATUS_VALID,
|
EDITOR_APP_STATUS_VALID,
|
||||||
} from '~/pipeline_editor/constants';
|
} from '~/pipeline_editor/constants';
|
||||||
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
|
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
|
||||||
|
@ -44,6 +47,7 @@ describe('Pipeline editor tabs component', () => {
|
||||||
provide: { ...mockProvide, ...provide },
|
provide: { ...mockProvide, ...provide },
|
||||||
stubs: {
|
stubs: {
|
||||||
TextEditor: MockTextEditor,
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
import { GlTabs } from '@gitlab/ui';
|
import { GlAlert, GlTabs } from '@gitlab/ui';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { nextTick } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
|
|
||||||
import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue';
|
import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue';
|
||||||
|
|
||||||
const mockContent1 = 'MOCK CONTENT 1';
|
const mockContent1 = 'MOCK CONTENT 1';
|
||||||
const mockContent2 = 'MOCK CONTENT 2';
|
const mockContent2 = 'MOCK CONTENT 2';
|
||||||
|
|
||||||
|
const MockEditorLite = {
|
||||||
|
template: '<div>EDITOR</div>',
|
||||||
|
};
|
||||||
|
|
||||||
describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
|
describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
let mockChildMounted = jest.fn();
|
let mockChildMounted = jest.fn();
|
||||||
|
@ -37,22 +40,34 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const createWrapper = () => {
|
const createMockedWrapper = () => {
|
||||||
wrapper = mount(MockTabbedContent);
|
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(() => {
|
beforeEach(() => {
|
||||||
mockChildMounted = jest.fn();
|
mockChildMounted = jest.fn();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tabs are mounted lazily', async () => {
|
it('tabs are mounted lazily', async () => {
|
||||||
createWrapper();
|
createMockedWrapper();
|
||||||
|
|
||||||
expect(mockChildMounted).toHaveBeenCalledTimes(0);
|
expect(mockChildMounted).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('first tab is only mounted after nextTick', async () => {
|
it('first tab is only mounted after nextTick', async () => {
|
||||||
createWrapper();
|
createMockedWrapper();
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
|
@ -60,6 +75,36 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
|
||||||
expect(mockChildMounted).toHaveBeenCalledWith(mockContent1);
|
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', () => {
|
describe('user interaction', () => {
|
||||||
const clickTab = async (testid) => {
|
const clickTab = async (testid) => {
|
||||||
wrapper.find(`[data-testid="${testid}"]`).trigger('click');
|
wrapper.find(`[data-testid="${testid}"]`).trigger('click');
|
||||||
|
@ -67,7 +112,7 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createWrapper();
|
createMockedWrapper();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mounts a tab once after selecting it', async () => {
|
it('mounts a tab once after selecting it', async () => {
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,12 +1,12 @@
|
||||||
import { GlAlert } from '@gitlab/ui';
|
import { GlAlert } from '@gitlab/ui';
|
||||||
import { shallowMount } from '@vue/test-utils';
|
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 LinksInner from '~/pipelines/components/graph_shared/links_inner.vue';
|
||||||
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
|
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
|
||||||
import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue';
|
import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue';
|
||||||
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
|
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
|
||||||
import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.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';
|
import { invalidNeedsData, pipelineData, singleStageData } from './mock_data';
|
||||||
|
|
||||||
describe('pipeline graph component', () => {
|
describe('pipeline graph component', () => {
|
||||||
|
@ -42,31 +42,6 @@ describe('pipeline graph component', () => {
|
||||||
wrapper.destroy();
|
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', () => {
|
describe('with `VALID` status', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapper = createComponent({
|
wrapper = createComponent({
|
||||||
|
|
|
@ -376,6 +376,26 @@ RSpec.describe 'DeclarativePolicy authorization in GraphQL ' do
|
||||||
end
|
end
|
||||||
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
|
private
|
||||||
|
|
||||||
def permit(*permissions)
|
def permit(*permissions)
|
||||||
|
|
|
@ -112,7 +112,7 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do
|
||||||
subject(:projects) { resolve_projects(args) }
|
subject(:projects) { resolve_projects(args) }
|
||||||
|
|
||||||
let(:include_subgroups) { false }
|
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
|
context 'when ids is provided' do
|
||||||
let(:ids) { [project_3.to_global_id.to_s] }
|
let(:ids) { [project_3.to_global_id.to_s] }
|
||||||
|
|
|
@ -3,44 +3,9 @@
|
||||||
require 'knapsack'
|
require 'knapsack'
|
||||||
|
|
||||||
module KnapsackEnv
|
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!
|
def self.configure!
|
||||||
return unless ENV['CI'] && ENV['KNAPSACK_GENERATE_REPORT'] && !ENV['NO_KNAPSACK']
|
return unless ENV['CI'] && ENV['KNAPSACK_GENERATE_REPORT'] && !ENV['NO_KNAPSACK']
|
||||||
|
|
||||||
RSpecContextAdapter.bind
|
Knapsack::Adapters::RSpecAdapter.bind
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -214,7 +214,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do
|
||||||
expect(Gitlab::SidekiqCluster).not_to receive(:start)
|
expect(Gitlab::SidekiqCluster).not_to receive(:start)
|
||||||
|
|
||||||
expect { cli.run(%W(#{flag} unknown_field=chatops)) }
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require 'fast_spec_helper'
|
require 'fast_spec_helper'
|
||||||
require 'rspec-parameterized'
|
|
||||||
|
|
||||||
RSpec.describe Gitlab::SidekiqConfig::CliMethods do
|
RSpec.describe Gitlab::SidekiqConfig::CliMethods do
|
||||||
let(:dummy_root) { '/tmp/' }
|
let(:dummy_root) { '/tmp/' }
|
||||||
|
@ -122,10 +121,8 @@ RSpec.describe Gitlab::SidekiqConfig::CliMethods do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '.query_workers' do
|
describe '.query_queues' do
|
||||||
using RSpec::Parameterized::TableSyntax
|
let(:worker_metadatas) do
|
||||||
|
|
||||||
let(:queues) do
|
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
name: 'a',
|
name: 'a',
|
||||||
|
@ -162,79 +159,16 @@ RSpec.describe Gitlab::SidekiqConfig::CliMethods do
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with valid input' do
|
let(:worker_matcher) { double(:WorkerMatcher) }
|
||||||
where(:query, :selected_queues) do
|
let(:query) { 'feature_category=category_a,category_c' }
|
||||||
# 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
|
before do
|
||||||
'has_external_dependencies=true' | %w(b)
|
allow(::Gitlab::SidekiqConfig::WorkerMatcher).to receive(:new).with(query).and_return(worker_matcher)
|
||||||
'has_external_dependencies=false' | %w(a a:2 c)
|
allow(worker_matcher).to receive(:match?).and_return(true, true, false, true)
|
||||||
'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
|
|
||||||
|
|
||||||
with_them do
|
|
||||||
it do
|
|
||||||
expect(described_class.query_workers(query, queues))
|
|
||||||
.to match_array(selected_queues)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with invalid input' do
|
it 'returns the queue names of matched workers' do
|
||||||
where(:query, :error) do
|
expect(described_class.query_queues(query, worker_metadatas)).to match(%w(a a:2 c))
|
||||||
'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
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
|
@ -376,6 +376,22 @@ RSpec.describe Todo do
|
||||||
end
|
end
|
||||||
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
|
describe '.any_for_target?' do
|
||||||
it 'returns true if there are todos for a given target' do
|
it 'returns true if there are todos for a given target' do
|
||||||
todo = create(:todo)
|
todo = create(:todo)
|
||||||
|
|
|
@ -69,7 +69,7 @@ RSpec.describe API::NugetGroupPackages do
|
||||||
let(:take) { 26 }
|
let(:take) { 26 }
|
||||||
let(:skip) { 0 }
|
let(:skip) { 0 }
|
||||||
let(:include_prereleases) { true }
|
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: {}}
|
subject { get api(url), headers: {}}
|
||||||
|
|
||||||
|
@ -145,7 +145,7 @@ RSpec.describe API::NugetGroupPackages do
|
||||||
let(:take) { 26 }
|
let(:take) { 26 }
|
||||||
let(:skip) { 0 }
|
let(:skip) { 0 }
|
||||||
let(:include_prereleases) { false }
|
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}" }
|
let(:url) { "/groups/#{group.id}/-/packages/nuget/query?#{query_parameters.to_query}" }
|
||||||
|
|
||||||
it_behaves_like 'returning response status', :forbidden
|
it_behaves_like 'returning response status', :forbidden
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
require 'spec_helper'
|
require 'spec_helper'
|
||||||
|
|
||||||
RSpec.describe TodoService do
|
RSpec.describe TodoService do
|
||||||
|
include AfterNextHelpers
|
||||||
|
|
||||||
let_it_be(:project) { create(:project, :repository) }
|
let_it_be(:project) { create(:project, :repository) }
|
||||||
let_it_be(:author) { create(:user) }
|
let_it_be(:author) { create(:user) }
|
||||||
let_it_be(:assignee) { create(:user) }
|
let_it_be(:assignee) { create(:user) }
|
||||||
|
@ -343,19 +345,19 @@ RSpec.describe TodoService do
|
||||||
|
|
||||||
describe '#destroy_target' do
|
describe '#destroy_target' do
|
||||||
it 'refreshes the todos count cache for users with todos on the 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
|
end
|
||||||
|
|
||||||
it 'does not refresh the todos count cache for users with only done todos on the target' do
|
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)
|
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
|
end
|
||||||
|
|
||||||
it 'yields the target to the caller' do
|
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
|
it 'updates cached counts when a todo is created' do
|
||||||
issue = create(:issue, project: project, assignees: [john_doe], author: author)
|
issue = create(:issue, project: project, assignees: [john_doe], author: author)
|
||||||
|
|
||||||
expect(john_doe.todos_pending_count).to eq(0)
|
expect_next(Users::UpdateTodoCountCacheService, [john_doe]).to receive(:execute)
|
||||||
expect(john_doe).to receive(:update_todos_count_cache).and_call_original
|
|
||||||
|
|
||||||
service.new_issue(issue, author)
|
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
|
end
|
||||||
|
|
||||||
shared_examples 'updating todos state' do |state, new_state, new_resolved_by = nil|
|
shared_examples 'updating todos state' do |state, new_state, new_resolved_by = nil|
|
||||||
|
|
|
@ -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
|
|
@ -225,7 +225,7 @@ RSpec.shared_examples 'handling nuget search requests' do |anonymous_requests_ex
|
||||||
let(:take) { 26 }
|
let(:take) { 26 }
|
||||||
let(:skip) { 0 }
|
let(:skip) { 0 }
|
||||||
let(:include_prereleases) { true }
|
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) }
|
subject { get api(url) }
|
||||||
|
|
||||||
|
|
|
@ -192,7 +192,10 @@ func handleExifUpload(ctx context.Context, r io.Reader, filename string, imageTy
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpfile.Seek(0, io.SeekStart)
|
if _, err := tmpfile.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
isValidType := false
|
isValidType := false
|
||||||
switch imageType {
|
switch imageType {
|
||||||
case exif.TypeJPEG:
|
case exif.TypeJPEG:
|
||||||
|
@ -201,7 +204,10 @@ func handleExifUpload(ctx context.Context, r io.Reader, filename string, imageTy
|
||||||
isValidType = isTIFF(tmpfile)
|
isValidType = isTIFF(tmpfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpfile.Seek(0, io.SeekStart)
|
if _, err := tmpfile.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if !isValidType {
|
if !isValidType {
|
||||||
log.WithContextFields(ctx, log.Fields{
|
log.WithContextFields(ctx, log.Fields{
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
|
|
Loading…
Reference in New Issue