Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-04-19 12:09:04 +00:00
parent 3257ae3af0
commit c6af94ea4e
86 changed files with 1361 additions and 675 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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__)

View file

@ -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;

View file

@ -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;
}

View file

@ -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"

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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"

View file

@ -0,0 +1,5 @@
mutation DismissPipelineNotification($featureName: String!) {
userCalloutCreate(input: { featureName: $featureName }) {
errors
}
}

View file

@ -0,0 +1,13 @@
query getUser {
currentUser {
id
__typename
callouts {
__typename
nodes {
__typename
featureName
}
}
}
}

View file

@ -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);

View file

@ -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');
},
});
};

View file

@ -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);

View file

@ -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';

View file

@ -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;
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)

View 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

View file

@ -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

View file

@ -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.')

View file

@ -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.') %>

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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) } }

View file

@ -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'

View file

@ -0,0 +1,5 @@
---
title: Centralize shared state in Authoring section
merge_request: 58790
author:
type: changed

View file

@ -0,0 +1,5 @@
---
title: Create prometheus service asynchronously by default when creating a project
merge_request: 59273
author:
type: changed

View file

@ -0,0 +1,5 @@
---
title: Externalise strings in password_change files
merge_request: 58219
author: nuwe1
type: other

View file

@ -0,0 +1,5 @@
---
title: Externalize strings in passwords/edit.html.haml
merge_request: 58233
author: nuwe1
type: other

View file

@ -0,0 +1,5 @@
---
title: Externalise strings in sessions/new.html.haml
merge_request: 58274
author: nuwe1
type: other

View file

@ -0,0 +1,5 @@
---
title: Externalize strings in sessions/two_factor.html.haml
merge_request: 58275
author: nuwe1
type: other

View file

@ -0,0 +1,5 @@
---
title: Avoid N+1 query when updating todo count cache
merge_request: 57622
author:
type: performance

View file

@ -0,0 +1,5 @@
---
title: Make NuGet SearchQueryService q parameter optional
merge_request: 57654
author: Huzaifa Iftikhar @huzaifaiftikhar
type: fixed

View file

@ -0,0 +1,5 @@
---
title: Migrate Start Review button on MRs to use confirm variant
merge_request: 59523
author:
type: changed

View file

@ -0,0 +1,5 @@
---
title: Added feature flag to show/hide assignees GraphQL widget
merge_request: 59620
author:
type: fixed

View file

@ -0,0 +1,5 @@
---
title: Remove redundant index from epics
merge_request: 59494
author:
type: other

View file

@ -0,0 +1,5 @@
---
title: Fix character escaping in Resolved By tooltips
merge_request: 59428
author:
type: fixed

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
d237690af576fb5a85d984416dcca1936a140a10a9b6c968d3ff57419568fb8f

View file

@ -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);

View file

@ -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. |

View file

@ -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`

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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?

View file

@ -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?)

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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."

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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) }

View file

@ -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}`);
});
});
});

View file

@ -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', () => {

View file

@ -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);
},
);
});
});

View file

@ -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 () => {

View file

@ -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);
});
});

View file

@ -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({

View file

@ -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)

View file

@ -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] }

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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)

View file

@ -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

View file

@ -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|

View 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

View file

@ -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) }

View file

@ -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,