Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
3257ae3af0
commit
c6af94ea4e
86 changed files with 1361 additions and 675 deletions
|
@ -2911,33 +2911,6 @@ Gitlab/NamespacedClass:
|
|||
- 'spec/tasks/gitlab/task_helpers_spec.rb'
|
||||
- 'spec/uploaders/object_storage_spec.rb'
|
||||
|
||||
# WIP: https://gitlab.com/gitlab-org/gitlab/-/issues/322739
|
||||
Style/HashTransformation:
|
||||
Exclude:
|
||||
- 'ee/app/models/ee/ci/build.rb'
|
||||
- 'ee/app/models/productivity_analytics.rb'
|
||||
- 'ee/app/models/sca/license_compliance.rb'
|
||||
- 'ee/app/services/security/store_report_service.rb'
|
||||
- 'ee/lib/ee/gitlab/auth/ldap/sync/group.rb'
|
||||
- 'ee/lib/ee/gitlab/usage_data.rb'
|
||||
- 'ee/lib/gitlab/custom_file_templates.rb'
|
||||
- 'ee/spec/elastic_integration/global_search_spec.rb'
|
||||
- 'ee/spec/lib/ee/gitlab/application_context_spec.rb'
|
||||
- 'spec/lib/atlassian/jira_connect/serializers/pull_request_entity_spec.rb'
|
||||
- 'spec/lib/gitlab/ci/status/composite_spec.rb'
|
||||
- 'spec/lib/gitlab/conflict/file_spec.rb'
|
||||
- 'spec/lib/gitlab/import_export/project/tree_restorer_spec.rb'
|
||||
- 'spec/models/concerns/featurable_spec.rb'
|
||||
- 'spec/models/event_spec.rb'
|
||||
- 'spec/models/packages/dependency_spec.rb'
|
||||
- 'spec/requests/api/graphql/project/alert_management/alert/assignees_spec.rb'
|
||||
- 'spec/requests/api/graphql/project/alert_management/alert/notes_spec.rb'
|
||||
- 'spec/requests/api/graphql/project/alert_management/alert/todos_spec.rb'
|
||||
- 'spec/requests/api/projects_spec.rb'
|
||||
- 'spec/support/helpers/graphql_helpers.rb'
|
||||
- 'spec/support/import_export/project_tree_expectations.rb'
|
||||
- 'spec/support/shared_contexts/services/projects/container_repository/delete_tags_service_shared_context.rb'
|
||||
|
||||
Style/ClassEqualityComparison:
|
||||
Exclude:
|
||||
- spec/lib/peek/views/active_record_spec.rb
|
||||
|
|
3
Gemfile
3
Gemfile
|
@ -342,7 +342,6 @@ group :metrics do
|
|||
end
|
||||
|
||||
group :development do
|
||||
gem 'brakeman', '~> 4.10.0', require: false
|
||||
gem 'lefthook', '~> 0.7.0', require: false
|
||||
|
||||
gem 'letter_opener_web', '~> 1.4.0'
|
||||
|
@ -383,7 +382,7 @@ group :development, :test do
|
|||
|
||||
gem 'benchmark-ips', '~> 2.3.0', require: false
|
||||
|
||||
gem 'knapsack', '~> 1.17'
|
||||
gem 'knapsack', '~> 1.21.1'
|
||||
gem 'crystalball', '~> 0.7.0', require: false
|
||||
|
||||
gem 'simple_po_parser', '~> 1.1.2', require: false
|
||||
|
|
|
@ -151,7 +151,6 @@ GEM
|
|||
bootstrap_form (4.2.0)
|
||||
actionpack (>= 5.0)
|
||||
activemodel (>= 5.0)
|
||||
brakeman (4.10.1)
|
||||
browser (4.2.0)
|
||||
builder (3.2.4)
|
||||
bullet (6.1.3)
|
||||
|
@ -672,7 +671,7 @@ GEM
|
|||
kaminari-core (= 1.2.1)
|
||||
kaminari-core (1.2.1)
|
||||
kgio (2.11.3)
|
||||
knapsack (1.17.0)
|
||||
knapsack (1.21.1)
|
||||
rake
|
||||
kramdown (2.3.1)
|
||||
rexml
|
||||
|
@ -1369,7 +1368,6 @@ DEPENDENCIES
|
|||
better_errors (~> 2.9.0)
|
||||
bootsnap (~> 1.4.6)
|
||||
bootstrap_form (~> 4.2.0)
|
||||
brakeman (~> 4.10.0)
|
||||
browser (~> 4.2)
|
||||
bullet (~> 6.1.3)
|
||||
bundler-audit (~> 0.7.0.1)
|
||||
|
@ -1476,7 +1474,7 @@ DEPENDENCIES
|
|||
json_schemer (~> 0.2.12)
|
||||
jwt (~> 2.1.0)
|
||||
kaminari (~> 1.0)
|
||||
knapsack (~> 1.17)
|
||||
knapsack (~> 1.21.1)
|
||||
kramdown (~> 2.3.1)
|
||||
kubeclient (~> 4.9.1)
|
||||
lefthook (~> 0.7.0)
|
||||
|
|
2
Rakefile
2
Rakefile
|
@ -4,6 +4,8 @@
|
|||
# Add your own tasks in files placed in lib/tasks ending in .rake,
|
||||
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
|
||||
|
||||
Rake::TaskManager.record_task_metadata = true
|
||||
|
||||
require File.expand_path('config/application', __dir__)
|
||||
|
||||
relative_url_conf = File.expand_path('config/initializers/relative_url', __dir__)
|
||||
|
|
|
@ -50,12 +50,18 @@ export default {
|
|||
return this.resolveDiscussion ? 'is-resolving-discussion' : 'is-unresolving-discussion';
|
||||
},
|
||||
resolveButtonTitle() {
|
||||
const escapeParameters = false;
|
||||
|
||||
if (this.isDraft || this.discussionId) return this.resolvedStatusMessage;
|
||||
|
||||
let title = __('Resolve thread');
|
||||
|
||||
if (this.resolvedBy) {
|
||||
title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name });
|
||||
title = sprintf(
|
||||
__('Resolved by %{name}'),
|
||||
{ name: this.resolvedBy.name },
|
||||
escapeParameters,
|
||||
);
|
||||
}
|
||||
|
||||
return title;
|
||||
|
|
|
@ -5,10 +5,13 @@ import InviteMemberModal from './components/invite_member_modal.vue';
|
|||
|
||||
Vue.use(GlToast);
|
||||
|
||||
const isAssigneesWidgetShown =
|
||||
(isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget;
|
||||
|
||||
export default function initInviteMembersModal() {
|
||||
const el = document.querySelector('.js-invite-member-modal');
|
||||
|
||||
if (!el || isInDesignPage() || isInIssuePage()) {
|
||||
if (!el || isAssigneesWidgetShown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
@ -390,7 +390,7 @@ export default {
|
|||
<gl-button
|
||||
:disabled="isDisabled"
|
||||
category="primary"
|
||||
variant="success"
|
||||
variant="confirm"
|
||||
class="gl-mr-3"
|
||||
data-qa-selector="start_review_button"
|
||||
@click="handleAddToReview"
|
||||
|
|
|
@ -1,29 +1,19 @@
|
|||
<script>
|
||||
import { GlAlert, GlIcon } from '@gitlab/ui';
|
||||
import { GlIcon } from '@gitlab/ui';
|
||||
import { uniqueId } from 'lodash';
|
||||
import { __, s__ } from '~/locale';
|
||||
import { DEFAULT, INVALID_CI_CONFIG } from '~/pipelines/constants';
|
||||
import { s__ } from '~/locale';
|
||||
import EditorLite from '~/vue_shared/components/editor_lite.vue';
|
||||
|
||||
export default {
|
||||
i18n: {
|
||||
viewOnlyMessage: s__('Pipelines|Merged YAML is view only'),
|
||||
},
|
||||
errorTexts: {
|
||||
[INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'),
|
||||
[DEFAULT]: __('An unknown error occurred.'),
|
||||
},
|
||||
components: {
|
||||
EditorLite,
|
||||
GlAlert,
|
||||
GlIcon,
|
||||
},
|
||||
inject: ['ciConfigPath'],
|
||||
props: {
|
||||
isValid: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
ciConfigData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
|
@ -35,66 +25,30 @@ export default {
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
failure() {
|
||||
switch (this.failureType) {
|
||||
case INVALID_CI_CONFIG:
|
||||
return this.$options.errorTexts[INVALID_CI_CONFIG];
|
||||
default:
|
||||
return this.$options.errorTexts[DEFAULT];
|
||||
}
|
||||
},
|
||||
fileGlobalId() {
|
||||
return `${this.ciConfigPath}-${uniqueId()}`;
|
||||
},
|
||||
hasError() {
|
||||
return this.failureType;
|
||||
},
|
||||
mergedYaml() {
|
||||
return this.ciConfigData.mergedYaml;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
ciConfigData: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
if (!this.isValid) {
|
||||
this.reportFailure(INVALID_CI_CONFIG);
|
||||
} else if (this.hasError) {
|
||||
this.resetFailure();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
reportFailure(errorType) {
|
||||
this.failureType = errorType;
|
||||
},
|
||||
resetFailure() {
|
||||
this.failureType = null;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<gl-alert v-if="hasError" variant="danger" :dismissible="false">
|
||||
{{ failure }}
|
||||
</gl-alert>
|
||||
<div v-else>
|
||||
<div class="gl-display-flex gl-align-items-center">
|
||||
<gl-icon :size="16" name="lock" class="gl-text-gray-500 gl-mr-3" />
|
||||
{{ $options.i18n.viewOnlyMessage }}
|
||||
</div>
|
||||
<div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1">
|
||||
<editor-lite
|
||||
ref="editor"
|
||||
:value="mergedYaml"
|
||||
:file-name="ciConfigPath"
|
||||
:file-global-id="fileGlobalId"
|
||||
:editor-options="{ readOnly: true }"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</div>
|
||||
<div class="gl-display-flex gl-align-items-center">
|
||||
<gl-icon :size="16" name="lock" class="gl-text-gray-500 gl-mr-3" />
|
||||
{{ $options.i18n.viewOnlyMessage }}
|
||||
</div>
|
||||
<div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1">
|
||||
<editor-lite
|
||||
ref="editor"
|
||||
:value="mergedYaml"
|
||||
:file-name="ciConfigPath"
|
||||
:file-global-id="fileGlobalId"
|
||||
:editor-options="{ readOnly: true }"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
<script>
|
||||
import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui';
|
||||
import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui';
|
||||
import { s__ } from '~/locale';
|
||||
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
|
||||
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import {
|
||||
CREATE_TAB,
|
||||
EDITOR_APP_STATUS_EMPTY,
|
||||
EDITOR_APP_STATUS_ERROR,
|
||||
EDITOR_APP_STATUS_INVALID,
|
||||
EDITOR_APP_STATUS_LOADING,
|
||||
EDITOR_APP_STATUS_VALID,
|
||||
LINT_TAB,
|
||||
|
@ -24,6 +26,17 @@ export default {
|
|||
tabGraph: s__('Pipelines|Visualize'),
|
||||
tabLint: s__('Pipelines|Lint'),
|
||||
tabMergedYaml: s__('Pipelines|View merged YAML'),
|
||||
empty: {
|
||||
visualization: s__(
|
||||
'PipelineEditor|The pipeline visualization is displayed when the CI/CD configuration file has valid syntax.',
|
||||
),
|
||||
lint: s__(
|
||||
'PipelineEditor|The CI/CD configuration is continuously validated. Errors and warnings are displayed when the CI/CD configuration file is not empty.',
|
||||
),
|
||||
merge: s__(
|
||||
'PipelineEditor|The merged YAML view is displayed when the CI/CD configuration file has valid syntax.',
|
||||
),
|
||||
},
|
||||
},
|
||||
errorTexts: {
|
||||
loadMergedYaml: s__('Pipelines|Could not load merged YAML content'),
|
||||
|
@ -40,7 +53,6 @@ export default {
|
|||
EditorTab,
|
||||
GlAlert,
|
||||
GlLoadingIcon,
|
||||
GlTab,
|
||||
GlTabs,
|
||||
PipelineGraph,
|
||||
TextEditor,
|
||||
|
@ -66,6 +78,12 @@ export default {
|
|||
// Not an invalid config and with `mergedYaml` data missing
|
||||
return this.appStatus === EDITOR_APP_STATUS_ERROR;
|
||||
},
|
||||
isEmpty() {
|
||||
return this.appStatus === EDITOR_APP_STATUS_EMPTY;
|
||||
},
|
||||
isInvalid() {
|
||||
return this.appStatus === EDITOR_APP_STATUS_INVALID;
|
||||
},
|
||||
isValid() {
|
||||
return this.appStatus === EDITOR_APP_STATUS_VALID;
|
||||
},
|
||||
|
@ -91,9 +109,12 @@ export default {
|
|||
>
|
||||
<text-editor :value="ciFileContent" v-on="$listeners" />
|
||||
</editor-tab>
|
||||
<gl-tab
|
||||
<editor-tab
|
||||
v-if="glFeatures.ciConfigVisualizationTab"
|
||||
class="gl-mb-3"
|
||||
:empty-message="$options.i18n.empty.visualization"
|
||||
:is-empty="isEmpty"
|
||||
:is-invalid="isInvalid"
|
||||
:title="$options.i18n.tabGraph"
|
||||
lazy
|
||||
data-testid="visualization-tab"
|
||||
|
@ -101,9 +122,11 @@ export default {
|
|||
>
|
||||
<gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
|
||||
<pipeline-graph v-else :pipeline-data="ciConfigData" />
|
||||
</gl-tab>
|
||||
</editor-tab>
|
||||
<editor-tab
|
||||
class="gl-mb-3"
|
||||
:empty-message="$options.i18n.empty.lint"
|
||||
:is-empty="isEmpty"
|
||||
:title="$options.i18n.tabLint"
|
||||
data-testid="lint-tab"
|
||||
@click="setCurrentTab($options.tabConstants.LINT_TAB)"
|
||||
|
@ -111,9 +134,13 @@ export default {
|
|||
<gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
|
||||
<ci-lint v-else :is-valid="isValid" :ci-config="ciConfigData" />
|
||||
</editor-tab>
|
||||
<gl-tab
|
||||
<editor-tab
|
||||
v-if="glFeatures.ciConfigMergedTab"
|
||||
class="gl-mb-3"
|
||||
:empty-message="$options.i18n.empty.merge"
|
||||
:keep-component-mounted="false"
|
||||
:is-empty="isEmpty"
|
||||
:is-invalid="isInvalid"
|
||||
:title="$options.i18n.tabMergedYaml"
|
||||
lazy
|
||||
data-testid="merged-tab"
|
||||
|
@ -123,12 +150,7 @@ export default {
|
|||
<gl-alert v-else-if="hasAppError" variant="danger" :dismissible="false">
|
||||
{{ $options.errorTexts.loadMergedYaml }}
|
||||
</gl-alert>
|
||||
<ci-config-merged-preview
|
||||
v-else
|
||||
:is-valid="isValid"
|
||||
:ci-config-data="ciConfigData"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</gl-tab>
|
||||
<ci-config-merged-preview v-else :ci-config-data="ciConfigData" v-on="$listeners" />
|
||||
</editor-tab>
|
||||
</gl-tabs>
|
||||
</template>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { GlTab } from '@gitlab/ui';
|
||||
|
||||
import { GlAlert, GlTab } from '@gitlab/ui';
|
||||
import { __, s__ } from '~/locale';
|
||||
/**
|
||||
* Wrapper of <gl-tab> to optionally lazily render this tab's content
|
||||
* when its shown **without dismounting after its hidden**.
|
||||
|
@ -10,10 +10,10 @@ import { GlTab } from '@gitlab/ui';
|
|||
* API is the same as <gl-tab>, for example:
|
||||
*
|
||||
* <gl-tabs>
|
||||
* <editor-tab title="Tab 1" :lazy="true">
|
||||
* <editor-tab title="Tab 1" lazy>
|
||||
* lazily mounted content (gets mounted if this is first tab)
|
||||
* </editor-tab>
|
||||
* <editor-tab title="Tab 2" :lazy="true">
|
||||
* <editor-tab title="Tab 2" lazy>
|
||||
* lazily mounted content
|
||||
* </editor-tab>
|
||||
* <editor-tab title="Tab 3">
|
||||
|
@ -25,10 +25,26 @@ import { GlTab } from '@gitlab/ui';
|
|||
* so it's contents are not dismounted.
|
||||
*
|
||||
* lazy is "false" by default, as in <gl-tab>.
|
||||
*
|
||||
* It is also possible to pass the `isEmpty` and or `isInvalid` to let
|
||||
* the tab component handle that state on its own. For example:
|
||||
*
|
||||
* * <gl-tabs>
|
||||
* <editor-tab-with-status title="Tab 1" :is-empty="isEmpty" :is-invalid="isInvalid">
|
||||
* ...
|
||||
* </editor-tab-with-status>
|
||||
* Will be the same as normal, except it will only render the slot component
|
||||
* if the status is not empty and not invalid. In any of these 2 cases, it will render
|
||||
* a generic component and avoid mounting whatever it received in the slot.
|
||||
* </gl-tabs>
|
||||
*/
|
||||
|
||||
export default {
|
||||
i18n: {
|
||||
invalid: __('Your CI/CD configuration syntax is invalid. View Lint tab for more details.'),
|
||||
},
|
||||
components: {
|
||||
GlAlert,
|
||||
GlTab,
|
||||
// Use a small renderless component to know when the tab content mounts because:
|
||||
// - gl-tab always gets mounted, even if lazy is `true`. See:
|
||||
|
@ -40,29 +56,63 @@ export default {
|
|||
},
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
emptyMessage: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: s__(
|
||||
'PipelineEditor|This tab will be usable when the CI/CD configuration file is populated with valid syntax.',
|
||||
),
|
||||
},
|
||||
isEmpty: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
isInvalid: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
lazy: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
keepComponentMounted: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLazy: this.lazy,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
slots() {
|
||||
return Object.keys(this.$slots);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onContentMounted() {
|
||||
// When a child is first mounted make the entire tab
|
||||
// permanently mounted by setting 'lazy' to false.
|
||||
this.isLazy = false;
|
||||
// permanently mounted by setting 'lazy' to false unless
|
||||
// explicitly opted out.
|
||||
if (this.keepComponentMounted) {
|
||||
this.isLazy = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<gl-tab :lazy="isLazy" v-bind="$attrs" v-on="$listeners">
|
||||
<slot v-for="slot in Object.keys($slots)" :name="slot"></slot>
|
||||
<mount-spy @hook:mounted="onContentMounted" />
|
||||
<gl-alert v-if="isEmpty" variant="tip">{{ emptyMessage }}</gl-alert>
|
||||
<gl-alert v-else-if="isInvalid" variant="danger">{{ $options.i18n.invalid }}</gl-alert>
|
||||
<template v-else>
|
||||
<slot v-for="slot in slots" :name="slot"></slot>
|
||||
<mount-spy @hook:mounted="onContentMounted" />
|
||||
</template>
|
||||
</gl-tab>
|
||||
</template>
|
||||
|
|
|
@ -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>
|
||||
import { GlAlert } from '@gitlab/ui';
|
||||
import { __ } from '~/locale';
|
||||
import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
|
||||
import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants';
|
||||
import { DRAW_FAILURE, DEFAULT } from '../../constants';
|
||||
import LinksLayer from '../graph_shared/links_layer.vue';
|
||||
import JobPill from './job_pill.vue';
|
||||
import StagePill from './stage_pill.vue';
|
||||
|
@ -21,10 +20,6 @@ export default {
|
|||
errorTexts: {
|
||||
[DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
|
||||
[DEFAULT]: __('An unknown error occurred.'),
|
||||
[EMPTY_PIPELINE_DATA]: __(
|
||||
'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.',
|
||||
),
|
||||
[INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'),
|
||||
},
|
||||
props: {
|
||||
pipelineData: {
|
||||
|
@ -55,18 +50,6 @@ export default {
|
|||
variant: 'danger',
|
||||
dismissible: true,
|
||||
};
|
||||
case EMPTY_PIPELINE_DATA:
|
||||
return {
|
||||
text: this.$options.errorTexts[EMPTY_PIPELINE_DATA],
|
||||
variant: 'tip',
|
||||
dismissible: false,
|
||||
};
|
||||
case INVALID_CI_CONFIG:
|
||||
return {
|
||||
text: this.$options.errorTexts[INVALID_CI_CONFIG],
|
||||
variant: 'danger',
|
||||
dismissible: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
text: this.$options.errorTexts[DEFAULT],
|
||||
|
@ -81,18 +64,6 @@ export default {
|
|||
hasHighlightedJob() {
|
||||
return Boolean(this.highlightedJob);
|
||||
},
|
||||
hideGraph() {
|
||||
// We won't even try to render the graph with these condition
|
||||
// because it would cause additional errors down the line for the user
|
||||
// which is confusing.
|
||||
return this.isPipelineDataEmpty || this.isInvalidCiConfig;
|
||||
},
|
||||
isInvalidCiConfig() {
|
||||
return this.pipelineData?.status === CI_CONFIG_STATUS_INVALID;
|
||||
},
|
||||
isPipelineDataEmpty() {
|
||||
return !this.isInvalidCiConfig && this.pipelineStages.length === 0;
|
||||
},
|
||||
pipelineStages() {
|
||||
return this.pipelineData?.stages || [];
|
||||
},
|
||||
|
@ -101,15 +72,9 @@ export default {
|
|||
pipelineData: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
if (this.isPipelineDataEmpty) {
|
||||
this.reportFailure(EMPTY_PIPELINE_DATA);
|
||||
} else if (this.isInvalidCiConfig) {
|
||||
this.reportFailure(INVALID_CI_CONFIG);
|
||||
} else {
|
||||
this.$nextTick(() => {
|
||||
this.computeGraphDimensions();
|
||||
});
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.computeGraphDimensions();
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -172,12 +137,7 @@ export default {
|
|||
>
|
||||
{{ failure.text }}
|
||||
</gl-alert>
|
||||
<div
|
||||
v-if="!hideGraph"
|
||||
:id="containerId"
|
||||
:ref="$options.CONTAINER_REF"
|
||||
data-testid="graph-container"
|
||||
>
|
||||
<div :id="containerId" :ref="$options.CONTAINER_REF" data-testid="graph-container">
|
||||
<links-layer
|
||||
:pipeline-data="pipelineStages"
|
||||
:pipeline-id="$options.PIPELINE_ID"
|
||||
|
|
|
@ -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 { createPipelinesDetailApp } from './pipeline_details_graph';
|
||||
import { createPipelineHeaderApp } from './pipeline_details_header';
|
||||
import { createPipelineNotificationApp } from './pipeline_details_notification';
|
||||
import { apolloProvider } from './pipeline_shared_client';
|
||||
import createTestReportsStore from './stores/test_reports';
|
||||
import { reportToSentry } from './utils';
|
||||
|
@ -18,6 +19,7 @@ const SELECTORS = {
|
|||
PIPELINE_DETAILS: '.js-pipeline-details-vue',
|
||||
PIPELINE_GRAPH: '#js-pipeline-graph-vue',
|
||||
PIPELINE_HEADER: '#js-pipeline-header-vue',
|
||||
PIPELINE_NOTIFICATION: '#js-pipeline-notification',
|
||||
PIPELINE_TESTS: '#js-pipeline-tests-detail',
|
||||
};
|
||||
|
||||
|
@ -93,6 +95,14 @@ export default async function initPipelineDetailsBundle() {
|
|||
Flash(__('An error occurred while loading a section of this page.'));
|
||||
}
|
||||
|
||||
if (gon.features.pipelineGraphLayersView) {
|
||||
try {
|
||||
createPipelineNotificationApp(SELECTORS.PIPELINE_NOTIFICATION, apolloProvider);
|
||||
} catch {
|
||||
Flash(__('An error occurred while loading a section of this page.'));
|
||||
}
|
||||
}
|
||||
|
||||
if (canShowNewPipelineDetails) {
|
||||
try {
|
||||
createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset);
|
||||
|
|
|
@ -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) {
|
||||
if (isInIssuePage() || isInDesignPage()) {
|
||||
if (isAssigneesWidgetShown) {
|
||||
mountAssigneesComponent();
|
||||
} else {
|
||||
mountAssigneesComponentDeprecated(mediator);
|
||||
|
|
|
@ -26,7 +26,6 @@
|
|||
@import './pages/projects';
|
||||
@import './pages/prometheus';
|
||||
@import './pages/registry';
|
||||
@import './pages/runners';
|
||||
@import './pages/search';
|
||||
@import './pages/service_desk';
|
||||
@import './pages/settings';
|
||||
|
|
|
@ -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_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)
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ class ProjectFeature < ApplicationRecord
|
|||
container_registry
|
||||
].freeze
|
||||
|
||||
EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance]).freeze
|
||||
EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance, :pages]).freeze
|
||||
|
||||
set_available_features(FEATURES)
|
||||
|
||||
|
|
|
@ -152,6 +152,20 @@ class Todo < ApplicationRecord
|
|||
def pluck_user_id
|
||||
pluck(:user_id)
|
||||
end
|
||||
|
||||
# Count todos grouped by user_id and state, using an UNION query
|
||||
# so we can utilize the partial indexes for each state.
|
||||
def count_grouped_by_user_id_and_state
|
||||
grouped_count = select(:user_id, 'count(id) AS count').group(:user_id)
|
||||
|
||||
done = grouped_count.where(state: :done).select("'done' AS state")
|
||||
pending = grouped_count.where(state: :pending).select("'pending' AS state")
|
||||
union = unscoped.from_union([done, pending], remove_duplicates: false)
|
||||
|
||||
connection.select_all(union).each_with_object({}) do |row, counts|
|
||||
counts[[row['user_id'], row['state']]] = row['count']
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def resource_parent
|
||||
|
|
|
@ -47,7 +47,7 @@ class TodoService
|
|||
|
||||
yield target
|
||||
|
||||
todo_users.each(&:update_todos_count_cache)
|
||||
Users::UpdateTodoCountCacheService.new(todo_users).execute if todo_users.present?
|
||||
end
|
||||
|
||||
# When we reassign an assignable object (issuable, alert) we should:
|
||||
|
@ -227,14 +227,16 @@ class TodoService
|
|||
users_with_pending_todos = pending_todos(users, attributes).pluck_user_id
|
||||
users.reject! { |user| users_with_pending_todos.include?(user.id) && Feature.disabled?(:multiple_todos, user) }
|
||||
|
||||
users.map do |user|
|
||||
todos = users.map do |user|
|
||||
issue_type = attributes.delete(:issue_type)
|
||||
track_todo_creation(user, issue_type)
|
||||
|
||||
todo = Todo.create(attributes.merge(user_id: user.id))
|
||||
user.update_todos_count_cache
|
||||
todo
|
||||
Todo.create(attributes.merge(user_id: user.id))
|
||||
end
|
||||
|
||||
Users::UpdateTodoCountCacheService.new(users).execute
|
||||
|
||||
todos
|
||||
end
|
||||
|
||||
def new_issuable(issuable, author)
|
||||
|
|
34
app/services/users/update_todo_count_cache_service.rb
Normal file
34
app/services/users/update_todo_count_cache_service.rb
Normal file
|
@ -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_learn_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'aws'),
|
||||
protected_environment_variables_link: help_page_path('ci/variables/README', anchor: 'protect-a-cicd-variable'),
|
||||
masked_environment_variables_link: help_page_path('ci/variables/README', anchor: 'mask-a-custom-variable'),
|
||||
masked_environment_variables_link: help_page_path('ci/variables/README', anchor: 'mask-a-cicd-variable'),
|
||||
} }
|
||||
|
||||
- if !@group && @project.group
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
= email_default_heading("Hello, #{@resource.name}!")
|
||||
= email_default_heading(_("Hello, %{name}!") % { name: @resource.name })
|
||||
%p
|
||||
The password for your GitLab account on
|
||||
#{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url)}
|
||||
has successfully been changed.
|
||||
= _('The password for your GitLab account on %{link_to_gitlab} has successfully been changed.').html_safe % { link_to_gitlab: link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url) }
|
||||
%p
|
||||
If you did not initiate this change, please contact your administrator
|
||||
immediately.
|
||||
= _('If you did not initiate this change, please contact your administrator immediately.')
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
Hello, <%= @resource.name %>!
|
||||
<%= _('Hello, %{name}!') % { name: @resource.name } %>
|
||||
|
||||
The password for your GitLab account on <%= Gitlab.config.gitlab.url %>
|
||||
has successfully been changed.
|
||||
<%= _('The password for your GitLab account on %{gitlab_url} has successfully been changed.') % { gitlab_url: Gitlab.config.gitlab.url } %>
|
||||
|
||||
If you did not initiate this change, please contact your administrator
|
||||
immediately.
|
||||
<%= _('If you did not initiate this change, please contact your administrator immediately.') %>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
= render 'devise/shared/tab_single', tab_title: 'Change your password'
|
||||
= render 'devise/shared/tab_single', tab_title: _('Change your password')
|
||||
.login-box
|
||||
.login-body
|
||||
= form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors' }) do |f|
|
||||
|
@ -6,16 +6,16 @@
|
|||
= render "devise/shared/error_messages", resource: resource
|
||||
= f.hidden_field :reset_password_token
|
||||
.form-group
|
||||
= f.label 'New password', for: "user_password"
|
||||
= f.password_field :password, class: "form-control gl-form-input top", required: true, title: 'This field is required', data: { qa_selector: 'password_field'}
|
||||
= f.label _('New password'), for: "user_password"
|
||||
= f.password_field :password, class: "form-control gl-form-input top", required: true, title: _('This field is required.'), data: { qa_selector: 'password_field'}
|
||||
.form-group
|
||||
= f.label 'Confirm new password', for: "user_password_confirmation"
|
||||
= f.password_field :password_confirmation, class: "form-control gl-form-input bottom", title: 'This field is required', data: { qa_selector: 'password_confirmation_field' }, required: true
|
||||
= f.label _('Confirm new password'), for: "user_password_confirmation"
|
||||
= f.password_field :password_confirmation, class: "form-control gl-form-input bottom", title: _('This field is required.'), data: { qa_selector: 'password_confirmation_field' }, required: true
|
||||
.clearfix
|
||||
= f.submit "Change your password", class: "gl-button btn btn-confirm", data: { qa_selector: 'change_password_button' }
|
||||
= f.submit _("Change your password"), class: "gl-button btn btn-confirm", data: { qa_selector: 'change_password_button' }
|
||||
|
||||
.clearfix.prepend-top-20
|
||||
%p
|
||||
%span.light Didn't receive a confirmation email?
|
||||
= link_to "Request a new one", new_confirmation_path(:user)
|
||||
%span.light= _("Didn't receive a confirmation email?")
|
||||
= link_to _("Request a new one"), new_confirmation_path(:user)
|
||||
= render 'devise/shared/sign_in_link'
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
-# Show a message if none of the mechanisms above are enabled
|
||||
- if !password_authentication_enabled_for_web? && !ldap_sign_in_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?)
|
||||
%div
|
||||
No authentication methods configured.
|
||||
= _('No authentication methods configured.')
|
||||
|
||||
- if allow_signup?
|
||||
%p.gl-mt-3
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
%div
|
||||
= render 'devise/shared/tab_single', tab_title: 'Two-Factor Authentication'
|
||||
= render 'devise/shared/tab_single', tab_title: _('Two-Factor Authentication')
|
||||
.login-box
|
||||
.login-body
|
||||
- if @user.two_factor_otp_enabled?
|
||||
|
@ -7,10 +7,10 @@
|
|||
- resource_params = params[resource_name].presence || params
|
||||
= f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0)
|
||||
%div
|
||||
= f.label 'Two-Factor Authentication code', name: :otp_attempt
|
||||
= f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', title: 'This field is required.', data: { qa_selector: 'two_fa_code_field' }
|
||||
%p.form-text.text-muted.hint Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.
|
||||
= f.label _('Two-Factor Authentication code'), name: :otp_attempt
|
||||
= f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', title: _('This field is required.'), data: { qa_selector: 'two_fa_code_field' }
|
||||
%p.form-text.text-muted.hint= _("Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.")
|
||||
.prepend-top-20
|
||||
= f.submit "Verify code", class: "gl-button btn btn-confirm", data: { qa_selector: 'verify_code_button' }
|
||||
= f.submit _("Verify code"), class: "gl-button btn btn-confirm", data: { qa_selector: 'verify_code_button' }
|
||||
- if @user.two_factor_webauthn_u2f_enabled?
|
||||
= render "authentication/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
- lint_link_start = '<a href="%{url}">'.html_safe % { url: lint_link_url }
|
||||
= s_('You can also test your %{gitlab_ci_yml} in %{lint_link_start}CI Lint%{lint_link_end}').html_safe % { gitlab_ci_yml: '.gitlab-ci.yml', lint_link_start: lint_link_start, lint_link_end: '</a>'.html_safe }
|
||||
|
||||
#js-pipeline-notification{ data: { dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs') } }
|
||||
= render "projects/pipelines/with_tabs", pipeline: @pipeline, stages: @stages, pipeline_has_errors: pipeline_has_errors
|
||||
|
||||
.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json), metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: @project.namespace, project_id: @project, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline) } }
|
||||
|
|
|
@ -5,6 +5,7 @@ require 'optparse'
|
|||
require_relative '../lib/gitlab'
|
||||
require_relative '../lib/gitlab/utils'
|
||||
require_relative '../lib/gitlab/sidekiq_config/cli_methods'
|
||||
require_relative '../lib/gitlab/sidekiq_config/worker_matcher'
|
||||
require_relative '../lib/gitlab/sidekiq_cluster'
|
||||
require_relative '../lib/gitlab/sidekiq_cluster/cli'
|
||||
|
||||
|
|
|
@ -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
|
5
changelogs/unreleased/mr-thread-comment-button.yml
Normal file
5
changelogs/unreleased/mr-thread-comment-button.yml
Normal file
|
@ -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
|
5
changelogs/unreleased/remove_epics_index.yml
Normal file
5
changelogs/unreleased/remove_epics_index.yml
Normal file
|
@ -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'
|
||||
type: development
|
||||
group: group::source code
|
||||
default_enabled: false
|
||||
default_enabled: true
|
||||
|
|
|
@ -8,7 +8,7 @@ product_category: collection
|
|||
value_type: string
|
||||
status: data_available
|
||||
time_frame: none
|
||||
data_source:
|
||||
data_source: ruby
|
||||
distribution:
|
||||
- ce
|
||||
- ee
|
||||
|
@ -16,4 +16,4 @@ tier:
|
|||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
skip_validation: true
|
||||
|
||||
|
|
|
@ -2,13 +2,13 @@
|
|||
key_path: mail.smtp_server
|
||||
description: The value of the SMTP server that is used
|
||||
product_section: growth
|
||||
product_stage:
|
||||
product_group: group::acquisition
|
||||
product_category:
|
||||
product_stage: growth
|
||||
product_group: group::activation
|
||||
product_category: onboarding
|
||||
value_type: number
|
||||
status: data_available
|
||||
time_frame: all
|
||||
data_source:
|
||||
data_source: ruby
|
||||
distribution:
|
||||
- ce
|
||||
- ee
|
||||
|
@ -16,4 +16,3 @@ tier:
|
|||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
skip_validation: true
|
|
@ -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
|
1
db/schema_migrations/20210415144538
Normal file
1
db/schema_migrations/20210415144538
Normal file
|
@ -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_group_id ON epics USING btree (group_id);
|
||||
|
||||
CREATE UNIQUE INDEX index_epics_on_group_id_and_external_key ON epics USING btree (group_id, external_key) WHERE (external_key IS NOT NULL);
|
||||
|
||||
CREATE UNIQUE INDEX index_epics_on_group_id_and_iid ON epics USING btree (group_id, iid);
|
||||
|
|
|
@ -3695,6 +3695,7 @@ Represents an iteration object.
|
|||
| `dueDate` | [`Time`](#time) | Timestamp of the iteration due date. |
|
||||
| `id` | [`ID!`](#id) | ID of the iteration. |
|
||||
| `iid` | [`ID!`](#id) | Internal ID of the iteration. |
|
||||
| `iterationCadence` | [`IterationCadence!`](#iterationcadence) | Cadence of the iteration. |
|
||||
| `report` | [`TimeboxReport`](#timeboxreport) | Historically accurate report about the timebox. |
|
||||
| `scopedPath` | [`String`](#string) | Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts. |
|
||||
| `scopedUrl` | [`String`](#string) | Web URL of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts. |
|
||||
|
|
|
@ -6768,9 +6768,9 @@ Tiers: `premium`, `ultimate`
|
|||
|
||||
The value of the SMTP server that is used
|
||||
|
||||
[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/metrics/counts_all/20210216174829_smtp_server.yml)
|
||||
[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/metrics/settings/20210216174829_smtp_server.yml)
|
||||
|
||||
Group: `group::acquisition`
|
||||
Group: `group::activation`
|
||||
|
||||
Status: `data_available`
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ Every project directly under the group namespace will be
|
|||
available to the user if they have access to them. For example:
|
||||
|
||||
- Public projects, in the group will be available to every signed-in user, if all enabled [project features](../project/settings/index.md#sharing-and-permissions)
|
||||
are set to **Everyone With Access**.
|
||||
except for GitLab Pages are set to **Everyone With Access**.
|
||||
- Private projects will be available only if the user is a member of the project.
|
||||
|
||||
Repository and database information that are copied over to each new project are
|
||||
|
|
|
@ -62,7 +62,7 @@ GitLab administrators can
|
|||
|
||||
Within this section, you can configure the group where all the custom project
|
||||
templates are sourced. Every project _template_ directly under the group namespace is
|
||||
available to every signed-in user, if all enabled [project features](../project/settings/index.md#sharing-and-permissions) are set to **Everyone With Access**.
|
||||
available to every signed-in user, if all enabled [project features](../project/settings/index.md#sharing-and-permissions) except for GitLab Pages are set to **Everyone With Access**.
|
||||
|
||||
However, private projects will be available only if the user is a member of the project.
|
||||
|
||||
|
|
|
@ -95,7 +95,7 @@ module API
|
|||
|
||||
# https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource
|
||||
params do
|
||||
requires :q, type: String, desc: 'The search term'
|
||||
optional :q, type: String, desc: 'The search term'
|
||||
optional :skip, type: Integer, desc: 'The number of results to skip', default: 0, regexp: NON_NEGATIVE_INTEGER_REGEX
|
||||
optional :take, type: Integer, desc: 'The number of results to return', default: Kaminari.config.default_per_page, regexp: POSITIVE_INTEGER_REGEX
|
||||
optional :prerelease, type: ::Grape::API::Boolean, desc: 'Include prerelease versions', default: true
|
||||
|
|
|
@ -37,6 +37,8 @@ module Gitlab
|
|||
end
|
||||
|
||||
def after_resolve(value:, context:, **rest)
|
||||
return value if value.is_a?(GraphQL::Execution::Execute::Skip)
|
||||
|
||||
if @field.connection?
|
||||
redact_connection(value, context)
|
||||
elsif @field.type.list?
|
||||
|
|
|
@ -53,11 +53,11 @@ module Gitlab
|
|||
'You cannot specify --queue-selector and --experimental-queue-selector together'
|
||||
end
|
||||
|
||||
all_queues = SidekiqConfig::CliMethods.all_queues(@rails_path)
|
||||
queue_names = SidekiqConfig::CliMethods.worker_queues(@rails_path)
|
||||
worker_metadatas = SidekiqConfig::CliMethods.worker_metadatas(@rails_path)
|
||||
worker_queues = SidekiqConfig::CliMethods.worker_queues(@rails_path)
|
||||
|
||||
queue_groups = argv.map do |queues|
|
||||
next queue_names if queues == '*'
|
||||
queue_groups = argv.map do |queues_or_query_string|
|
||||
next worker_queues if queues_or_query_string == SidekiqConfig::WorkerMatcher::WILDCARD_MATCH
|
||||
|
||||
# When using the queue query syntax, we treat each queue group
|
||||
# as a worker attribute query, and resolve the queues for the
|
||||
|
@ -65,14 +65,14 @@ module Gitlab
|
|||
|
||||
# Simplify with https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/646
|
||||
if @queue_selector || @experimental_queue_selector
|
||||
SidekiqConfig::CliMethods.query_workers(queues, all_queues)
|
||||
SidekiqConfig::CliMethods.query_queues(queues_or_query_string, worker_metadatas)
|
||||
else
|
||||
SidekiqConfig::CliMethods.expand_queues(queues.split(','), queue_names)
|
||||
SidekiqConfig::CliMethods.expand_queues(queues_or_query_string.split(','), worker_queues)
|
||||
end
|
||||
end
|
||||
|
||||
if @negate_queues
|
||||
queue_groups.map! { |queues| queue_names - queues }
|
||||
queue_groups.map! { |queues| worker_queues - queues }
|
||||
end
|
||||
|
||||
if queue_groups.all?(&:empty?)
|
||||
|
|
|
@ -12,35 +12,19 @@ module Gitlab
|
|||
# rubocop:disable Gitlab/ModuleWithInstanceVariables
|
||||
extend self
|
||||
|
||||
# The file names are misleading. Those files contain the metadata of the
|
||||
# workers. They should be renamed to all_workers instead.
|
||||
# https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1018
|
||||
QUEUE_CONFIG_PATHS = begin
|
||||
result = %w[app/workers/all_queues.yml]
|
||||
result << 'ee/app/workers/all_queues.yml' if Gitlab.ee?
|
||||
result
|
||||
end.freeze
|
||||
|
||||
QUERY_OR_OPERATOR = '|'
|
||||
QUERY_AND_OPERATOR = '&'
|
||||
QUERY_CONCATENATE_OPERATOR = ','
|
||||
QUERY_TERM_REGEX = %r{^(\w+)(!?=)([\w:#{QUERY_CONCATENATE_OPERATOR}]+)}.freeze
|
||||
def worker_metadatas(rails_path = Rails.root.to_s)
|
||||
@worker_metadatas ||= {}
|
||||
|
||||
QUERY_PREDICATES = {
|
||||
feature_category: :to_sym,
|
||||
has_external_dependencies: lambda { |value| value == 'true' },
|
||||
name: :to_s,
|
||||
resource_boundary: :to_sym,
|
||||
tags: :to_sym,
|
||||
urgency: :to_sym
|
||||
}.freeze
|
||||
|
||||
QueryError = Class.new(StandardError)
|
||||
InvalidTerm = Class.new(QueryError)
|
||||
UnknownOperator = Class.new(QueryError)
|
||||
UnknownPredicate = Class.new(QueryError)
|
||||
|
||||
def all_queues(rails_path = Rails.root.to_s)
|
||||
@worker_queues ||= {}
|
||||
|
||||
@worker_queues[rails_path] ||= QUEUE_CONFIG_PATHS.flat_map do |path|
|
||||
@worker_metadatas[rails_path] ||= QUEUE_CONFIG_PATHS.flat_map do |path|
|
||||
full_path = File.join(rails_path, path)
|
||||
|
||||
File.exist?(full_path) ? YAML.load_file(full_path) : []
|
||||
|
@ -49,7 +33,7 @@ module Gitlab
|
|||
# rubocop:enable Gitlab/ModuleWithInstanceVariables
|
||||
|
||||
def worker_queues(rails_path = Rails.root.to_s)
|
||||
worker_names(all_queues(rails_path))
|
||||
worker_names(worker_metadatas(rails_path))
|
||||
end
|
||||
|
||||
def expand_queues(queues, all_queues = self.worker_queues)
|
||||
|
@ -62,13 +46,18 @@ module Gitlab
|
|||
end
|
||||
end
|
||||
|
||||
def query_workers(query_string, queues)
|
||||
worker_names(queues.select(&query_string_to_lambda(query_string)))
|
||||
def query_queues(query_string, worker_metadatas)
|
||||
matcher = SidekiqConfig::WorkerMatcher.new(query_string)
|
||||
selected_metadatas = worker_metadatas.select do |worker_metadata|
|
||||
matcher.match?(worker_metadata)
|
||||
end
|
||||
|
||||
worker_names(selected_metadatas)
|
||||
end
|
||||
|
||||
def clear_memoization!
|
||||
if instance_variable_defined?('@worker_queues')
|
||||
remove_instance_variable('@worker_queues')
|
||||
if instance_variable_defined?('@worker_metadatas')
|
||||
remove_instance_variable('@worker_metadatas')
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -77,53 +66,6 @@ module Gitlab
|
|||
def worker_names(workers)
|
||||
workers.map { |queue| queue[:name] }
|
||||
end
|
||||
|
||||
def query_string_to_lambda(query_string)
|
||||
or_clauses = query_string.split(QUERY_OR_OPERATOR).map do |and_clauses_string|
|
||||
and_clauses_predicates = and_clauses_string.split(QUERY_AND_OPERATOR).map do |term|
|
||||
predicate_for_term(term)
|
||||
end
|
||||
|
||||
lambda { |worker| and_clauses_predicates.all? { |predicate| predicate.call(worker) } }
|
||||
end
|
||||
|
||||
lambda { |worker| or_clauses.any? { |predicate| predicate.call(worker) } }
|
||||
end
|
||||
|
||||
def predicate_for_term(term)
|
||||
match = term.match(QUERY_TERM_REGEX)
|
||||
|
||||
raise InvalidTerm.new("Invalid term: #{term}") unless match
|
||||
|
||||
_, lhs, op, rhs = *match
|
||||
|
||||
predicate_for_op(op, predicate_factory(lhs, rhs.split(QUERY_CONCATENATE_OPERATOR)))
|
||||
end
|
||||
|
||||
def predicate_for_op(op, predicate)
|
||||
case op
|
||||
when '='
|
||||
predicate
|
||||
when '!='
|
||||
lambda { |worker| !predicate.call(worker) }
|
||||
else
|
||||
# This is unreachable because InvalidTerm will be raised instead, but
|
||||
# keeping it allows to guard against that changing in future.
|
||||
raise UnknownOperator.new("Unknown operator: #{op}")
|
||||
end
|
||||
end
|
||||
|
||||
def predicate_factory(lhs, values)
|
||||
values_block = QUERY_PREDICATES[lhs.to_sym]
|
||||
|
||||
raise UnknownPredicate.new("Unknown predicate: #{lhs}") unless values_block
|
||||
|
||||
lambda do |queue|
|
||||
comparator = Array(queue[lhs.to_sym]).to_set
|
||||
|
||||
values.map(&values_block).to_set.intersect?(comparator)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
86
lib/gitlab/sidekiq_config/worker_matcher.rb
Normal file
86
lib/gitlab/sidekiq_config/worker_matcher.rb
Normal file
|
@ -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
|
||||
|
||||
desc "GitLab | Run all tests"
|
||||
desc "GitLab | List rake tasks for tests"
|
||||
task :test do
|
||||
Rake::Task["gitlab:test"].invoke
|
||||
puts "Running the full GitLab test suite takes significant time to pass. We recommend using one of the following spec tasks:\n\n"
|
||||
|
||||
spec_tasks = Rake::Task.tasks.select { |t| t.name.start_with?('spec:') }
|
||||
longest_task_name = spec_tasks.map { |t| t.name.size }.max
|
||||
|
||||
spec_tasks.each do |task|
|
||||
puts "#{"%-#{longest_task_name}s" % task.name} | #{task.full_comment}"
|
||||
end
|
||||
|
||||
puts "\nLearn more at https://docs.gitlab.com/ee/development/rake_tasks.html#run-tests."
|
||||
end
|
||||
|
|
|
@ -8305,6 +8305,9 @@ msgstr ""
|
|||
msgid "Confirm"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm new password"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm your account"
|
||||
msgstr ""
|
||||
|
||||
|
@ -11220,6 +11223,9 @@ msgstr ""
|
|||
msgid "DevopsReport|Score"
|
||||
msgstr ""
|
||||
|
||||
msgid "Didn't receive a confirmation email?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Diff content limits"
|
||||
msgstr ""
|
||||
|
||||
|
@ -16130,6 +16136,9 @@ msgstr ""
|
|||
msgid "If you add %{codeStart}needs%{codeEnd} to jobs in your pipeline you'll be able to view the %{codeStart}needs%{codeEnd} relationships between jobs in this tab as a %{linkStart}Directed Acyclic Graph (DAG)%{linkEnd}."
|
||||
msgstr ""
|
||||
|
||||
msgid "If you did not initiate this change, please contact your administrator immediately."
|
||||
msgstr ""
|
||||
|
||||
msgid "If you did not recently sign in, you should immediately %{password_link_start}change your password%{password_link_end}."
|
||||
msgstr ""
|
||||
|
||||
|
@ -23150,6 +23159,18 @@ msgstr ""
|
|||
msgid "PipelineCharts|Total:"
|
||||
msgstr ""
|
||||
|
||||
msgid "PipelineEditor|The CI/CD configuration is continuously validated. Errors and warnings are displayed when the CI/CD configuration file is not empty."
|
||||
msgstr ""
|
||||
|
||||
msgid "PipelineEditor|The merged YAML view is displayed when the CI/CD configuration file has valid syntax."
|
||||
msgstr ""
|
||||
|
||||
msgid "PipelineEditor|The pipeline visualization is displayed when the CI/CD configuration file has valid syntax."
|
||||
msgstr ""
|
||||
|
||||
msgid "PipelineEditor|This tab will be usable when the CI/CD configuration file is populated with valid syntax."
|
||||
msgstr ""
|
||||
|
||||
msgid "PipelineScheduleIntervalPattern|Custom (%{linkStart}Cron syntax%{linkEnd})"
|
||||
msgstr ""
|
||||
|
||||
|
@ -25700,6 +25721,9 @@ msgstr ""
|
|||
msgid "Protocol"
|
||||
msgstr ""
|
||||
|
||||
msgid "Provide feedback"
|
||||
msgstr ""
|
||||
|
||||
msgid "Provider"
|
||||
msgstr ""
|
||||
|
||||
|
@ -26789,6 +26813,9 @@ msgstr ""
|
|||
msgid "Request Access"
|
||||
msgstr ""
|
||||
|
||||
msgid "Request a new one"
|
||||
msgstr ""
|
||||
|
||||
msgid "Request details"
|
||||
msgstr ""
|
||||
|
||||
|
@ -31272,6 +31299,12 @@ msgstr ""
|
|||
msgid "The password for the Jenkins server."
|
||||
msgstr ""
|
||||
|
||||
msgid "The password for your GitLab account on %{gitlab_url} has successfully been changed."
|
||||
msgstr ""
|
||||
|
||||
msgid "The password for your GitLab account on %{link_to_gitlab} has successfully been changed."
|
||||
msgstr ""
|
||||
|
||||
msgid "The phase of the development lifecycle."
|
||||
msgstr ""
|
||||
|
||||
|
@ -31425,9 +31458,6 @@ msgstr ""
|
|||
msgid "The value of the provided variable exceeds the %{count} character limit"
|
||||
msgstr ""
|
||||
|
||||
msgid "The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax."
|
||||
msgstr ""
|
||||
|
||||
msgid "The vulnerability is known, and has not been remediated or mitigated, but is considered to be an acceptable business risk."
|
||||
msgstr ""
|
||||
|
||||
|
@ -31563,6 +31593,9 @@ msgstr ""
|
|||
msgid "There was a problem communicating with your device."
|
||||
msgstr ""
|
||||
|
||||
msgid "There was a problem dismissing this notification."
|
||||
msgstr ""
|
||||
|
||||
msgid "There was a problem fetching branches."
|
||||
msgstr ""
|
||||
|
||||
|
@ -34497,6 +34530,9 @@ msgstr ""
|
|||
msgid "Verify SAML Configuration"
|
||||
msgstr ""
|
||||
|
||||
msgid "Verify code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Verify configuration"
|
||||
msgstr ""
|
||||
|
||||
|
@ -34588,6 +34624,9 @@ msgstr ""
|
|||
msgid "View job"
|
||||
msgstr ""
|
||||
|
||||
msgid "View job dependencies in the pipeline graph!"
|
||||
msgstr ""
|
||||
|
||||
msgid "View job log"
|
||||
msgstr ""
|
||||
|
||||
|
@ -35778,6 +35817,9 @@ msgstr ""
|
|||
msgid "You can now export your security dashboard to a CSV report."
|
||||
msgstr ""
|
||||
|
||||
msgid "You can now group jobs in the pipeline graph based on which jobs are configured to run first, if you use the %{codeStart}needs:%{codeEnd} keyword to establish job dependencies in your CI/CD pipelines. %{linkStart}Learn how to speed up your pipeline with needs.%{linkEnd}"
|
||||
msgstr ""
|
||||
|
||||
msgid "You can now submit a merge request to get this change into the original branch."
|
||||
msgstr ""
|
||||
|
||||
|
@ -36165,7 +36207,7 @@ msgstr ""
|
|||
msgid "Your %{strong}%{plan_name}%{strong_close} subscription will expire on %{strong}%{expires_on}%{strong_close}. After that, you will not be able to create issues or merge requests as well as many other features."
|
||||
msgstr ""
|
||||
|
||||
msgid "Your CI configuration file is invalid."
|
||||
msgid "Your CI/CD configuration syntax is invalid. View Lint tab for more details."
|
||||
msgstr ""
|
||||
|
||||
msgid "Your CSV export has started. It will be emailed to %{email} when complete."
|
||||
|
|
|
@ -30,116 +30,198 @@ RSpec.describe 'Issue Sidebar' do
|
|||
let(:user2) { create(:user) }
|
||||
let(:issue2) { create(:issue, project: project, author: user2) }
|
||||
|
||||
context 'when a privileged user can invite' do
|
||||
it 'shows a link for inviting members and launches invite modal' do
|
||||
project.add_maintainer(user)
|
||||
visit_issue(project, issue2)
|
||||
|
||||
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
|
||||
context 'when GraphQL assignees widget feature flag is disabled' do
|
||||
before do
|
||||
stub_experiment_for_subject(invite_members_version_b: true)
|
||||
stub_feature_flags(issue_assignees_widget: false)
|
||||
end
|
||||
|
||||
it 'shows a link for inviting members and follows through to modal' do
|
||||
project.add_developer(user)
|
||||
visit_issue(project, issue2)
|
||||
include_examples 'issuable invite members experiments' do
|
||||
let(:issuable_path) { project_issue_path(project, issue2) }
|
||||
end
|
||||
|
||||
open_assignees_dropdown
|
||||
context 'when user is a developer' do
|
||||
before do
|
||||
project.add_developer(user)
|
||||
visit_issue(project, issue2)
|
||||
|
||||
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"]')
|
||||
find('.block.assignee .edit-link').click
|
||||
wait_for_requests
|
||||
end
|
||||
|
||||
click_link 'Invite members'
|
||||
|
||||
expect(page).to have_content("Oops, this feature isn't ready yet")
|
||||
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')
|
||||
it 'shows author in assignee dropdown' do
|
||||
page.within '.dropdown-menu-user' do
|
||||
expect(page).to have_content(user2.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is a developer' do
|
||||
before do
|
||||
project.add_developer(user)
|
||||
visit_issue(project, issue2)
|
||||
end
|
||||
it 'shows author when filtering assignee dropdown' do
|
||||
page.within '.dropdown-menu-user' do
|
||||
find('.dropdown-input-field').set(user2.name)
|
||||
|
||||
it 'shows author in assignee dropdown' do
|
||||
open_assignees_dropdown
|
||||
wait_for_requests
|
||||
|
||||
page.within '.dropdown-menu-user' do
|
||||
expect(page).to have_content(user2.name)
|
||||
expect(page).to have_content(user2.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'shows author when filtering assignee dropdown' do
|
||||
open_assignees_dropdown
|
||||
it 'assigns yourself' do
|
||||
find('.block.assignee .dropdown-menu-toggle').click
|
||||
|
||||
page.within '.dropdown-menu-user' do
|
||||
find('.js-dropdown-input-field').find('input').set(user2.name)
|
||||
click_button 'assign yourself'
|
||||
|
||||
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
|
||||
|
||||
it 'assigns yourself' do
|
||||
click_button 'assign yourself'
|
||||
wait_for_requests
|
||||
context 'when invite_members_version_b experiment is enabled' do
|
||||
before do
|
||||
stub_experiment_for_subject(invite_members_version_b: true)
|
||||
end
|
||||
|
||||
page.within '.assignee' do
|
||||
expect(page).to have_content(user.name)
|
||||
it 'shows a link for inviting members and follows through to modal' do
|
||||
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
|
||||
|
||||
it 'keeps your filtered term after filtering and dismissing the dropdown' do
|
||||
open_assignees_dropdown
|
||||
context 'when invite_members_version_b experiment is disabled' do
|
||||
it 'shows author in assignee dropdown and no invite link' do
|
||||
project.add_developer(user)
|
||||
visit_issue(project, issue2)
|
||||
|
||||
find('.js-dropdown-input-field').find('input').set(user2.name)
|
||||
wait_for_requests
|
||||
open_assignees_dropdown
|
||||
|
||||
page.within '.dropdown-menu-user' do
|
||||
expect(page).not_to have_content 'Unassigned'
|
||||
click_link user2.name
|
||||
page.within '.dropdown-menu-user' do
|
||||
expect(page).not_to have_link('Invite members')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is a developer' do
|
||||
before do
|
||||
project.add_developer(user)
|
||||
visit_issue(project, issue2)
|
||||
end
|
||||
|
||||
find('.js-right-sidebar').click
|
||||
it 'shows author in assignee dropdown' do
|
||||
open_assignees_dropdown
|
||||
|
||||
open_assignees_dropdown
|
||||
|
||||
page.within('.assignee') do
|
||||
expect(page.all('[data-testid="selected-participant"]').length).to eq(1)
|
||||
page.within '.dropdown-menu-user' do
|
||||
expect(page).to have_content(user2.name)
|
||||
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
|
||||
|
|
|
@ -167,79 +167,165 @@ RSpec.describe "Issues > User edits issue", :js do
|
|||
end
|
||||
|
||||
describe 'update assignee' do
|
||||
context 'by authorized user' do
|
||||
it 'allows user to select unassigned' do
|
||||
visit project_issue_path(project, issue)
|
||||
context 'when GraphQL assignees widget feature flag is disabled' do
|
||||
before do
|
||||
stub_feature_flags(issue_assignees_widget: false)
|
||||
end
|
||||
|
||||
page.within('.assignee') do
|
||||
expect(page).to have_content "#{user.name}"
|
||||
context 'by authorized user' do
|
||||
def close_dropdown_menu_if_visible
|
||||
find('.dropdown-menu-toggle', visible: :all).tap do |toggle|
|
||||
toggle.click if toggle.visible?
|
||||
end
|
||||
end
|
||||
|
||||
click_button('Edit')
|
||||
wait_for_requests
|
||||
it 'allows user to select unassigned' do
|
||||
visit project_issue_path(project, issue)
|
||||
|
||||
find('[data-testid="unassign"]').click
|
||||
find('[data-testid="title"]').click
|
||||
wait_for_requests
|
||||
page.within('.assignee') do
|
||||
expect(page).to have_content "#{user.name}"
|
||||
|
||||
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
|
||||
|
||||
it 'allows user to select an assignee' do
|
||||
issue2 = create(:issue, project: project, author: user)
|
||||
visit project_issue_path(project, issue2)
|
||||
context 'by unauthorized user' do
|
||||
let(:guest) { create(:user) }
|
||||
|
||||
page.within('.assignee') do
|
||||
expect(page).to have_content "None"
|
||||
click_button('Edit')
|
||||
wait_for_requests
|
||||
before do
|
||||
project.add_guest(guest)
|
||||
end
|
||||
|
||||
page.within '.dropdown-menu-user' do
|
||||
click_link user.name
|
||||
end
|
||||
it 'shows assignee text' do
|
||||
sign_out(:user)
|
||||
sign_in(guest)
|
||||
|
||||
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"
|
||||
visit project_issue_path(project, issue)
|
||||
expect(page).to have_content issue.assignees.first.name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'by unauthorized user' do
|
||||
let(:guest) { create(:user) }
|
||||
context 'when GraphQL assignees widget feature flag is enabled' do
|
||||
context 'by authorized user' do
|
||||
it 'allows user to select unassigned' do
|
||||
visit project_issue_path(project, issue)
|
||||
|
||||
before do
|
||||
project.add_guest(guest)
|
||||
page.within('.assignee') do
|
||||
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
|
||||
|
||||
it 'shows assignee text' do
|
||||
sign_out(:user)
|
||||
sign_in(guest)
|
||||
context 'by unauthorized user' do
|
||||
let(:guest) { create(:user) }
|
||||
|
||||
visit project_issue_path(project, issue)
|
||||
expect(page).to have_content issue.assignees.first.name
|
||||
before do
|
||||
project.add_guest(guest)
|
||||
end
|
||||
|
||||
it 'shows assignee text' do
|
||||
sign_out(:user)
|
||||
sign_in(guest)
|
||||
|
||||
visit project_issue_path(project, issue)
|
||||
expect(page).to have_content issue.assignees.first.name
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -25,7 +25,8 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do
|
|||
fill_in('confirm_name_input', with: forked_project.name)
|
||||
click_button('Confirm')
|
||||
|
||||
expect(page).to have_content('The fork relationship has been removed.')
|
||||
wait_for_requests
|
||||
|
||||
expect(forked_project.reload.forked?).to be_falsy
|
||||
end
|
||||
end
|
||||
|
|
|
@ -174,26 +174,6 @@ RSpec.describe 'Project' do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'remove forked relationship', :js do
|
||||
let(:user) { create(:user) }
|
||||
let(:project) { fork_project(create(:project, :public), user, namespace: user.namespace) }
|
||||
|
||||
before do
|
||||
sign_in user
|
||||
visit edit_project_path(project)
|
||||
end
|
||||
|
||||
it 'removes fork', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/327817' do
|
||||
expect(page).to have_content 'Remove fork relationship'
|
||||
|
||||
remove_with_confirm('Remove fork relationship', project.path)
|
||||
|
||||
expect(page).to have_content 'The fork relationship has been removed.'
|
||||
expect(project.reload.forked?).to be_falsey
|
||||
expect(page).not_to have_content 'Remove fork relationship'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'showing information about source of a project fork' do
|
||||
let(:user) { create(:user) }
|
||||
let(:base_project) { create(:project, :public, :repository) }
|
||||
|
|
|
@ -151,6 +151,22 @@ describe('noteActions', () => {
|
|||
const assignUserButton = wrapper.find('[data-testid="assign-user"]');
|
||||
expect(assignUserButton.exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('should render the correct (unescaped) name in the Resolved By tooltip', () => {
|
||||
const complexUnescapedName = 'This is a Ǝ\'𝞓\'E "cat"?';
|
||||
wrapper = mountNoteActions({
|
||||
...props,
|
||||
canResolve: true,
|
||||
isResolving: false,
|
||||
isResolved: true,
|
||||
resolvedBy: {
|
||||
name: complexUnescapedName,
|
||||
},
|
||||
});
|
||||
|
||||
const { resolveButton } = wrapper.vm.$refs;
|
||||
expect(resolveButton.$el.getAttribute('title')).toBe(`Resolved by ${complexUnescapedName}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { GlAlert, GlIcon } from '@gitlab/ui';
|
||||
import { GlIcon } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
|
||||
import { EDITOR_READY_EVENT } from '~/editor/constants';
|
||||
import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue';
|
||||
import { INVALID_CI_CONFIG } from '~/pipelines/constants';
|
||||
import { mockLintResponse, mockCiConfigPath } from '../../mock_data';
|
||||
|
||||
describe('Text editor component', () => {
|
||||
|
@ -32,7 +31,6 @@ describe('Text editor component', () => {
|
|||
});
|
||||
};
|
||||
|
||||
const findAlert = () => wrapper.findComponent(GlAlert);
|
||||
const findIcon = () => wrapper.findComponent(GlIcon);
|
||||
const findEditor = () => wrapper.findComponent(MockEditorLite);
|
||||
|
||||
|
@ -40,24 +38,9 @@ describe('Text editor component', () => {
|
|||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('when status is invalid', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ props: { isValid: false } });
|
||||
});
|
||||
|
||||
it('show an error message', () => {
|
||||
expect(findAlert().exists()).toBe(true);
|
||||
expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[INVALID_CI_CONFIG]);
|
||||
});
|
||||
|
||||
it('hides the editor', () => {
|
||||
expect(findEditor().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when status is valid', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ props: { isValid: true } });
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it('shows an information message that the section is not editable', () => {
|
||||
|
|
|
@ -4,9 +4,12 @@ import { nextTick } from 'vue';
|
|||
import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue';
|
||||
import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue';
|
||||
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
|
||||
import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue';
|
||||
import {
|
||||
EDITOR_APP_STATUS_EMPTY,
|
||||
EDITOR_APP_STATUS_ERROR,
|
||||
EDITOR_APP_STATUS_LOADING,
|
||||
EDITOR_APP_STATUS_INVALID,
|
||||
EDITOR_APP_STATUS_VALID,
|
||||
} from '~/pipeline_editor/constants';
|
||||
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
|
||||
|
@ -44,6 +47,7 @@ describe('Pipeline editor tabs component', () => {
|
|||
provide: { ...mockProvide, ...provide },
|
||||
stubs: {
|
||||
TextEditor: MockTextEditor,
|
||||
EditorTab,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -192,4 +196,24 @@ describe('Pipeline editor tabs component', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('show tab content based on status', () => {
|
||||
it.each`
|
||||
appStatus | editor | viz | lint | merged
|
||||
${undefined} | ${true} | ${true} | ${true} | ${true}
|
||||
${EDITOR_APP_STATUS_EMPTY} | ${true} | ${false} | ${false} | ${false}
|
||||
${EDITOR_APP_STATUS_INVALID} | ${true} | ${false} | ${true} | ${false}
|
||||
${EDITOR_APP_STATUS_VALID} | ${true} | ${true} | ${true} | ${true}
|
||||
`(
|
||||
'when status is $appStatus, we show - editor:$editor | viz:$viz | lint:$lint | merged:$merged ',
|
||||
({ appStatus, editor, viz, lint, merged }) => {
|
||||
createComponent({ appStatus });
|
||||
|
||||
expect(findTextEditor().exists()).toBe(editor);
|
||||
expect(findPipelineGraph().exists()).toBe(viz);
|
||||
expect(findCiLint().exists()).toBe(lint);
|
||||
expect(findMergedPreview().exists()).toBe(merged);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
import { GlTabs } from '@gitlab/ui';
|
||||
import { GlAlert, GlTabs } from '@gitlab/ui';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue';
|
||||
|
||||
const mockContent1 = 'MOCK CONTENT 1';
|
||||
const mockContent2 = 'MOCK CONTENT 2';
|
||||
|
||||
const MockEditorLite = {
|
||||
template: '<div>EDITOR</div>',
|
||||
};
|
||||
|
||||
describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
|
||||
let wrapper;
|
||||
let mockChildMounted = jest.fn();
|
||||
|
@ -37,22 +40,34 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
|
|||
`,
|
||||
};
|
||||
|
||||
const createWrapper = () => {
|
||||
const createMockedWrapper = () => {
|
||||
wrapper = mount(MockTabbedContent);
|
||||
};
|
||||
|
||||
const createWrapper = ({ props } = {}) => {
|
||||
wrapper = mount(EditorTab, {
|
||||
propsData: props,
|
||||
slots: {
|
||||
default: MockEditorLite,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const findSlotComponent = () => wrapper.findComponent(MockEditorLite);
|
||||
const findAlert = () => wrapper.findComponent(GlAlert);
|
||||
|
||||
beforeEach(() => {
|
||||
mockChildMounted = jest.fn();
|
||||
});
|
||||
|
||||
it('tabs are mounted lazily', async () => {
|
||||
createWrapper();
|
||||
createMockedWrapper();
|
||||
|
||||
expect(mockChildMounted).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('first tab is only mounted after nextTick', async () => {
|
||||
createWrapper();
|
||||
createMockedWrapper();
|
||||
|
||||
await nextTick();
|
||||
|
||||
|
@ -60,6 +75,36 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
|
|||
expect(mockChildMounted).toHaveBeenCalledWith(mockContent1);
|
||||
});
|
||||
|
||||
describe('showing the tab content depending on `isEmpty` and `isInvalid`', () => {
|
||||
it.each`
|
||||
isEmpty | isInvalid | showSlotComponent | text
|
||||
${undefined} | ${undefined} | ${true} | ${'renders'}
|
||||
${false} | ${false} | ${true} | ${'renders'}
|
||||
${undefined} | ${true} | ${false} | ${'hides'}
|
||||
${true} | ${false} | ${false} | ${'hides'}
|
||||
${false} | ${true} | ${false} | ${'hides'}
|
||||
`(
|
||||
'$text the slot component when isEmpty:$isEmpty and isInvalid:$isInvalid',
|
||||
({ isEmpty, isInvalid, showSlotComponent }) => {
|
||||
createWrapper({
|
||||
props: { isEmpty, isInvalid },
|
||||
});
|
||||
expect(findSlotComponent().exists()).toBe(showSlotComponent);
|
||||
expect(findAlert().exists()).toBe(!showSlotComponent);
|
||||
},
|
||||
);
|
||||
|
||||
it('can have a custom empty message', () => {
|
||||
const text = 'my custom alert message';
|
||||
createWrapper({ props: { isEmpty: true, emptyMessage: text } });
|
||||
|
||||
const alert = findAlert();
|
||||
|
||||
expect(alert.exists()).toBe(true);
|
||||
expect(alert.text()).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe('user interaction', () => {
|
||||
const clickTab = async (testid) => {
|
||||
wrapper.find(`[data-testid="${testid}"]`).trigger('click');
|
||||
|
@ -67,7 +112,7 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
|
|||
};
|
||||
|
||||
beforeEach(() => {
|
||||
createWrapper();
|
||||
createMockedWrapper();
|
||||
});
|
||||
|
||||
it('mounts a tab once after selecting it', async () => {
|
||||
|
|
|
@ -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 { shallowMount } from '@vue/test-utils';
|
||||
import { CI_CONFIG_STATUS_INVALID, CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants';
|
||||
import { CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants';
|
||||
import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue';
|
||||
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
|
||||
import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue';
|
||||
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
|
||||
import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue';
|
||||
import { DRAW_FAILURE, EMPTY_PIPELINE_DATA, INVALID_CI_CONFIG } from '~/pipelines/constants';
|
||||
import { DRAW_FAILURE } from '~/pipelines/constants';
|
||||
import { invalidNeedsData, pipelineData, singleStageData } from './mock_data';
|
||||
|
||||
describe('pipeline graph component', () => {
|
||||
|
@ -42,31 +42,6 @@ describe('pipeline graph component', () => {
|
|||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('with no data', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({ pipelineData: {} });
|
||||
});
|
||||
|
||||
it('does not render the graph', () => {
|
||||
expect(wrapper.text()).toBe(wrapper.vm.$options.errorTexts[EMPTY_PIPELINE_DATA]);
|
||||
expect(findPipelineGraph().exists()).toBe(false);
|
||||
expect(findAllStagePills()).toHaveLength(0);
|
||||
expect(findAllJobPills()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with `INVALID` status', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({ pipelineData: { status: CI_CONFIG_STATUS_INVALID } });
|
||||
});
|
||||
|
||||
it('renders an error message and does not render the graph', () => {
|
||||
expect(findAlert().exists()).toBe(true);
|
||||
expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[INVALID_CI_CONFIG]);
|
||||
expect(findPipelineGraph().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with `VALID` status', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({
|
||||
|
|
|
@ -376,6 +376,26 @@ RSpec.describe 'DeclarativePolicy authorization in GraphQL ' do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'Authorization on GraphQL::Execution::Execute::SKIP' do
|
||||
let(:type) do
|
||||
type_factory do |type|
|
||||
type.authorize permission_single
|
||||
end
|
||||
end
|
||||
|
||||
let(:query_type) do
|
||||
query_factory do |query|
|
||||
query.field :item, [type], null: true, resolver: new_resolver(GraphQL::Execution::Execute::SKIP)
|
||||
end
|
||||
end
|
||||
|
||||
it 'skips redaction' do
|
||||
expect(Ability).not_to receive(:allowed?)
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def permit(*permissions)
|
||||
|
|
|
@ -112,7 +112,7 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do
|
|||
subject(:projects) { resolve_projects(args) }
|
||||
|
||||
let(:include_subgroups) { false }
|
||||
let(:project_3) { create(:project, name: 'Project', path: 'project', namespace: namespace) }
|
||||
let!(:project_3) { create(:project, name: 'Project', path: 'project', namespace: namespace) }
|
||||
|
||||
context 'when ids is provided' do
|
||||
let(:ids) { [project_3.to_global_id.to_s] }
|
||||
|
|
|
@ -3,44 +3,9 @@
|
|||
require 'knapsack'
|
||||
|
||||
module KnapsackEnv
|
||||
class RSpecContextAdapter < Knapsack::Adapters::RSpecAdapter
|
||||
def bind_time_tracker
|
||||
::RSpec.configure do |config|
|
||||
# Original version starts timer in `config.prepend_before(:each) do`
|
||||
# https://github.com/KnapsackPro/knapsack/blob/v1.17.0/lib/knapsack/adapters/rspec_adapter.rb#L9
|
||||
config.prepend_before(:context) do
|
||||
Knapsack.tracker.start_timer
|
||||
end
|
||||
|
||||
# Original version is `config.prepend_before(:each) do`
|
||||
# https://github.com/KnapsackPro/knapsack/blob/v1.17.0/lib/knapsack/adapters/rspec_adapter.rb#L9
|
||||
config.prepend_before(:each) do # rubocop:disable RSpec/HookArgument
|
||||
current_example_group =
|
||||
if ::RSpec.respond_to?(:current_example)
|
||||
::RSpec.current_example.metadata[:example_group]
|
||||
else
|
||||
example.metadata
|
||||
end
|
||||
|
||||
Knapsack.tracker.test_path = Knapsack::Adapters::RSpecAdapter.test_path(current_example_group)
|
||||
end
|
||||
|
||||
# Original version stops timer in `config.append_after(:each) do`
|
||||
# https://github.com/KnapsackPro/knapsack/blob/v1.17.0/lib/knapsack/adapters/rspec_adapter.rb#L20
|
||||
config.append_after(:context) do
|
||||
Knapsack.tracker.stop_timer
|
||||
end
|
||||
|
||||
config.after(:suite) do
|
||||
Knapsack.logger.info(Knapsack::Presenter.global_time)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.configure!
|
||||
return unless ENV['CI'] && ENV['KNAPSACK_GENERATE_REPORT'] && !ENV['NO_KNAPSACK']
|
||||
|
||||
RSpecContextAdapter.bind
|
||||
Knapsack::Adapters::RSpecAdapter.bind
|
||||
end
|
||||
end
|
||||
|
|
|
@ -214,7 +214,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do
|
|||
expect(Gitlab::SidekiqCluster).not_to receive(:start)
|
||||
|
||||
expect { cli.run(%W(#{flag} unknown_field=chatops)) }
|
||||
.to raise_error(Gitlab::SidekiqConfig::CliMethods::QueryError)
|
||||
.to raise_error(Gitlab::SidekiqConfig::WorkerMatcher::QueryError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'fast_spec_helper'
|
||||
require 'rspec-parameterized'
|
||||
|
||||
RSpec.describe Gitlab::SidekiqConfig::CliMethods do
|
||||
let(:dummy_root) { '/tmp/' }
|
||||
|
@ -122,10 +121,8 @@ RSpec.describe Gitlab::SidekiqConfig::CliMethods do
|
|||
end
|
||||
end
|
||||
|
||||
describe '.query_workers' do
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
let(:queues) do
|
||||
describe '.query_queues' do
|
||||
let(:worker_metadatas) do
|
||||
[
|
||||
{
|
||||
name: 'a',
|
||||
|
@ -162,79 +159,16 @@ RSpec.describe Gitlab::SidekiqConfig::CliMethods do
|
|||
]
|
||||
end
|
||||
|
||||
context 'with valid input' do
|
||||
where(:query, :selected_queues) do
|
||||
# feature_category
|
||||
'feature_category=category_a' | %w(a a:2)
|
||||
'feature_category=category_a,category_c' | %w(a a:2 c)
|
||||
'feature_category=category_a|feature_category=category_c' | %w(a a:2 c)
|
||||
'feature_category!=category_a' | %w(b c)
|
||||
let(:worker_matcher) { double(:WorkerMatcher) }
|
||||
let(:query) { 'feature_category=category_a,category_c' }
|
||||
|
||||
# has_external_dependencies
|
||||
'has_external_dependencies=true' | %w(b)
|
||||
'has_external_dependencies=false' | %w(a a:2 c)
|
||||
'has_external_dependencies=true,false' | %w(a a:2 b c)
|
||||
'has_external_dependencies=true|has_external_dependencies=false' | %w(a a:2 b c)
|
||||
'has_external_dependencies!=true' | %w(a a:2 c)
|
||||
|
||||
# urgency
|
||||
'urgency=high' | %w(a:2 b)
|
||||
'urgency=low' | %w(a)
|
||||
'urgency=high,low,throttled' | %w(a a:2 b c)
|
||||
'urgency=low|urgency=throttled' | %w(a c)
|
||||
'urgency!=high' | %w(a c)
|
||||
|
||||
# name
|
||||
'name=a' | %w(a)
|
||||
'name=a,b' | %w(a b)
|
||||
'name=a,a:2|name=b' | %w(a a:2 b)
|
||||
'name!=a,a:2' | %w(b c)
|
||||
|
||||
# resource_boundary
|
||||
'resource_boundary=memory' | %w(b c)
|
||||
'resource_boundary=memory,cpu' | %w(a b c)
|
||||
'resource_boundary=memory|resource_boundary=cpu' | %w(a b c)
|
||||
'resource_boundary!=memory,cpu' | %w(a:2)
|
||||
|
||||
# tags
|
||||
'tags=no_disk_io' | %w(a b)
|
||||
'tags=no_disk_io,git_access' | %w(a a:2 b)
|
||||
'tags=no_disk_io|tags=git_access' | %w(a a:2 b)
|
||||
'tags=no_disk_io&tags=git_access' | %w(a)
|
||||
'tags!=no_disk_io' | %w(a:2 c)
|
||||
'tags!=no_disk_io,git_access' | %w(c)
|
||||
'tags=unknown_tag' | []
|
||||
'tags!=no_disk_io' | %w(a:2 c)
|
||||
'tags!=no_disk_io,git_access' | %w(c)
|
||||
'tags!=unknown_tag' | %w(a a:2 b c)
|
||||
|
||||
# combinations
|
||||
'feature_category=category_a&urgency=high' | %w(a:2)
|
||||
'feature_category=category_a&urgency=high|feature_category=category_c' | %w(a:2 c)
|
||||
end
|
||||
|
||||
with_them do
|
||||
it do
|
||||
expect(described_class.query_workers(query, queues))
|
||||
.to match_array(selected_queues)
|
||||
end
|
||||
end
|
||||
before do
|
||||
allow(::Gitlab::SidekiqConfig::WorkerMatcher).to receive(:new).with(query).and_return(worker_matcher)
|
||||
allow(worker_matcher).to receive(:match?).and_return(true, true, false, true)
|
||||
end
|
||||
|
||||
context 'with invalid input' do
|
||||
where(:query, :error) do
|
||||
'feature_category="category_a"' | described_class::InvalidTerm
|
||||
'feature_category=' | described_class::InvalidTerm
|
||||
'feature_category~category_a' | described_class::InvalidTerm
|
||||
'worker_name=a' | described_class::UnknownPredicate
|
||||
end
|
||||
|
||||
with_them do
|
||||
it do
|
||||
expect { described_class.query_workers(query, queues) }
|
||||
.to raise_error(error)
|
||||
end
|
||||
end
|
||||
it 'returns the queue names of matched workers' do
|
||||
expect(described_class.query_queues(query, worker_metadatas)).to match(%w(a a:2 c))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
129
spec/lib/gitlab/sidekiq_config/worker_matcher_spec.rb
Normal file
129
spec/lib/gitlab/sidekiq_config/worker_matcher_spec.rb
Normal file
|
@ -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
|
||||
|
||||
describe '.group_by_user_id_and_state' do
|
||||
let_it_be(:user1) { create(:user) }
|
||||
let_it_be(:user2) { create(:user) }
|
||||
|
||||
before do
|
||||
create(:todo, user: user1, state: :pending)
|
||||
create(:todo, user: user1, state: :pending)
|
||||
create(:todo, user: user1, state: :done)
|
||||
create(:todo, user: user2, state: :pending)
|
||||
end
|
||||
|
||||
specify do
|
||||
expect(Todo.count_grouped_by_user_id_and_state).to eq({ [user1.id, "done"] => 1, [user1.id, "pending"] => 2, [user2.id, "pending"] => 1 })
|
||||
end
|
||||
end
|
||||
|
||||
describe '.any_for_target?' do
|
||||
it 'returns true if there are todos for a given target' do
|
||||
todo = create(:todo)
|
||||
|
|
|
@ -69,7 +69,7 @@ RSpec.describe API::NugetGroupPackages do
|
|||
let(:take) { 26 }
|
||||
let(:skip) { 0 }
|
||||
let(:include_prereleases) { true }
|
||||
let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases } }
|
||||
let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases }.compact }
|
||||
|
||||
subject { get api(url), headers: {}}
|
||||
|
||||
|
@ -145,7 +145,7 @@ RSpec.describe API::NugetGroupPackages do
|
|||
let(:take) { 26 }
|
||||
let(:skip) { 0 }
|
||||
let(:include_prereleases) { false }
|
||||
let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases } }
|
||||
let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases }.compact }
|
||||
let(:url) { "/groups/#{group.id}/-/packages/nuget/query?#{query_parameters.to_query}" }
|
||||
|
||||
it_behaves_like 'returning response status', :forbidden
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
require 'spec_helper'
|
||||
|
||||
RSpec.describe TodoService do
|
||||
include AfterNextHelpers
|
||||
|
||||
let_it_be(:project) { create(:project, :repository) }
|
||||
let_it_be(:author) { create(:user) }
|
||||
let_it_be(:assignee) { create(:user) }
|
||||
|
@ -343,19 +345,19 @@ RSpec.describe TodoService do
|
|||
|
||||
describe '#destroy_target' do
|
||||
it 'refreshes the todos count cache for users with todos on the target' do
|
||||
create(:todo, target: issue, user: john_doe, author: john_doe, project: issue.project)
|
||||
create(:todo, state: :pending, target: issue, user: john_doe, author: john_doe, project: issue.project)
|
||||
|
||||
expect_any_instance_of(User).to receive(:update_todos_count_cache).and_call_original
|
||||
expect_next(Users::UpdateTodoCountCacheService, [john_doe]).to receive(:execute)
|
||||
|
||||
service.destroy_target(issue) { }
|
||||
service.destroy_target(issue) { issue.destroy! }
|
||||
end
|
||||
|
||||
it 'does not refresh the todos count cache for users with only done todos on the target' do
|
||||
create(:todo, :done, target: issue, user: john_doe, author: john_doe, project: issue.project)
|
||||
|
||||
expect_any_instance_of(User).not_to receive(:update_todos_count_cache)
|
||||
expect(Users::UpdateTodoCountCacheService).not_to receive(:new)
|
||||
|
||||
service.destroy_target(issue) { }
|
||||
service.destroy_target(issue) { issue.destroy! }
|
||||
end
|
||||
|
||||
it 'yields the target to the caller' do
|
||||
|
@ -1099,13 +1101,9 @@ RSpec.describe TodoService do
|
|||
it 'updates cached counts when a todo is created' do
|
||||
issue = create(:issue, project: project, assignees: [john_doe], author: author)
|
||||
|
||||
expect(john_doe.todos_pending_count).to eq(0)
|
||||
expect(john_doe).to receive(:update_todos_count_cache).and_call_original
|
||||
expect_next(Users::UpdateTodoCountCacheService, [john_doe]).to receive(:execute)
|
||||
|
||||
service.new_issue(issue, author)
|
||||
|
||||
expect(Todo.where(user_id: john_doe.id, state: :pending).count).to eq 1
|
||||
expect(john_doe.todos_pending_count).to eq(1)
|
||||
end
|
||||
|
||||
shared_examples 'updating todos state' do |state, new_state, new_resolved_by = nil|
|
||||
|
|
61
spec/services/users/update_todo_count_cache_service_spec.rb
Normal file
61
spec/services/users/update_todo_count_cache_service_spec.rb
Normal file
|
@ -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(:skip) { 0 }
|
||||
let(:include_prereleases) { true }
|
||||
let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases } }
|
||||
let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases }.compact }
|
||||
|
||||
subject { get api(url) }
|
||||
|
||||
|
|
|
@ -192,7 +192,10 @@ func handleExifUpload(ctx context.Context, r io.Reader, filename string, imageTy
|
|||
return nil, err
|
||||
}
|
||||
|
||||
tmpfile.Seek(0, io.SeekStart)
|
||||
if _, err := tmpfile.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isValidType := false
|
||||
switch imageType {
|
||||
case exif.TypeJPEG:
|
||||
|
@ -201,7 +204,10 @@ func handleExifUpload(ctx context.Context, r io.Reader, filename string, imageTy
|
|||
isValidType = isTIFF(tmpfile)
|
||||
}
|
||||
|
||||
tmpfile.Seek(0, io.SeekStart)
|
||||
if _, err := tmpfile.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !isValidType {
|
||||
log.WithContextFields(ctx, log.Fields{
|
||||
"filename": filename,
|
||||
|
|
Loading…
Reference in a new issue