Commit f3d45708 authored by Robert Speicher's avatar Robert Speicher

Merge branch 'master' of gitlab.com:gitlab-org/gitlab

parents 23d70567 80a9bea9
......@@ -21,6 +21,11 @@ export default {
default: '',
required: false,
},
isRawContent: {
type: Boolean,
default: false,
required: false,
},
loading: {
type: Boolean,
default: true,
......@@ -65,6 +70,8 @@ export default {
v-else
ref="contentViewer"
:content="content"
:is-raw-content="isRawContent"
:file-name="blob.name"
:type="activeViewer.fileType"
data-qa-selector="file_content"
/>
......
......@@ -126,7 +126,7 @@ export default {
const diffLines = this.diffFile[INLINE_DIFF_LINES_KEY];
let isAdding = false;
for (let i = 0, diffLinesLength = diffLines.length - 1; i < diffLinesLength; i += 1) {
for (let i = 0, diffLinesLength = diffLines.length - 1; i <= diffLinesLength; i += 1) {
const line = diffLines[i];
if (start.line_code === line.line_code) {
......
<script>
import { GlButton } from '@gitlab/ui';
import { GlButton, GlLink } from '@gitlab/ui';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
export default {
components: { GlButton },
components: { GlButton, GlLink },
props: {
displayText: {
type: String,
......@@ -37,6 +37,42 @@ export default {
required: false,
default: undefined,
},
triggerElement: {
type: String,
required: false,
default: 'button',
},
event: {
type: String,
required: false,
default: '',
},
label: {
type: String,
required: false,
default: '',
},
},
computed: {
isButton() {
return this.triggerElement === 'button';
},
componentAttributes() {
const baseAttributes = {
class: this.classes,
'data-qa-selector': 'invite_members_button',
};
if (this.event && this.label) {
return {
...baseAttributes,
'data-track-event': this.event,
'data-track-label': this.label,
};
}
return baseAttributes;
},
},
mounted() {
this.trackExperimentOnShow();
......@@ -57,12 +93,15 @@ export default {
<template>
<gl-button
:class="classes"
:icon="icon"
v-if="isButton"
v-bind="componentAttributes"
:variant="variant"
data-qa-selector="invite_members_button"
:icon="icon"
@click="openModal"
>
{{ displayText }}
</gl-button>
<gl-link v-else v-bind="componentAttributes" data-is-link="true" @click="openModal">
{{ displayText }}
</gl-link>
</template>
......@@ -2,6 +2,7 @@
import { mapState, mapActions } from 'vuex';
import { __, s__ } from '~/locale';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import getTableHeaders from '../utils';
import PackageTypeToken from './tokens/package_type_token.vue';
......@@ -16,7 +17,7 @@ export default {
operators: [{ value: '=', description: __('is'), default: 'true' }],
},
],
components: { RegistrySearch },
components: { RegistrySearch, UrlSync },
computed: {
...mapState({
isGroupPage: (state) => state.config.isGroupPage,
......@@ -38,13 +39,18 @@ export default {
</script>
<template>
<registry-search
:filter="filter"
:sorting="sorting"
:tokens="$options.tokens"
:sortable-fields="sortableFields"
@sorting:changed="updateSorting"
@filter:changed="setFilter"
@filter:submit="$emit('update')"
/>
<url-sync>
<template #default="{ updateQuery }">
<registry-search
:filter="filter"
:sorting="sorting"
:tokens="$options.tokens"
:sortable-fields="sortableFields"
@sorting:changed="updateSorting"
@filter:changed="setFilter"
@filter:submit="$emit('update')"
@query:changed="updateQuery"
/>
</template>
</url-sync>
</template>
......@@ -6,6 +6,7 @@ import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants';
import PackageSearch from './package_search.vue';
import PackageTitle from './package_title.vue';
......@@ -42,11 +43,21 @@ export default {
},
},
mounted() {
const queryParams = getQueryParams(window.document.location.search);
const { sorting, filters } = extractFilterAndSorting(queryParams);
this.setSorting(sorting);
this.setFilter(filters);
this.requestPackagesList();
this.checkDeleteAlert();
},
methods: {
...mapActions(['requestPackagesList', 'requestDeletePackage', 'setSelectedType']),
...mapActions([
'requestPackagesList',
'requestDeletePackage',
'setSelectedType',
'setSorting',
'setFilter',
]),
onPageChanged(page) {
return this.requestPackagesList({ page });
},
......
......@@ -7,3 +7,23 @@ export const keyValueToFilterToken = (type, data) => ({ type, value: { data } })
export const searchArrayToFilterTokens = (search) =>
search.map((s) => keyValueToFilterToken(FILTERED_SEARCH_TERM, s));
export const extractFilterAndSorting = (queryObject) => {
const { type, search, sort, orderBy } = queryObject;
const filters = [];
const sorting = {};
if (type) {
filters.push(keyValueToFilterToken('type', type));
}
if (search) {
filters.push(...searchArrayToFilterTokens(search));
}
if (sort) {
sorting.sort = sort;
}
if (orderBy) {
sorting.orderBy = orderBy;
}
return { filters, sorting };
};
......@@ -4,6 +4,7 @@ import initIssuableSidebar from '~/init_issuable_sidebar';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { IssuableType } from '~/issuable_show/constants';
import Issue from '~/issue';
import '~/notes/index';
......@@ -36,6 +37,7 @@ export default function initShowIssue() {
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
initInviteMembersModal();
initInviteMembersTrigger();
import(/* webpackChunkName: 'design_management' */ '~/design_management')
.then((module) => module.default())
......
......@@ -6,6 +6,7 @@ import initIssuableSidebar from '~/init_issuable_sidebar';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { handleLocationHash } from '~/lib/utils/common_utils';
import StatusBox from '~/merge_request/components/status_box.vue';
import initSourcegraph from '~/sourcegraph';
......@@ -22,6 +23,7 @@ export default function initMergeRequestShow() {
initInviteMemberModal();
initInviteMemberTrigger();
initInviteMembersModal();
initInviteMembersTrigger();
const el = document.querySelector('.js-mr-status-box');
// eslint-disable-next-line no-new
......
......@@ -12,6 +12,7 @@ import { get } from 'lodash';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
import createFlash from '~/flash';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import { extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
import Tracking from '~/tracking';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import DeleteImage from '../components/delete_image.vue';
......@@ -82,6 +83,9 @@ export default {
searchConfig: SORT_FIELDS,
apollo: {
baseImages: {
skip() {
return !this.fetchBaseQuery;
},
query: getContainerRepositoriesQuery,
variables() {
return this.queryVariables;
......@@ -125,15 +129,19 @@ export default {
sorting: { orderBy: 'UPDATED', sort: 'desc' },
name: null,
mutationLoading: false,
fetchBaseQuery: false,
fetchAdditionalDetails: false,
};
},
computed: {
images() {
return this.baseImages.map((image, index) => ({
...image,
...get(this.additionalDetails, index, {}),
}));
if (this.baseImages) {
return this.baseImages.map((image, index) => ({
...image,
...get(this.additionalDetails, index, {}),
}));
}
return [];
},
graphqlResource() {
return this.config.isGroupPage ? 'group' : 'project';
......@@ -172,8 +180,15 @@ export default {
},
},
mounted() {
const { sorting, filters } = extractFilterAndSorting(this.$route.query);
this.filter = [...filters];
this.name = filters[0]?.value.data;
this.sorting = { ...this.sorting, ...sorting };
// If the two graphql calls - which are not batched - resolve togheter we will have a race
// condition when apollo sets the cache, with this we give the 'base' call an headstart
this.fetchBaseQuery = true;
setTimeout(() => {
this.fetchAdditionalDetails = true;
}, 200);
......@@ -245,6 +260,9 @@ export default {
const search = this.filter.find((i) => i.type === FILTERED_SEARCH_TERM);
this.name = search?.value?.data;
},
updateUrlQueryString(query) {
this.$router.push({ query });
},
},
};
</script>
......@@ -304,6 +322,7 @@ export default {
@sorting:changed="updateSorting"
@filter:changed="filter = $event"
@filter:submit="doFilter"
@query:changed="updateUrlQueryString"
/>
<div v-if="isLoading" class="gl-mt-5">
......
......@@ -114,7 +114,7 @@ export default {
<gl-sprintf
:message="
__(
'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.',
'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0.0%{codeEnd}, %{codeStart}v2.1.0-pre%{codeEnd}.',
)
"
>
......
......@@ -36,6 +36,12 @@ export default {
blobHash: uniqueId(),
};
},
props: {
path: {
type: String,
required: true,
},
},
data() {
return {
projectPath: '',
......@@ -85,6 +91,7 @@ export default {
<blob-content
:blob="blobInfo"
:content="blobInfo.rawBlob"
:is-raw-content="true"
:active-viewer="viewer"
:loading="false"
/>
......
......@@ -2,16 +2,21 @@
// This file is in progress and behind a feature flag, please see the following issue for more:
// https://gitlab.com/gitlab-org/gitlab/-/issues/323200
// TODO (follow-up MR): import BlobContentViewer from '../components/blob_content_viewer.vue';
import BlobContentViewer from '../components/blob_content_viewer.vue';
export default {
components: {
// TODO (follow-up MR): BlobContentViewer,
BlobContentViewer,
},
props: {
path: {
type: String,
required: true,
},
},
};
</script>
<template>
<div></div>
<!-- TODO (follow-up MR): <blob-content-viewer/> -->
<blob-content-viewer :path="path" />
</template>
......@@ -11,6 +11,16 @@ export default {
type: String,
required: true,
},
isRawContent: {
type: Boolean,
default: false,
required: false,
},
fileName: {
type: String,
required: false,
default: '',
},
},
mounted() {
eventHub.$emit(SNIPPET_MEASURE_BLOBS_CONTENT);
......
<script>
/* eslint-disable vue/no-v-html */
import { GlIcon } from '@gitlab/ui';
import EditorLite from '~/vue_shared/components/editor_lite.vue';
import { HIGHLIGHT_CLASS_NAME } from './constants';
import ViewerMixin from './mixins';
export default {
components: {
GlIcon,
EditorLite,
},
mixins: [ViewerMixin],
inject: ['blobHash'],
......@@ -45,27 +47,36 @@ export default {
};
</script>
<template>
<div
class="file-content code js-syntax-highlight"
data-qa-selector="file_content"
:class="$options.userColorScheme"
>
<div class="line-numbers">
<a
v-for="line in lineNumbers"
:id="`L${line}`"
:key="line"
class="diff-line-num js-line-number"
:href="`#LC${line}`"
:data-line-number="line"
@click="scrollToLine(`#LC${line}`)"
>
<gl-icon :size="12" name="link" />
{{ line }}
</a>
</div>
<div class="blob-content">
<pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre>
<div>
<editor-lite
v-if="isRawContent"
:value="content"
:file-name="fileName"
:editor-options="{ readOnly: true }"
/>
<div
v-else
class="file-content code js-syntax-highlight"
data-qa-selector="file_content"
:class="$options.userColorScheme"
>
<div class="line-numbers">
<a
v-for="line in lineNumbers"
:id="`L${line}`"
:key="line"
class="diff-line-num js-line-number"
:href="`#LC${line}`"
:data-line-number="line"
@click="scrollToLine(`#LC${line}`)"
>
<gl-icon :size="12" name="link" />
{{ line }}
</a>
</div>
<div class="blob-content">
<pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre>
</div>
</div>
</div>
</template>
......@@ -168,6 +168,9 @@ export default {
false,
);
},
suggestionsStartIndex() {
return Math.max(this.lines.length - 1, 0);
},
},
watch: {
isSubmitting(isSubmitting) {
......@@ -260,7 +263,7 @@ export default {
:line-content="lineContent"
:can-suggest="canSuggest"
:show-suggest-popover="showSuggestPopover"
:suggestion-start-index="lines.length - 1"
:suggestion-start-index="suggestionsStartIndex"
@preview-markdown="showPreviewTab"
@write-markdown="showWriteTab"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
......
......@@ -91,15 +91,29 @@ class InvitesController < ApplicationController
def authenticate_user!
return if current_user
notice = ["To accept this invitation, sign in"]
notice << "or create an account" if Gitlab::CurrentSettings.allow_signup?
notice = notice.join(' ') + "."
store_location_for :user, request.fullpath
redirect_params = member ? { invite_email: member.invite_email } : {}
if user_sign_up?
redirect_to new_user_registration_path(invite_email: member.invite_email), notice: _("To accept this invitation, create an account or sign in.")
else
redirect_to new_user_session_path(sign_in_redirect_params), notice: sign_in_notice
end
end
store_location_for :user, request.fullpath
def sign_in_redirect_params
member ? { invite_email: member.invite_email } : {}
end
def user_sign_up?
Gitlab::CurrentSettings.allow_signup? && member && !User.find_by_any_email(member.invite_email)
end
redirect_to new_user_session_path(redirect_params), notice: notice
def sign_in_notice
if Gitlab::CurrentSettings.allow_signup?
_("To accept this invitation, sign in or create an account.")
else
_("To accept this invitation, sign in.")
end
end
def invite_details
......
......@@ -29,11 +29,9 @@ module Mutations
end
def ready?(**args)
if Gitlab::Database.read_only?
raise_resource_not_available_error! ERROR_MESSAGE
else
true
end
raise_resource_not_available_error! ERROR_MESSAGE if Gitlab::Database.read_only?
true
end
def load_application_object(argument, lookup_as_type, id, context)
......
......@@ -7,6 +7,10 @@ module Resolvers
include ResolvesProject
type Types::Ci::Config::ConfigType, null: true
description <<~MD
Linted and processed contents of a CI config.
Should not be requested more than once per request.
MD
authorize :read_pipeline
......@@ -55,7 +59,7 @@ module Resolvers
name: job[:name],
stage: job[:stage],
group_name: CommitStatus.new(name: job[:name]).group_name,
needs: job.dig(:needs) || [],
needs: job[:needs] || [],
allow_failure: job[:allow_failure],
before_script: job[:before_script],
script: job[:script],
......
......@@ -3,24 +3,30 @@
module Resolvers
module Ci
class RunnerSetupResolver < BaseResolver
ACCESS_DENIED = 'User is not authorized to register a runner for the specified resource!'
type Types::Ci::RunnerSetupType, null: true
description 'Runner setup instructions.'
argument :platform, GraphQL::STRING_TYPE,
required: true,
description: 'Platform to generate the instructions for.'
argument :platform,
type: GraphQL::STRING_TYPE,
required: true,
description: 'Platform to generate the instructions for.'
argument :architecture, GraphQL::STRING_TYPE,
required: true,
description: 'Architecture to generate the instructions for.'
argument :architecture,
type: GraphQL::STRING_TYPE,
required: true,
description: 'Architecture to generate the instructions for.'
argument :project_id, ::Types::GlobalIDType[::Project],
required: false,
description: 'Project to register the runner for.'
argument :project_id,
type: ::Types::GlobalIDType[::Project],
required: false,
description: 'Project to register the runner for.'
argument :group_id, ::Types::GlobalIDType[::Group],
required: false,
description: 'Group to register the runner for.'
argument :group_id,
type: ::Types::GlobalIDType[::Group],
required: false,
description: 'Group to register the runner for.'
def resolve(platform:, architecture:, **args)
instructions = Gitlab::Ci::RunnerInstructions.new(
......@@ -35,11 +41,15 @@ module Resolvers
register_instructions: instructions.register_command
}
ensure
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'User is not authorized to register a runner for the specified resource!' if instructions.errors.include?('Gitlab::Access::AccessDeniedError')
raise Gitlab::Graphql::Errors::ResourceNotAvailable, ACCESS_DENIED if access_denied?(instructions)
end
private
def access_denied?(instructions)
instructions.errors.include?('Gitlab::Access::AccessDeniedError')
end
def other_install_instructions(platform)
Gitlab::Ci::RunnerInstructions::OTHER_ENVIRONMENTS[platform.to_sym][:installation_instructions_url]
end
......
......@@ -5,8 +5,10 @@ module Resolvers
type ::GraphQL::STRING_TYPE, null: false
description 'Testing endpoint to validate the API with'
argument :text, GraphQL::STRING_TYPE, required: true,
description: 'Text to echo back.'
argument :text,
type: GraphQL::STRING_TYPE,
required: true,
description: 'Text to echo back.'
def resolve(text:)
username = current_user&.username
......
# frozen_string_literal: true
# rubocop:disable Graphql/ResolverType (inherited from MilestonesResolver)
module Resolvers
class GroupMilestonesResolver < MilestonesResolver
......
......@@ -5,8 +5,8 @@ module Resolvers
type Types::ProjectType.connection_type, null: true
argument :search, GraphQL::STRING_TYPE,
required: false,
description: 'Search query.'
required: false,
description: 'Search query.'
alias_method :user, :object
......
......@@ -10,7 +10,10 @@ module Types
argument :not, NegatedBoardIssueInputType,
required: false,
description: 'List of negated params. Warning: this argument is experimental and a subject to change in future.'
description: <<~MD
List of negated arguments.
Warning: this argument is experimental and a subject to change in future.
MD
argument :search, GraphQL::STRING_TYPE,
required: false,
......
......@@ -8,39 +8,65 @@ module Types
expose_permissions Types::PermissionTypes::Group
field :web_url, GraphQL::STRING_TYPE, null: false,
field :web_url,
type: GraphQL::STRING_TYPE,
null: false,
description: 'Web URL of the group.'
field :avatar_url, GraphQL::STRING_TYPE, null: true,
field :avatar_url,
type: GraphQL::STRING_TYPE,
null: true,
description: 'Avatar URL of the group.'
field :custom_emoji, Types::CustomEmojiType.connection_type, null: true,
field :custom_emoji,
type: Types::CustomEmojiType.connection_type,
null: true,
description: 'Custom emoji within this namespace.',
feature_flag: :custom_emoji
field :share_with_group_lock, GraphQL::BOOLEAN_TYPE, null: true,
field :share_with_group_lock,
type: GraphQL::BOOLEAN_TYPE,
null: true,
description: 'Indicates if sharing a project with another group within this group is prevented.'
field :project_creation_level, GraphQL::STRING_TYPE, null: true, method: :project_creation_level_str,
field :project_creation_level,
type: GraphQL::STRING_TYPE,
null: true,
method: :project_creation_level_str,
description: 'The permission level required to create projects in the group.'
field :subgroup_creation_level, GraphQL::STRING_TYPE, null: true, method: :subgroup_creation_level_str,
field :subgroup_creation_level,
type: GraphQL::STRING_TYPE,
null: true,
method: :subgroup_creation_level_str,
description: 'The permission level required to create subgroups within the group.'
field :require_two_factor_authentication, GraphQL::BOOLEAN_TYPE, null: true,
field :require_two_factor_authentication,
type: GraphQL::BOOLEAN_TYPE,
null: true,
description: 'Indicates if all users in this group are required to set up two-factor authentication.'
field :two_factor_grace_period, GraphQL::INT_TYPE, null: true,
field :two_factor_grace_period,
type: GraphQL::INT_TYPE,
null: true,
description: 'Time before two-factor authentication is enforced.'
field :auto_devops_enabled, GraphQL::BOOLEAN_TYPE, null: true,
field :auto_devops_enabled,
type: GraphQL::BOOLEAN_TYPE,
null: true,
description: 'Indicates whether Auto DevOps is enabled for all projects within this group.'
field :emails_disabled, GraphQL::BOOLEAN_TYPE, null: true,
field :emails_disabled,
type: GraphQL::BOOLEAN_TYPE,
null: true,
description: 'Indicates if a group has email notifications disabled.'
field :mentions_disabled, GraphQL::BOOLEAN_TYPE, null: true,
field :mentions_disabled,
type: GraphQL::BOOLEAN_TYPE,
null: true,
description: 'Indicates if a group is disabled from getting mentioned.'
field :parent, GroupType, null: true,
field :parent,
type: GroupType,
null: true,
description: 'Parent group.'
field :issues,
......@@ -55,7 +81,7 @@ module Types
description: 'Merge requests for projects in this group.',
resolver: Resolvers::GroupMergeRequestsResolver
field :milestones, Types::MilestoneType.connection_type, null: true,
field :milestones,
description: 'Milestones of the group.',
resolver: Resolvers::GroupMilestonesResolver
......@@ -76,9 +102,10 @@ module Types
Types::LabelType,
null: true,
description: 'A label available on this group.' do
argument :title, GraphQL::STRING_TYPE,
required: true,
description: 'Title of the label.'
argument :title,
type: GraphQL::STRING_TYPE,
required: true,
description: 'Title of the label.'
end
field :group_members,
......@@ -92,7 +119,9 @@ module Types
resolver: Resolvers::ContainerRepositoriesResolver,
authorize: :read_container_image
field :container_repositories_count, GraphQL::INT_TYPE, null: false,
field :container_repositories_count,
type: GraphQL::INT_TYPE,
null: false,
description: 'Number of container repositories in the group.'
field :packages,
......
......@@ -5,12 +5,12 @@ module Types
graphql_name 'JiraUsersMappingInputType'
argument :jira_account_id,
GraphQL::STRING_TYPE,
required: true,
description: 'Jira account ID of the user.'
GraphQL::STRING_TYPE,
required: true,
description: 'Jira account ID of the user.'
argument :gitlab_id,
GraphQL::INT_TYPE,
required: false,
description: 'Id of the GitLab user.'
GraphQL::INT_TYPE,
required: false,
description: 'ID of the GitLab user.'
end
end
......@@ -55,7 +55,10 @@ module Types
field :container_repository, Types::ContainerRepositoryDetailsType,
null: true,
description: 'Find a container repository.' do
argument :id, ::Types::GlobalIDType[::ContainerRepository], required: true, description: 'The global ID of the container repository.'
argument :id,
type: ::Types::GlobalIDType[::ContainerRepository],
required: true,
description: 'The global ID of the container repository.'
end
field :package,
......@@ -72,9 +75,7 @@ module Types
description: 'Find users.',
resolver: Resolvers::UsersResolver
field :echo, GraphQL::STRING_TYPE, null: false,
description: 'Text to echo back.',
resolver: Resolvers::EchoResolver
field :echo, resolver: Resolvers::EchoResolver
field :issue, Types::IssueType,
null: true,
......@@ -102,18 +103,10 @@ module Types
null: true,
description: 'CI related settings that apply to the entire instance.'
field :runner_platforms, Types::Ci::RunnerPlatformType.connection_type,
null: true, description: 'Supported runner platforms.',
resolver: Resolvers::Ci::RunnerPlatformsResolver
field :runner_platforms, resolver: Resolvers::Ci::RunnerPlatformsResolver
field :runner_setup, resolver: Resolvers::Ci::RunnerSetupResolver
field :runner_setup, Types::Ci::RunnerSetupType, null: true,
description: 'Get runner setup instructions.',
resolver: Resolvers::Ci::RunnerSetupResolver
field :ci_config, Types::Ci::Config::ConfigType, null: true,
description: 'Get linted and processed contents of a CI config. Should not be requested more than once per request.',
resolver: Resolvers::Ci::ConfigResolver,
complexity: 126 # AUTHENTICATED_COMPLEXITY / 2 + 1
field :ci_config, resolver: Resolvers::Ci::ConfigResolver, complexity: 126 # AUTHENTICATED_COMPLEXITY / 2 + 1
def design_management
DesignManagementObject.new(nil)
......
......@@ -11,44 +11,72 @@ module Types
expose_permissions Types::PermissionTypes::User
field :id, GraphQL::ID_TYPE, null: false,
field :id,
type: GraphQL::ID_TYPE,
null: false,
description: 'ID of the user.'
field :bot, GraphQL::BOOLEAN_TYPE, null: false,
field :bot,
type: GraphQL::BOOLEAN_TYPE,
null: false,
description: 'Indicates if the user is a bot.',
method: :bot?
field :username, GraphQL::STRING_TYPE, null: false,
field :username,
type: GraphQL::STRING_TYPE,
null: false,
description: 'Username of the user. Unique within this instance of GitLab.'
field :name, GraphQL::STRING_TYPE, null: false,
field :name,
type: GraphQL::STRING_TYPE,
null: false,
description: 'Human-readable name of the user.'
field :state, Types::UserStateEnum, null: false,
field :state,
type: Types::UserStateEnum,
null: false,
description: 'State of the user.'
field :email, GraphQL::STRING_TYPE, null: true,
field :email,
type: GraphQL::STRING_TYPE,
null: true,
description: 'User email.', method: :public_email,
deprecated: { reason: :renamed, replacement: 'User.publicEmail', milestone: '13.7' }
field :public_email, GraphQL::STRING_TYPE, null: true,
field :public_email,
type: GraphQL::STRING_TYPE,
null: true,
description: "User's public email."
field :avatar_url, GraphQL::STRING_TYPE, null: true,
field :avatar_url,
type: GraphQL::STRING_TYPE,
null: true,
description: "URL of the user's avatar."
field :web_url, GraphQL::STRING_TYPE, null: false,
field :web_url,
type: GraphQL::STRING_TYPE,
null: false,
description: 'Web URL of the user.'
field :web_path, GraphQL::STRING_TYPE, null: false,
field :web_path,
type: GraphQL::STRING_TYPE,
null: false,
description: 'Web path of the user.'
field :todos, Types::TodoType.connection_type, null: false,
field :todos,
resolver: Resolvers::TodoResolver,
description: 'To-do items of the user.'
field :group_memberships, Types::GroupMemberType.connection_type, null: true,
field :group_memberships,
type: Types::GroupMemberType.connection_type,
null: true,
description: 'Group memberships of the user.'
field :group_count, GraphQL::INT_TYPE, null: true,
field :group_count,
resolver: Resolvers::Users::GroupCountResolver,
description: 'Group count for the user.',
feature_flag: :user_group_counts
field :status, Types::UserStatusType, null: true,
description: 'User status.'
field :location, ::GraphQL::STRING_TYPE, null: true,
field :status,
type: Types::UserStatusType,
null: true,
description: 'User status.'
field :location,
type: ::GraphQL::STRING_TYPE,
null: true,
description: 'The location of the user.'
field :project_memberships, Types::ProjectMemberType.connection_type, null: true,
field :project_memberships,
type: Types::ProjectMemberType.connection_type,
null: true,
description: 'Project memberships of the user.'
field :starred_projects, Types::ProjectType.connection_type, null: true,
field :starred_projects,
description: 'Projects starred by the user.',
resolver: Resolvers::UserStarredProjectsResolver
......@@ -64,8 +92,6 @@ module Types
description: 'Merge Requests assigned to the user for review.'
field :snippets,
Types::SnippetType.connection_type,
null: true,
description: 'Snippets authored by the user.',
resolver: Resolvers::Users::SnippetsResolver
field :callouts,
......
%p.text-center
%span.light
Already have login and password?
= link_to "Sign in", new_session_path(:user, redirect_to_referer: 'yes')
- path_params = { redirect_to_referer: 'yes' }
- path_params[:invite_email] = @invite_email if @invite_email.present?
= link_to "Sign in", new_session_path(:user, path_params)
......@@ -53,10 +53,10 @@
%ul.dropdown-footer-list
%li
- if directly_invite_members?
= link_to invite_text,
project_project_members_path(@project),
title: invite_text,
data: { 'is-link': true, 'track-event': 'click_invite_members', 'track-label': track_label }
.js-invite-members-trigger{ data: { trigger_element: 'anchor',
display_text: invite_text,
event: 'click_invite_members',
label: track_label } }
- else
.js-invite-member-trigger{ data: { display_text: invite_text, event: 'click_invite_members_version_b', label: track_label } }
- else
......
---
title: Remove deprecated repository archive routes
merge_request: 57236
author:
type: removed
---
title: Change assignee dropdown invite to utilize invite modal
merge_request: 57002
author:
type: changed
---
title: Connect Registries searches to URL
merge_request: 57251
author:
type: added
---
title: Send invited users to sign up instead of sign in when possible
merge_request: 57240
author:
type: other
---
title: Migrate group badges when using Bulk Import
merge_request: 56357
author:
type: added
......@@ -5,3 +5,4 @@ filenames:
- ee/app/assets/javascripts/security_configuration/api_fuzzing/graphql/create_api_fuzzing_configuration.mutation.graphql
- ee/app/assets/javascripts/security_configuration/dast_profiles/graphql/dast_failed_site_validations.query.graphql
- app/assets/javascripts/repository/queries/blob_info.query.graphql
- ee/app/assets/javascripts/security_configuration/graphql/configure_dependency_scanning.mutation.graphql
......@@ -2,15 +2,7 @@
# All routing related to repository browsing
resource :repository, only: [:create] do
member do
# deprecated since GitLab 9.5
get 'archive', constraints: { format: Gitlab::PathRegex.archive_formats_regex }, as: 'archive_alternative', defaults: { append_sha: true }
# deprecated since GitLab 10.7
get ':id/archive', constraints: { format: Gitlab::PathRegex.archive_formats_regex, id: /.+/ }, action: 'archive', as: 'archive_deprecated', defaults: { append_sha: true }
end
end
resource :repository, only: [:create]
resources :commit, only: [:show], constraints: { id: /\h{7,40}/ } do
member do
......
......@@ -6,8 +6,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# PostgreSQL replication and failover with Omnibus GitLab **(PREMIUM SELF)**
This document focuses on configuration supported with [GitLab Premium](https://about.gitlab.com/pricing/), using the Omnibus GitLab package.
If you're a Community Edition or Starter user, consider using a cloud hosted solution.
If you're a Free user of GitLab self-managed, consider using a cloud-hosted solution.
This document doesn't cover installations from source.
If a setup with replication and failover isn't what you were looking for, see
......
......@@ -215,7 +215,7 @@ Example response:
## Update an issue board
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/5954) in [GitLab Starter](https://about.gitlab.com/pricing/) 11.1.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/5954) in GitLab 11.1.
Updates a project issue board.
......@@ -228,10 +228,10 @@ PUT /projects/:id/boards/:board_id
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `board_id` | integer | yes | The ID of a board |
| `name` | string | no | The new name of the board |
| `assignee_id` **(STARTER)** | integer | no | The assignee the board should be scoped to |
| `milestone_id` **(STARTER)** | integer | no | The milestone the board should be scoped to |
| `labels` **(STARTER)** | string | no | Comma-separated list of label names which the board should be scoped to |
| `weight` **(STARTER)** | integer | no | The weight range from 0 to 9, to which the board should be scoped to |
| `assignee_id` **(PREMIUM)** | integer | no | The assignee the board should be scoped to |
| `milestone_id` **(PREMIUM)** | integer | no | The milestone the board should be scoped to |
| `labels` **(PREMIUM)** | string | no | Comma-separated list of label names which the board should be scoped to |
| `weight` **(PREMIUM)** | integer | no | The weight range from 0 to 9, to which the board should be scoped to |
```shell
curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/boards/1?name=new_name&milestone_id=43&assignee_id=1&labels=Doing&weight=4"
......
......@@ -38,7 +38,8 @@ Returns [`CiApplicationSettings`](#ciapplicationsettings).
### `ciConfig`
Get linted and processed contents of a CI config. Should not be requested more than once per request.
Linted and processed contents of a CI config.
Should not be requested more than once per request.
Returns [`CiConfig`](#ciconfig).
......@@ -93,7 +94,7 @@ Returns [`DevopsAdoptionSegmentConnection`](#devopsadoptionsegmentconnection).
### `echo`
Text to echo back.
Testing endpoint to validate the API with.
Returns [`String!`](#string).
......@@ -271,7 +272,7 @@ Returns [`RunnerPlatformConnection`](#runnerplatformconnection).
### `runnerSetup`
Get runner setup instructions.
Runner setup instructions.
Returns [`RunnerSetup`](#runnersetup).
......@@ -398,7 +399,9 @@ Returns [`VulnerabilitiesCountByDayConnection`](#vulnerabilitiescountbydayconnec
### `vulnerabilitiesCountByDayAndSeverity`
Number of vulnerabilities per severity level, per day, for the projects on the current user's instance security dashboard.
Number of vulnerabilities per severity level, per day, for the projects on the
current user's instance security dashboard.
.
WARNING:
**Deprecated** in 13.3.
......@@ -6509,7 +6512,7 @@ Representation of a GitLab user.
| `starredProjects` | [`ProjectConnection`](#projectconnection) | Projects starred by the user. |
| `state` | [`UserState!`](#userstate) | State of the user. |
| `status` | [`UserStatus`](#userstatus) | User status. |
| `todos` | [`TodoConnection!`](#todoconnection) | To-do items of the user. |
| `todos` | [`TodoConnection`](#todoconnection) | To-do items of the user. |
| `userPermissions` | [`UserPermissions!`](#userpermissions) | Permissions for the current user on the resource. |
| `username` | [`String!`](#string) | Username of the user. Unique within this instance of GitLab. |
| `webPath` | [`String!`](#string) | Web path of the user. |
......
......@@ -183,20 +183,52 @@ file.
To add a redirect:
1. Create a merge request in one of the internal docs projects (`gitlab`,
`gitlab-runner`, `omnibus-gitlab`, or `charts`), depending on the location of
the file that's being moved, renamed, or removed.
1. To move or rename the documentation file, create a new file with the new
name or location, but don't delete the existing documentation file.
1. In the original documentation file, add the redirect code for
`/help`. Use the following template exactly, and change only the links and
date. Use relative paths and `.md` for a redirect to another documentation
page. Use the full URL (with `https://`) to redirect to a different project or
site:
1. In the repository (`gitlab`, `gitlab-runner`, `omnibus-gitlab`, or `charts`),
create a new documentation file. Don't delete the old one. The easiest
way is to copy it. For example:
```shell
cp doc/user/search/old_file.md doc/api/new_file.md
```
1. Add the redirect code to the old documentation file by running the
following Rake task. The first argument is the path of the old file,
and the second argument is the path of the new file:
- To redirect to a page in the same project, use relative paths and
the `.md` extension. Both old and new paths start from the same location.
In the following example, both paths are relative to `doc/`:
```shell
bundle exec rake "gitlab:docs:redirect[doc/user/search/old_file.md, doc/api/new_file.md]"
```
- To redirect to a page in a different project or site, use the full URL (with `https://`) :
```shell
bundle exec rake "gitlab:docs:redirect[doc/user/search/old_file.md, https://example.com]"
```
Alternatively, you can omit the arguments, and you'll be asked to enter
their values:
```shell
bundle exec rake gitlab:docs:redirect
```
If you don't want to use the Rake task, you can use the following template.
However, the file paths must be relative to the `doc` or `docs` directory.
Replace the value of `redirect_to` with the new file path and `YYYY-MM-DD`
with the date the file should be removed.
Redirect files that link to docs in internal documentation projects
are removed after three months. Redirect files that link to external sites are
removed after one year:
```markdown
---
redirect_to: '../path/to/file/index.md'
redirect_to: '../newpath/to/file/index.md'
---
This document was moved to [another location](../path/to/file/index.md).
......@@ -205,27 +237,24 @@ To add a redirect:
<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page -->
```
Redirect files linking to docs in any of the internal documentations projects
are removed after three months. Redirect files linking to external sites are
removed after one year.
1. If the documentation page being moved has any Disqus comments, follow the steps
described in [Redirections for pages with Disqus comments](#redirections-for-pages-with-disqus-comments).
1. If a documentation page you're removing includes images that aren't used
1. Open a merge request with your changes. If a documentation page
you're removing includes images that aren't used
with any other documentation pages, be sure to use your merge request to delete
those images from the repository.
1. Assign the merge request to a technical writer for review and merge.
1. Search for links to the original documentation file. You must find and update all
links that point to the original documentation file:
1. Search for links to the old documentation file. You must find and update all
links that point to the old documentation file:
- In <https://gitlab.com/gitlab-com/www-gitlab-com>, search for full URLs:
`grep -r "docs.gitlab.com/ee/path/to/file.html" .`
- In <https://gitlab.com/gitlab-org/gitlab-docs/-/tree/master/content/_data>,
search the navigation bar configuration files for the path with `.html`:
`grep -r "path/to/file.html" .`
- In any of the four internal projects. This includes searching for links in the docs
- In any of the four internal projects, search for links in the docs
and codebase. Search for all variations, including full URL and just the path.
In macOS for example, go to the root directory of the `gitlab` project and run:
For example, go to the root directory of the `gitlab` project and run:
```shell
grep -r "docs.gitlab.com/ee/path/to/file.html" .
......
......@@ -302,7 +302,7 @@ end
Adding foreign key to `projects`:
We can use the `add_concurrenct_foreign_key` method in this case, as this helper method
We can use the `add_concurrent_foreign_key` method in this case, as this helper method
has the lock retries built into it.
```ruby
......
......@@ -22,13 +22,13 @@ a black-box testing framework for the API and the UI.
### Testing nightly builds
We run scheduled pipelines each night to test nightly builds created by Omnibus.
You can find these nightly pipelines at `https://gitlab.com/gitlab-org/quality/nightly/pipelines`
You can find these pipelines at <https://gitlab.com/gitlab-org/quality/nightly/pipelines>
(need Developer access permissions). Results are reported in the `#qa-nightly` Slack channel.
### Testing staging
We run scheduled pipelines each night to test staging.
You can find these nightly pipelines at `https://gitlab.com/gitlab-org/quality/staging/pipelines`
You can find these pipelines at <https://gitlab.com/gitlab-org/quality/staging/pipelines>
(need Developer access permissions). Results are reported in the `#qa-staging` Slack channel.
### Testing code in merge requests
......@@ -36,64 +36,63 @@ You can find these nightly pipelines at `https://gitlab.com/gitlab-org/quality/s
#### Using the `package-and-qa` job
It is possible to run end-to-end tests for a merge request, eventually being run in
a pipeline in the [`gitlab-qa-mirror`](https://gitlab.com/gitlab-org/gitlab-qa-mirror/) project,
by triggering the `package-and-qa` manual action in the `test` stage (not
a pipeline in the [`gitlab-org/gitlab-qa-mirror`](https://gitlab.com/gitlab-org/gitlab-qa-mirror) project,
by triggering the `package-and-qa` manual action in the `qa` stage (not
available for forks).
**This runs end-to-end tests against a custom CE and EE (with an Ultimate license)
Omnibus package built from your merge request's changes.**
**This runs end-to-end tests against a custom EE (with an Ultimate license)
Docker image built from your merge request's changes.**
Manual action that starts end-to-end tests is also available in merge requests
in [Omnibus GitLab](https://gitlab.com/gitlab-org/omnibus-gitlab).
Below you can read more about how to use it and how does it work.
Manual action that starts end-to-end tests is also available
in [`gitlab-org/omnibus-gitlab` merge requests](https://docs.gitlab.com/omnibus/build/team_member_docs.html#i-have-an-mr-in-the-omnibus-gitlab-project-and-want-a-package-or-docker-image-to-test-it).
#### How does it work?
Currently, we are using _multi-project pipeline_-like approach to run QA
Currently, we are using _multi-project pipeline_-like approach to run end-to-end
pipelines.
```mermaid
graph LR
A1 -.->|1. Triggers an omnibus-gitlab-mirror pipeline and wait for it to be done| A2
B2[`Trigger-qa` stage<br>`Trigger:qa-test` job] -.->|2. Triggers a gitlab-qa-mirror pipeline and wait for it to be done| A3
subgraph "gitlab-foss/gitlab pipeline"
A1[`test` stage<br>`package-and-qa` job]
graph TB
A1 -.->|once done, can be triggered| A2
A2 -.->|1. Triggers an `omnibus-gitlab-mirror` pipeline<br>and wait for it to be done| B1
B2[`Trigger-qa` stage<br>`Trigger:qa-test` job] -.->|2. Triggers a `gitlab-qa-mirror` pipeline<br>and wait for it to be done| C1
subgraph "`gitlab-org/gitlab` pipeline"
A1[`build-images` stage<br>`build-qa-image` and `build-assets-image` jobs]
A2[`qa` stage<br>`package-and-qa` job]
end
subgraph "omnibus-gitlab pipeline"
A2[`Trigger-docker` stage<br>`Trigger:gitlab-docker` job] -->|once done| B2
subgraph "`gitlab-org/build/omnibus-gitlab-mirror` pipeline"
B1[`Trigger-docker` stage<br>`Trigger:gitlab-docker` job] -->|once done| B2
end
subgraph "gitlab-qa-mirror pipeline"
A3>QA jobs run] -.->|3. Reports back the pipeline result to the `package-and-qa` job<br>and post the result on the original commit tested| A1
subgraph "`gitlab-org/gitlab-qa-mirror` pipeline"
C1>End-to-end jobs run]
end
```
1. Developer triggers a manual action, that can be found in GitLab merge
requests. This starts a chain of pipelines in multiple projects.
1. The script being executed triggers a pipeline in
[Omnibus GitLab Mirror](https://gitlab.com/gitlab-org/build/omnibus-gitlab-mirror)
and waits for the resulting status. We call this a _status attribution_.
1. GitLab packages are being built in the [Omnibus GitLab Mirror](https://gitlab.com/gitlab-org/build/omnibus-gitlab-mirror)
pipeline. Packages are then pushed to its Container Registry.
1. In the [`gitlab-org/gitlab` pipeline](https://gitlab.com/gitlab-org/gitlab):
1. Developer triggers the `package-and-qa` manual action (available once the `build-qa-image` and
`build-assets-image` jobs are done), that can be found in GitLab merge
requests. This starts a chain of pipelines in multiple projects.
1. The script being executed triggers a pipeline in
[`gitlab-org/build/omnibus-gitlab-mirror`](https://gitlab.com/gitlab-org/build/omnibus-gitlab-mirror)
and polls for the resulting status. We call this a _status attribution_.
1. When packages are ready, and available in the registry, a final step in the
[Omnibus GitLab Mirror](https://gitlab.com/gitlab-org/build/omnibus-gitlab-mirror) pipeline, triggers a new
GitLab QA pipeline (those with access can view them at `https://gitlab.com/gitlab-org/gitlab-qa-mirror/pipelines`). It also waits for a resulting status.
1. In the [`gitlab-org/build/omnibus-gitlab-mirror` pipeline](https://gitlab.com/gitlab-org/build/omnibus-gitlab-mirror):
1. Docker image is being built and pushed to its Container Registry.
1. Finally, the `Trigger:qa-test` job triggers a new end-to-end pipeline in
[`gitlab-org/gitlab-qa-mirror`](https://gitlab.com/gitlab-org/gitlab-qa-mirror/pipelines) and polls for the resulting status.
1. GitLab QA pulls images from the registry, spins-up containers and runs tests
against a test environment that has been just orchestrated by the `gitlab-qa`
tool.
1. In the [`gitlab-org/gitlab-qa-mirror` pipeline](https://gitlab.com/gitlab-org/gitlab-qa-mirror):
1. Container for the Docker image stored in the [`gitlab-org/build/omnibus-gitlab-mirror`](https://gitlab.com/gitlab-org/build/omnibus-gitlab-mirror) registry is spun-up.
1. End-to-end tests are run with the `gitlab-qa` executable, which spin up a container for the end-to-end image from the [`gitlab-org/gitlab`](https://gitlab.com/gitlab-org/gitlab) registry.
1. The result of the GitLab QA pipeline is being
propagated upstream, through Omnibus, back to the GitLab merge request.
1. The result of the [`gitlab-org/gitlab-qa-mirror` pipeline](https://gitlab.com/gitlab-org/gitlab-qa-mirror) is being
propagated upstream (through polling from upstream pipelines), through [`gitlab-org/build/omnibus-gitlab-mirror`](https://gitlab.com/gitlab-org/build/omnibus-gitlab-mirror), back to the [`gitlab-org/gitlab`](https://gitlab.com/gitlab-org/gitlab) merge request.
Please note, we plan to [add more specific information](https://gitlab.com/gitlab-org/quality/team-tasks/-/issues/156)
about the tests included in each job/scenario that runs in `gitlab-qa-mirror`.
about the tests included in each job/scenario that runs in `gitlab-org/gitlab-qa-mirror`.
#### With Pipeline for Merged Results
......
......@@ -978,7 +978,7 @@ Example aggregated metric entries:
```yaml
- name: example_metrics_union
operator: OR
events:
events:
- 'i_search_total'
- 'i_search_advanced'
- 'i_search_paid'
......@@ -1362,4 +1362,24 @@ bin/rake gitlab:usage_data:dump_sql_in_yaml > ~/Desktop/usage-metrics-2020-09-02
## Generating and troubleshooting usage ping
To get a usage ping, or to troubleshoot caching issues on your GitLab instance, please follow [instructions to generate usage ping](../../administration/troubleshooting/gitlab_rails_cheat_sheet.md#generate-usage-ping).
This activity is to be done via a detached screen session on a remote server.
Before you begin these steps, make sure the key is added to the SSH agent locally
with the `ssh-add` command.
### Triggering
1. Connect to bastion with agent forwarding: `$ ssh -A lb-bastion.gprd.gitlab.com`
1. Create named screen: `$ screen -S <username>_usage_ping_<date>`
1. Connect to console host: `$ ssh $USER-rails@console-01-sv-gprd.c.gitlab-production.internal`
1. Run `SubmitUsagePingService.new.execute`
1. Detach from screen: `ctrl + a, ctrl + d`
1. Exit from bastion: `$ exit`
### Verification (After approx 30 hours)
1. Reconnect to bastion: `$ ssh -A lb-bastion.gprd.gitlab.com`
1. Find your screen session: `$ screen -ls`
1. Attach to your screen session: `$ screen -x 14226.mwawrzyniak_usage_ping_2021_01_22`
1. Check the last payload in `raw_usage_data` table: `RawUsageData.last.payload`
1. Check the when the payload was sent: `RawUsageData.last.sent_at`
......@@ -6,93 +6,93 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Jira DVCS connector
If you're using GitLab.com and Jira Cloud, use the
[GitLab for Jira app](connect-app.md) unless you have a specific need for the DVCS Connector.
Use the Jira DVCS (distributed version control system) connector if you self-host
either your Jira instance or your GitLab instance, and you want to sync information
between them. If you use Jira Cloud and GitLab.com, you should use the
[GitLab for Jira app](connect-app.md) unless you specifically need the DVCS connector.
When configuring Jira DVCS Connector:
When you configure the Jira DVCS connector, make sure your GitLab and Jira instances
are accessible.
- If you are using self-managed GitLab, make sure your GitLab instance is accessible by Jira.
- If you're connecting to Jira Cloud, ensure your instance is accessible through the internet.
- If you are using Jira Server, make sure your instance is accessible however your network is set up.
- **Self-managed GitLab**: Your GitLab instance must be accessible by Jira.
- **Jira Cloud**: Your instance must be accessible through the internet.
- **Jira Server**: Your network must allow access to your instance.
## GitLab account configuration for DVCS
## Configure a GitLab application for DVCS
NOTE:
To ensure that regular user account maintenance doesn't impact your integration,
create and use a single-purpose `jira` user in GitLab.
We recommend you create and use a `jira` user in GitLab, and use the account only
for integration work. A separate account ensures regular account maintenance does not affect
your integration.
1. In GitLab, create a new application to allow Jira to connect with your GitLab account.
1. Sign in to the GitLab account that you want Jira to use to connect to GitLab.
1. In the top-right corner, select your avatar.
1. Select **Edit profile**.
1. In GitLab, [create a user](../../user/profile/account/create_accounts.md) for Jira to
use to connect to GitLab. For Jira to access all projects,
a user with [Administrator](../../user/permissions.md) permissions must
create the user.
1. In the top right corner, click the account's avatar, and select **Edit profile**.
1. In the left sidebar, select **Applications**.
1. In the **Name** field, enter a descriptive name for the integration, such as `Jira`.
1. In the **Redirect URI** field, enter `https://<gitlab.example.com>/login/oauth/callback`,
replacing `<gitlab.example.com>` with your GitLab instance domain. For example, if you are using GitLab.com,
this would be `https://gitlab.com/login/oauth/callback`.
NOTE:
If using a GitLab version earlier than 11.3, the `Redirect URI` must be
`https://<gitlab.example.com>/-/jira/login/oauth/callback`. If you want Jira
to have access to all projects, GitLab recommends that an administrator create the
application.
![GitLab application setup](img/jira_dev_panel_gl_setup_1.png)
1. Check **API** in the **Scopes** section, and clear any other checkboxes.
1. Click **Save application**. GitLab displays the generated **Application ID**
and **Secret** values. Copy these values, which you use in Jira.
## Jira DVCS Connector setup
If you're using GitLab.com and Jira Cloud, use the
[GitLab for Jira app](connect-app.md) unless you have a specific need for the DVCS Connector.
1. Ensure you have completed the [GitLab configuration](#gitlab-account-configuration-for-dvcs).
1. If you're using Jira Server, go to **Settings (gear) > Applications > DVCS accounts**.
If you're using Jira Cloud, go to **Settings (gear) > Products > DVCS accounts**.
1. Click **Link GitHub Enterprise account** to start creating a new integration.
(We're pretending to be GitHub in this integration, until there's additional platform support in Jira.)
1. Complete the form:
1. Select **GitHub Enterprise** for the **Host** field.
1. In the **Team or User Account** field, enter either:
1. In the **Redirect URI** field, enter the URI appropriate for your version of GitLab,
replacing `<gitlab.example.com>` with your GitLab instance domain:
- *For GitLab versions 11.3 and later,* use `https://<gitlab.example.com>/login/oauth/callback`.
If you use GitLab.com, the URL is `https://gitlab.com/login/oauth/callback`.
- *For GitLab versions 11.2 and earlier,* use
`https://<gitlab.example.com>/-/jira/login/oauth/callback`.
1. For **Scopes**, select `api` and clear any other checkboxes.
1. Select **Submit**.
1. GitLab displays the generated **Application ID**
and **Secret** values. Copy these values, as you need them to configure Jira.
## Configure Jira for DVCS
If you use Jira Cloud and GitLab.com, use the [GitLab for Jira app](connect-app.md)
unless you specifically need the DVCS Connector.
Configure this connection when you want to import all GitLab commits and branches,
for the groups you specify, into Jira. This import takes a few minutes and, after
it completes, refreshes every 60 minutes:
1. Ensure you have completed the [GitLab configuration](#configure-a-gitlab-application-for-dvcs).
1. Go to your DVCS account:
- *For Jira Server,* go to **Settings (gear) > Applications > DVCS accounts**.
- *For Jira Cloud,* go to **Settings (gear) > Products > DVCS accounts**.
1. To create a new integration, select the appropriate value for **Host**:
- *For Jira versions 8.14 and later:* Select **GitLab** or
<!-- vale gitlab.Substitutions = NO -->
**GitLab Self-Hosted**.
<!-- vale gitlab.Substitutions = YES -->
- *For Jira versions 8.13 and earlier:* Select **GitHub Enterprise**.
1. For **Team or User Account**, enter either:
- The relative path of a top-level GitLab group that you have access to.
- The relative path of your personal namespace.
![Creation of Jira DVCS integration](img/jira_dev_panel_jira_setup_2.png)
1. In the **Host URL** field, enter `https://<gitlab.example.com>/`,
replacing `<gitlab.example.com>` with your GitLab instance domain. For example, if you are using GitLab.com,
this would be `https://gitlab.com/`.
NOTE:
If using a GitLab version earlier than 11.3 the **Host URL** value should be `https://<gitlab.example.com>/-/jira`
1. For the **Client ID** field, use the **Application ID** value from the previous section.
1. For the **Client Secret** field, use the **Secret** value from the previous section.
1. In the **Host URL** field, enter the URI appropriate for your version of GitLab,
replacing `<gitlab.example.com>` with your GitLab instance domain:
- *For GitLab versions 11.3 and later,* use `https://<gitlab.example.com>/`.
- *For GitLab versions 11.2 and earlier,* use
`https://<gitlab.example.com>/-/jira`.
1. For **Client ID**, use the **Application ID** value from the previous section.
1. For **Client Secret**, use the **Secret** value from the previous section.
1. Ensure that the rest of the checkboxes are checked.
1. Select **Add** to complete and create the integration.
1. Click **Add** to complete and create the integration.
Jira takes up to a few minutes to know about (import behind the scenes) all the commits and branches
for all the projects in the GitLab group you specified in the previous step. These are refreshed
every 60 minutes.
To connect additional GitLab projects from other GitLab top-level groups, or
personal namespaces, repeat the previous steps with additional Jira DVCS accounts.
In the future, we plan on implementing real-time integration. If you need
to refresh the data manually, you can do this from the `Applications -> DVCS
accounts` screen where you initially set up the integration:
After you configure the integration, read more about [how to test and use it](index.md#usage).
![Refresh GitLab information in Jira](img/jira_dev_panel_manual_refresh.png)
## Refresh data imported to Jira
To connect additional GitLab projects from other GitLab top-level groups (or personal namespaces), repeat the previous
steps with additional Jira DVCS accounts.
Jira imports the commits and branches every 60 minutes for your projects. You
can refresh the data manually from the Jira interface:
Now that the integration is configured, read more about how to test and use it in [Usage](index.md#usage).
1. Sign in to your Jira instance as the user you configured the integration with.
1. Go to **Settings (gear) > Applications**.
1. Select **DVCS accounts**.
1. In the table, for the repository you want to refresh, in the **Last Activity**
column, select the icon:
![Refresh GitLab information in Jira](img/jira_dev_panel_manual_refresh.png)
## Troubleshooting your DVCS connection
......@@ -100,39 +100,46 @@ Refer to the items in this section if you're having problems with your DVCS conn
### Jira cannot access GitLab server
If you complete the **Add New Account** form, authorize access, and you receive
this error, Jira and GitLab cannot connect. No other error messages
appear in any logs:
```plaintext
Error obtaining access token. Cannot access https://gitlab.example.com from Jira.
```
This error message is generated in Jira, after completing the **Add New Account**
form and authorizing access. It indicates a connectivity issue from Jira to
GitLab. No other error messages appear in any logs.
### SSL and TLS problems
If there was an issue with SSL/TLS, this error message is generated.
Problems with SSL and TLS can cause this error message:
```plaintext
Error obtaining access token. Cannot access https://gitlab.example.com from Jira.
```
- The [GitLab Jira integration](../../user/project/integrations/jira.md) requires GitLab to connect to Jira. Any
TLS issues that arise from a private certificate authority or self-signed
certificate [are resolved on the GitLab server](https://docs.gitlab.com/omnibus/settings/ssl.html#other-certificate-authorities),
- The [GitLab Jira integration](../../user/project/integrations/jira.md) requires
GitLab to connect to Jira. Any TLS issues that arise from a private certificate
authority or self-signed certificate are resolved
[on the GitLab server](https://docs.gitlab.com/omnibus/settings/ssl.html#other-certificate-authorities),
as GitLab is the TLS client.
- The Jira Development panel integration requires Jira to connect to GitLab, which
causes Jira to be the TLS client. If your GitLab server's certificate is not
issued by a public certificate authority, the Java Truststore on Jira's server
needs to have the appropriate certificate added to it (such as your organization's
root certificate).
must have the appropriate certificate (such as your organization's
root certificate) added to it .
Refer to Atlassian's documentation and Atlassian Support for assistance setting up Jira correctly:
- [Adding a certificate to the trust store](https://confluence.atlassian.com/kb/how-to-import-a-public-ssl-certificate-into-a-jvm-867025849.html).
- Simplest approach is to use [`keytool`](https://docs.oracle.com/javase/8/docs/technotes/tools/unix/keytool.html).
- [Add a certificate](https://confluence.atlassian.com/kb/how-to-import-a-public-ssl-certificate-into-a-jvm-867025849.html)
to the trust store.
- The simplest approach is [`keytool`](https://docs.oracle.com/javase/8/docs/technotes/tools/unix/keytool.html).
- Add additional roots to Java's default Truststore (`cacerts`) to allow Jira to
also trust public certificate authorities.
- If the integration stops working after upgrading Jira's Java runtime, this
might be because the `cacerts` Truststore got replaced.
- If the integration stops working after upgrading Jira's Java runtime, the
`cacerts` Truststore may have been replaced during the upgrade.
- [Troubleshooting connectivity up to and including TLS handshaking](https://confluence.atlassian.com/kb/unable-to-connect-to-ssl-services-due-to-pkix-path-building-failed-error-779355358.html),
- Troubleshooting connectivity [up to and including TLS handshaking](https://confluence.atlassian.com/kb/unable-to-connect-to-ssl-services-due-to-pkix-path-building-failed-error-779355358.html),
using the a java class called `SSLPoke`.
- Download the class from Atlassian's knowledge base to Jira's server, for example to `/tmp`.
- Download the class from Atlassian's knowledge base to a directory on Jira's server, such as `/tmp`.
- Use the same Java runtime as Jira.
- Pass all networking-related parameters that Jira is called with, such as proxy
settings or an alternative root Truststore (`-Djavax.net.ssl.trustStore`):
......@@ -154,38 +161,42 @@ The requested scope is invalid, unknown, or malformed.
Potential resolutions:
- Verify the URL shown in the browser after being redirected from Jira in step 5 of [Jira DVCS Connector Setup](#jira-dvcs-connector-setup) includes `scope=api` in the query string.
- If `scope=api` is missing from the URL, return to [GitLab account configuration](#gitlab-account-configuration-for-dvcs) and ensure the application you created in step 1 has the `api` box checked under scopes.
1. Verify that the URL shown in the browser after being redirected from Jira in the
[Jira DVCS connector setup](#configure-jira-for-dvcs) includes `scope=api` in
the query string.
1. If `scope=api` is missing from the URL, edit the
[GitLab account configuration](#configure-a-gitlab-application-for-dvcs). Review
the **Scopes** field and ensure the `api` check box is selected.
### Jira error adding account and no repositories listed
```plaintext
Error!
Failed adding the account: [Error retrieving list of repositories]
```
After you complete the **Add New Account** form in Jira and authorize access, you might
encounter these issues:
This error message is generated in Jira after completing the **Add New Account**
form in Jira and authorizing access. Attempting to click **Try Again** returns
`Account is already integrated with JIRA.` The account is set up in the DVCS
accounts view, but no repositories are listed.
- An `Error! Failed adding the account: [Error retrieving list of repositories]` error.
- An `Account is already integrated with JIRA` error when you click **Try Again**.
- An account is visible in the DVCS accounts view, but no repositories are listed.
Potential resolutions:
To resolve this issue:
- If you're using GitLab versions 11.10-12.7, upgrade to GitLab 12.8.10 or later
to resolve an identified [issue](https://gitlab.com/gitlab-org/gitlab/-/issues/37012).
- If you're using GitLab Free, be sure you're using GitLab 13.4 or later.
- If you're using GitLab Free or GitLab Starter, be sure you're using
GitLab 13.4 or later.
- *If you're using GitLab versions 11.10-12.7,* upgrade to GitLab 12.8.10 or later
to resolve [an identified issue](https://gitlab.com/gitlab-org/gitlab/-/issues/37012).
[Contact GitLab Support](https://about.gitlab.com/support/) if none of these reasons apply.
### Fixing synchronization issues
### Fix synchronization issues
If Jira displays incorrect information (such as deleted branches), you may need to
If Jira displays incorrect information, such as deleted branches, you may need to
resynchronize the information. To do so:
1. In Jira, go to **Jira Administration > Applications > DVCS accounts**.
1. At the account (group or subgroup) level, Jira displays an option to
**Refresh repositories** in the `...` (ellipsis) menu.
**Refresh repositories** in the **{ellipsis_h}** (ellipsis) menu.
1. For each project, there's a sync button displayed next to the **last activity** date.
To perform a *soft resync*, click the button, or complete a *full sync* by shift clicking
the button. For more information, see
[Atlassian's documentation](https://support.atlassian.com/jira-cloud-administration/docs/synchronize-jira-cloud-to-bitbucket/).
- To perform a *soft resync*, click the button.
- To complete a *full sync*, shift-click the button.
For more information, read
[Atlassian's documentation](https://support.atlassian.com/jira-cloud-administration/docs/synchronize-jira-cloud-to-bitbucket/).
......@@ -18,10 +18,10 @@ to use this endpoint.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/245331) in GitLab Free 13.5.
With Maintainer or higher [permissions](../../user/permissions.md), you can view
the list of configured alerts integrations by navigating to
**Settings > Operations** in your project's sidebar menu, and expanding **Alerts** section.
The list displays the integration name, type, and status (enabled or disabled):
With Maintainer or higher [permissions](../../user/permissions.md),
you can view the list of configured alerts integrations by navigating to **Settings > Operations**
in your project's sidebar menu, and expanding the **Alert integrations** section. The list displays
the integration name, type, and status (enabled or disabled):
![Current Integrations](img/integrations_list_v13_5.png)
......@@ -39,9 +39,11 @@ receive alert payloads in JSON format. You can always
1. Sign in to GitLab as a user with maintainer [permissions](../../user/permissions.md)
for a project.
1. Navigate to **Settings > Operations** in your project.
1. Expand the **Alerts** section, and in the **Integration** dropdown menu, select **Generic**.
1. Toggle the **Active** alert setting to display the **URL** and **Authorization Key**
for the webhook configuration.
1. Expand the **Alert integrations** section, and in the **Select integration type** dropdown menu,
select **HTTP Endpoint**.
1. Toggle the **Active** alert setting. The URL and Authorization Key for the webhook configuration
are available in the **View credentials** tab after you save the integration. You must also input
the URL and Authorization Key in your external service.
### HTTP Endpoints **(PREMIUM)**
......@@ -54,11 +56,11 @@ and you can [customize the payload](#customize-the-alert-payload-outside-of-gitl
1. Sign in to GitLab as a user with maintainer [permissions](../../user/permissions.md)
for a project.
1. Navigate to **Settings > Operations** in your project.
1. Expand the **Alerts** section.
1. Expand the **Alert integrations** section.
1. For each endpoint you want to create:
1. Click the **Add new integration** button.
1. In the **Integration** dropdown menu, select **HTTP Endpoint**.
1. In the **Select integration type** dropdown menu, select **HTTP Endpoint**.
1. Name the integration.
1. Toggle the **Active** alert setting. The **URL** and **Authorization Key** for the webhook
configuration are available in the **View credentials** tab after you save the integration.
......@@ -85,7 +87,7 @@ correct information in the [Alert list](alerts.md) and the
[Alert Details page](alerts.md#alert-details-page), map your alert's fields to
GitLab fields when you [create an HTTP endpoint](#http-endpoints):
![Alert Management List](img/custom_alert_mapping_v13_10.png)
![Alert Management List](img/custom_alert_mapping_v13_11.png)
### External Prometheus integration
......@@ -165,9 +167,11 @@ alert to confirm your integration works properly.
1. Sign in as a user with Developer or greater [permissions](../../user/permissions.md).
1. Navigate to **Settings > Operations** in your project.
1. Click **Alerts endpoint** to expand the section.
1. Enter a sample payload in **Alert test payload** (valid JSON is required).
1. Click **Test alert payload**.
1. Click **Alert integrations** to expand the section.
1. Click the **{settings}** settings icon on the right side of the integration in [the list](#integrations-list).
1. Select the **Send test alert** tab to open it.
1. Enter a test payload in the payload field (valid JSON is required).
1. Click **Send**.
GitLab displays an error or success message, depending on the outcome of your test.
......
......@@ -13,7 +13,8 @@ responsibilities. Maintain the availability of your software services by putting
With an on-call schedule, your team is notified immediately when things go wrong so they can quickly
respond to service outages and disruptions.
To use on-call schedules, you must do the following:
To use on-call schedules, users with Maintainer [permissions](../../user/permissions.md)
must do the following:
1. [Create a schedule](#schedules).
1. [Add a rotation to the schedule](#rotations).
......
......@@ -221,7 +221,7 @@ to set the status for each alert:
By default, the list doesn't display resolved or dismissed alerts. To show these alerts, clear the
checkbox **Hide dismissed alerts**.
![Policy Alert List](img/threat_monitoring_policy_alert_list_v13_9.png)
![Policy Alert List](img/threat_monitoring_policy_alert_list_v13_11.png)
Clicking an alert's name takes the user to the [alert details page](../../../operations/incident_management/alerts.md#alert-details-page).
......
......@@ -66,6 +66,10 @@ The following resources are migrated to the target instance:
- due date
- created at
- updated at
- Badges ([Introduced in 13.11](https://gitlab.com/gitlab-org/gitlab/-/issues/292431))
- name
- link URL
- image URL
Any other items are **not** migrated.
......
......@@ -653,3 +653,15 @@ The group's new subgroups have push rules set for them based on either:
- [Lock the sharing with group feature](#prevent-a-project-from-being-shared-with-groups).
- [Enforce two-factor authentication (2FA)](../../security/two_factor_authentication.md#enforcing-2fa-for-all-users-in-a-group): Enforce 2FA
for all group members.
## Troubleshooting
### Verify if access is blocked by IP restriction
If a user sees a 404 when they would normally expect access, and the problem is limited to a specific group, search the `auth.log` rails log for one or more of the following:
- `json.message`: `'Attempting to access IP restricted group'`
- `json.allowed`: `false`
In viewing the log entries, compare the `remote.ip` with the list of
[allowed IPs](#restrict-group-access-by-ip-address) for the group.
......@@ -96,7 +96,9 @@ Please note that the certificate [fingerprint algorithm](../../../integration/sa
- [Improved](https://gitlab.com/gitlab-org/gitlab/-/issues/292811) in GitLab 13.8, with an updated timeout experience.
- [Improved](https://gitlab.com/gitlab-org/gitlab/-/issues/211962) in GitLab 13.8 with allowing group owners to not go through SSO.
With this option enabled, users must go through your group's GitLab single sign-on URL. They may also be added via SCIM, if configured. Users can't be added manually, and may only access project/group resources via the UI by signing in through the SSO URL.
With this option enabled, users must go through your group's GitLab single sign-on URL if they wish to access group resources through the UI. Users can't be manually added as members.
SSO enforcement does not affect sign in or access to any resources outside of the group. Users can view which groups and projects they are a member of without SSO sign in.
However, users are not prompted to sign in through SSO on each visit. GitLab checks whether a user
has authenticated through SSO. If it's been more than 1 day since the last sign-in, GitLab
......
<script>
import { GlLink, GlTable } from '@gitlab/ui';
import { GlAlert, GlLink, GlTable } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import FeatureStatus from './feature_status.vue';
import ManageFeature from './manage_feature.vue';
......@@ -9,6 +9,7 @@ const thClass = `gl-text-gray-900 gl-bg-transparent! ${borderClasses}`;
export default {
components: {
GlAlert,
GlLink,
GlTable,
FeatureStatus,
......@@ -35,12 +36,20 @@ export default {
default: '',
},
},
data() {
return {
errorMessage: '',
};
},
methods: {
getFeatureDocumentationLinkLabel(item) {
return sprintf(s__('SecurityConfiguration|Feature documentation for %{featureName}'), {
return sprintf(this.$options.i18n.docsLinkLabel, {
featureName: item.name,
});
},
onError(value) {
this.errorMessage = value;
},
},
fields: [
{
......@@ -59,42 +68,51 @@ export default {
thClass,
},
],
i18n: {
docsLinkLabel: s__('SecurityConfiguration|Feature documentation for %{featureName}'),
docsLinkText: s__('SecurityConfiguration|More information'),
},
};
</script>
<template>
<gl-table
:items="features"
:fields="$options.fields"
stacked="md"
:tbody-tr-attr="{ 'data-testid': 'security-scanner-row' }"
>
<template #cell(description)="{ item }">
<div class="gl-text-gray-900">{{ item.name }}</div>
<div>
{{ item.description }}
<gl-link
target="_blank"
:href="item.helpPath"
:aria-label="getFeatureDocumentationLinkLabel(item)"
>
{{ s__('SecurityConfiguration|More information') }}
</gl-link>
</div>
</template>
<div>
<gl-alert v-if="errorMessage" variant="danger" :dismissible="false">
{{ errorMessage }}
</gl-alert>
<gl-table
:items="features"
:fields="$options.fields"
stacked="md"
:tbody-tr-attr="{ 'data-testid': 'security-scanner-row' }"
>
<template #cell(description)="{ item }">
<div class="gl-text-gray-900">{{ item.name }}</div>
<div>
{{ item.description }}
<gl-link
target="_blank"
:href="item.helpPath"
:aria-label="getFeatureDocumentationLinkLabel(item)"
>
{{ $options.i18n.docsLinkText }}
</gl-link>
</div>
</template>
<template #cell(status)="{ item }">
<feature-status
:feature="item"
:gitlab-ci-present="gitlabCiPresent"
:gitlab-ci-history-path="gitlabCiHistoryPath"
:auto-devops-enabled="autoDevopsEnabled"
:data-qa-selector="`${item.type}_status`"
/>
</template>
<template #cell(status)="{ item }">
<feature-status
:feature="item"
:gitlab-ci-present="gitlabCiPresent"
:gitlab-ci-history-path="gitlabCiHistoryPath"
:auto-devops-enabled="autoDevopsEnabled"
:data-qa-selector="`${item.type}_status`"
/>
</template>
<template #cell(manage)="{ item }">
<manage-feature :feature="item" />
</template>
</gl-table>
<template #cell(manage)="{ item }">
<manage-feature :feature="item" @error="onError" />
</template>
</gl-table>
</div>
</template>
import { s__ } from '~/locale';
import { REPORT_TYPE_DEPENDENCY_SCANNING } from '~/vue_shared/security_reports/constants';
import configureDependencyScanningMutation from '../graphql/configure_dependency_scanning.mutation.graphql';
export const SMALL = 'SMALL';
export const MEDIUM = 'MEDIUM';
......@@ -15,3 +17,10 @@ export const SCHEMA_TO_PROP_SIZE_MAP = {
export const CUSTOM_VALUE_MESSAGE = s__(
"SecurityConfiguration|Using custom settings. You won't receive automatic updates on this variable. %{anchorStart}Restore to default%{anchorEnd}",
);
export const featureToMutationMap = {
[REPORT_TYPE_DEPENDENCY_SCANNING]: {
type: 'configureDependencyScanning',
mutation: configureDependencyScanningMutation,
},
};
<script>
import { propsUnion } from '~/vue_shared/components/lib/utils/props_utils';
import { REPORT_TYPE_DAST_PROFILES } from '~/vue_shared/security_reports/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
REPORT_TYPE_DAST_PROFILES,
REPORT_TYPE_DEPENDENCY_SCANNING,
} from '~/vue_shared/security_reports/constants';
import ManageDastProfiles from './manage_dast_profiles.vue';
import ManageGeneric from './manage_generic.vue';
import ManageViaMr from './manage_via_mr.vue';
const scannerComponentMap = {
[REPORT_TYPE_DAST_PROFILES]: ManageDastProfiles,
[REPORT_TYPE_DEPENDENCY_SCANNING]: ManageViaMr,
};
export default {
mixins: [glFeatureFlagMixin()],
props: propsUnion([ManageGeneric, ...Object.values(scannerComponentMap)]),
computed: {
filteredScannerComponentMap() {
const scannerComponentMapCopy = { ...scannerComponentMap };
if (!this.glFeatures.secDependencyScanningUiEnable) {
delete scannerComponentMapCopy[REPORT_TYPE_DEPENDENCY_SCANNING];
}
return scannerComponentMapCopy;
},
manageComponent() {
return scannerComponentMap[this.feature.type] ?? ManageGeneric;
return this.filteredScannerComponentMap[this.feature.type] ?? ManageGeneric;
},
},
};
</script>
<template>
<component :is="manageComponent" v-bind="$props" />
<component :is="manageComponent" v-bind="$props" @error="$emit('error', $event)" />
</template>
<script>
import { GlButton } from '@gitlab/ui';
import { redirectTo } from '~/lib/utils/url_utility';
import { sprintf, s__ } from '~/locale';
import apolloProvider from '../graphql/provider';
import { featureToMutationMap } from './constants';
export default {
apolloProvider,
components: {
GlButton,
},
inject: {
projectPath: {
from: 'projectPath',
default: '',
},
},
props: {
feature: {
type: Object,
required: true,
},
},
data() {
return {
isLoading: false,
};
},
computed: {
featureSettings() {
return featureToMutationMap[this.feature.type];
},
},
methods: {
async mutate() {
this.isLoading = true;
try {
const { data } = await this.$apollo.mutate({
mutation: this.featureSettings.mutation,
variables: {
fullPath: this.projectPath,
},
});
const { errors, successPath } = data[this.featureSettings.type];
if (errors.length > 0) {
throw new Error(errors[0]);
}
if (!successPath) {
throw new Error(
sprintf(this.$options.i18n.noSuccessPathError, { featureName: this.feature.name }),
);
}
redirectTo(successPath);
} catch (e) {
this.$emit('error', e.message);
this.isLoading = false;
}
},
},
i18n: {
buttonLabel: s__('SecurityConfiguration|Configure via Merge Request'),
noSuccessPathError: s__(
'SecurityConfiguration|%{featureName} merge request creation mutation failed',
),
},
};
</script>
<template>
<gl-button
v-if="!feature.configured"
:loading="isLoading"
variant="success"
category="secondary"
@click="mutate"
>{{ $options.i18n.buttonLabel }}</gl-button
>
</template>
mutation configureDependencyScanning($fullPath: ID!) {
configureDependencyScanning(fullPath: $fullPath) {
successPath
errors
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export default new VueApollo({
defaultClient: createDefaultClient(),
});
<script>
import { GlFormCheckbox, GlFormGroup, GlSearchBoxByType } from '@gitlab/ui';
import { s__ } from '~/locale';
import { DEBOUNCE, DEFAULT_FILTERS } from './constants';
import {
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlFormGroup,
GlIcon,
GlSearchBoxByType,
GlTruncate,
} from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import { ALL, DEBOUNCE, STATUSES } from './constants';
export default {
ALL,
DEBOUNCE,
DEFAULT_DISMISSED_FILTER: true,
components: { GlFormCheckbox, GlFormGroup, GlSearchBoxByType },
components: {
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlFormGroup,
GlIcon,
GlSearchBoxByType,
GlTruncate,
},
props: {
filters: {
type: Object,
required: false,
default: () => {},
default: () => ({}),
},
},
data() {
......@@ -20,46 +37,107 @@ export default {
};
},
i18n: {
STATUSES,
HIDE_DISMISSED_TITLE: s__('ThreatMonitoring|Hide dismissed alerts'),
POLICY_NAME_FILTER_PLACEHOLDER: s__('NetworkPolicy|Search by policy name'),
POLICY_NAME_FILTER_TITLE: s__('NetworkPolicy|Policy'),
POLICY_STATUS_FILTER_TITLE: s__('NetworkPolicy|Status'),
},
computed: {
extraOptionCount() {
const numOfStatuses = this.filters.statuses?.length || 0;
return numOfStatuses > 0 ? numOfStatuses - 1 : 0;
},
firstSelectedOption() {
const firstOption = this.filters.statuses?.length ? this.filters.statuses[0] : undefined;
return this.$options.i18n.STATUSES[firstOption] || this.$options.ALL.value;
},
extraOptionText() {
return sprintf(__('+%{extra} more'), { extra: this.extraOptionCount });
},
},
methods: {
changeDismissedFilter(filtered) {
const newFilters = filtered ? DEFAULT_FILTERS : { statuses: [] };
this.handleFilterChange(newFilters);
handleFilterChange(newFilters) {
this.$emit('filter-change', { ...this.filters, ...newFilters });
},
handleSearch(searchTerm) {
handleNameFilter(searchTerm) {
const newFilters = { searchTerm };
this.handleFilterChange(newFilters);
},
handleFilterChange(newFilters) {
this.$emit('filter-change', { ...this.filters, ...newFilters });
handleStatusFilter(status) {
let newFilters;
if (status === this.$options.ALL.key) {
newFilters = { statuses: [] };
} else {
newFilters = this.isChecked(status)
? { statuses: [...this.filters.statuses.filter((s) => s !== status)] }
: { statuses: [...this.filters.statuses, status] };
}
// If all statuses are selected, select the 'All' option
if (newFilters.statuses.length === Object.entries(STATUSES).length) {
newFilters = { statuses: [] };
}
this.handleFilterChange(newFilters);
},
isChecked(status) {
if (status === this.$options.ALL.key) {
return !this.filters.statuses?.length;
}
return this.filters.statuses?.includes(status);
},
},
};
</script>
<template>
<div
class="gl-p-4 gl-bg-gray-10 gl-display-flex gl-justify-content-space-between gl-align-items-center"
>
<div>
<h5 class="gl-mt-0">{{ $options.i18n.POLICY_NAME_FILTER_TITLE }}</h5>
<div class="gl-p-4 gl-bg-gray-10 gl-display-flex gl-align-items-center">
<gl-form-group :label="$options.i18n.POLICY_NAME_FILTER_TITLE" label-size="sm" class="gl-mb-0">
<gl-search-box-by-type
:debounce="$options.DEBOUNCE"
:placeholder="$options.i18n.POLICY_NAME_FILTER_PLACEHOLDER"
@input="handleSearch"
@input="handleNameFilter"
/>
</div>
<gl-form-group label-size="sm" class="gl-mb-0">
<gl-form-checkbox
class="gl-mt-3"
:checked="$options.DEFAULT_DISMISSED_FILTER"
@change="changeDismissedFilter"
>
{{ $options.i18n.HIDE_DISMISSED_TITLE }}
</gl-form-checkbox>
</gl-form-group>
<gl-form-group
:label="$options.i18n.POLICY_STATUS_FILTER_TITLE"
label-size="sm"
class="gl-mb-0 col-sm-6 col-md-4 col-lg-2"
data-testid="policy-alert-status-filter"
>
<gl-dropdown toggle-class="gl-inset-border-1-gray-400!" class="gl-w-full">
<template #button-content>
<gl-truncate :text="firstSelectedOption" class="gl-min-w-0 gl-mr-2" />
<span v-if="extraOptionCount > 0" class="gl-mr-2">
{{ extraOptionText }}
</span>
<gl-icon name="chevron-down" class="gl-flex-shrink-0 gl-ml-auto" />
</template>
<gl-dropdown-item
key="All"
data-testid="ALL"
:is-checked="isChecked($options.ALL.key)"
is-check-item
@click="handleStatusFilter($options.ALL.key)"
>
{{ $options.ALL.value }}
</gl-dropdown-item>
<gl-dropdown-divider />
<template v-for="[status, translated] in Object.entries($options.i18n.STATUSES)">
<gl-dropdown-item
:key="status"
:data-testid="status"
:is-checked="isChecked(status)"
is-check-item
@click="handleStatusFilter(status)"
>
{{ translated }}
</gl-dropdown-item>
</template>
</gl-dropdown>
</gl-form-group>
</div>
</template>
import { s__ } from '~/locale';
import { s__, __ } from '~/locale';
export const MESSAGES = {
CONFIGURE: s__(
......@@ -58,3 +58,5 @@ export const DEFAULT_FILTERS = { statuses: ['TRIGGERED', 'ACKNOWLEDGED'] };
export const DOMAIN = 'threat_monitoring';
export const DEBOUNCE = 250;
export const ALL = { key: 'ALL', value: __('All') };
......@@ -15,6 +15,7 @@ module EE
before_action only: [:show] do
push_frontend_feature_flag(:security_auto_fix, project, default_enabled: false)
push_frontend_feature_flag(:api_fuzzing_configuration_ui, project, default_enabled: :yaml)
push_frontend_feature_flag(:sec_dependency_scanning_ui_enable, project, default_enabled: :yaml)
end
before_action only: [:auto_fix] do
......
......@@ -32,15 +32,20 @@ module EE
field :vulnerabilities_count_by_day,
::Types::VulnerabilitiesCountByDayType.connection_type,
null: true,
description: "Number of vulnerabilities per day for the projects on the current user's instance security dashboard.",
resolver: ::Resolvers::VulnerabilitiesCountPerDayResolver
resolver: ::Resolvers::VulnerabilitiesCountPerDayResolver,
description: <<~DESC
Number of vulnerabilities per day for the projects on the current user's instance security dashboard.
DESC
field :vulnerabilities_count_by_day_and_severity,
::Types::VulnerabilitiesCountByDayAndSeverityType.connection_type,
null: true,
description: "Number of vulnerabilities per severity level, per day, for the projects on the current user's instance security dashboard.",
resolver: ::Resolvers::VulnerabilitiesHistoryResolver,
deprecated: { reason: :discouraged, replacement: 'Query.vulnerabilitiesCountByDay', milestone: '13.3' }
deprecated: { reason: :discouraged, replacement: 'Query.vulnerabilitiesCountByDay', milestone: '13.3' },
description: <<~DESC
Number of vulnerabilities per severity level, per day, for the projects on the
current user's instance security dashboard.
DESC
field :geo_node, ::Types::Geo::GeoNodeType,
null: true,
......
......@@ -25,10 +25,14 @@ module EE
controllers = %w(issues_analytics#show)
if @group&.feature_available?(:iterations)
controllers = %w(iterations#index)
controllers = iterations_sub_menu_controllers
end
super.concat(controllers)
end
def iterations_sub_menu_controllers
['iterations#index', 'iterations#show']
end
end
end
- if group_sidebar_link?(:iterations)
= nav_link(path: 'iterations#index') do
= nav_link(path: iterations_sub_menu_controllers) do
= link_to group_iterations_path(@group), data: { qa_selector: 'group_iterations_link' } do
%span
= _('Iterations')
---
title: Create status filter on policy alerts
merge_request: 57538
author:
type: changed
---
title: Expand left sidebar when viewing group iteration report
merge_request: 56358
author:
type: fixed
---
name: sec_dependency_scanning_ui_enable
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57496
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/326005
milestone: '13.11'
type: development
group: group::composition analysis
default_enabled: false
......@@ -33,6 +33,12 @@ RSpec.describe 'User views iteration' do
end
it 'shows iteration info' do
aggregate_failures 'expect Iterations highlighted on left sidebar' do
page.within '.qa-group-sidebar' do
expect(page).to have_css('li.active > a', text: 'Iterations')
end
end
aggregate_failures 'expect title, description, and dates' do
expect(page).to have_content(iteration.title)
expect(page).to have_content(iteration.description)
......
......@@ -14,7 +14,6 @@ RSpec.describe 'Group or Project invitations' do
allow(::Gitlab).to receive(:dev_env_or_com?).and_return(dev_env_or_com)
visit invite_path(group_invite.raw_invite_token)
click_link 'Register now'
end
def fill_in_sign_up_form(user)
......
const buildConfigureDependencyScanningMock = ({
successPath = 'testSuccessPath',
errors = [],
} = {}) => ({
data: {
configureDependencyScanning: {
successPath,
errors,
__typename: 'ConfigureDependencyScanningPayload',
},
},
});
export const configureDependencyScanningSuccess = buildConfigureDependencyScanningMock();
export const configureDependencyScanningNoSuccessPath = buildConfigureDependencyScanningMock({
successPath: '',
});
export const configureDependencyScanningError = buildConfigureDependencyScanningMock({
errors: ['foo'],
});
import { GlLink } from '@gitlab/ui';
import { GlAlert, GlLink } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import ConfigurationTable from 'ee/security_configuration/components/configuration_table.vue';
import FeatureStatus from 'ee/security_configuration/components/feature_status.vue';
......@@ -41,6 +41,7 @@ describe('ConfigurationTable component', () => {
});
};
const getTable = () => wrapper.find('table');
const getRows = () => wrapper.findAll('tbody tr');
const getRowCells = (row) => {
const [description, status, manage] = row.findAll('td').wrappers;
......@@ -53,8 +54,7 @@ describe('ConfigurationTable component', () => {
it.each(mockFeatures)('renders the feature %p correctly', (feature) => {
createComponent({ features: [feature] });
expect(wrapper.classes('b-table-stacked-md')).toBeTruthy();
expect(getTable().classes('b-table-stacked-md')).toBe(true);
const rows = getRows();
expect(rows).toHaveLength(1);
......@@ -70,4 +70,16 @@ describe('ConfigurationTable component', () => {
expect(manage.find(ManageFeature).props()).toEqual({ feature });
expect(description.find(GlLink).attributes('href')).toBe(feature.helpPath);
});
it('catches errors and displays them in an alert', async () => {
const error = 'error message';
createComponent({ features: mockFeatures });
const firstRow = getRows().at(0);
await firstRow.findComponent(ManageFeature).vm.$emit('error', error);
const alert = wrapper.findComponent(GlAlert);
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(error);
});
});
......@@ -2,7 +2,11 @@ import { shallowMount } from '@vue/test-utils';
import ManageDastProfiles from 'ee/security_configuration/components/manage_dast_profiles.vue';
import ManageFeature from 'ee/security_configuration/components/manage_feature.vue';
import ManageGeneric from 'ee/security_configuration/components/manage_generic.vue';
import { REPORT_TYPE_DAST_PROFILES } from '~/vue_shared/security_reports/constants';
import ManageViaMr from 'ee/security_configuration/components/manage_via_mr.vue';
import {
REPORT_TYPE_DAST_PROFILES,
REPORT_TYPE_DEPENDENCY_SCANNING,
} from '~/vue_shared/security_reports/constants';
import { generateFeatures } from './helpers';
const attrs = {
......@@ -13,7 +17,14 @@ describe('ManageFeature component', () => {
let wrapper;
const createComponent = (options) => {
wrapper = shallowMount(ManageFeature, options);
wrapper = shallowMount(ManageFeature, {
provide: {
glFeatures: {
secDependencyScanningUiEnable: true,
},
},
...options,
});
};
afterEach(() => {
......@@ -29,12 +40,20 @@ describe('ManageFeature component', () => {
it('passes through attributes to the expected component', () => {
expect(wrapper.attributes()).toMatchObject(attrs);
});
it('re-emits caught errors', () => {
const component = wrapper.findComponent(ManageGeneric);
component.vm.$emit('error', 'testerror');
expect(wrapper.emitted('error')).toEqual([['testerror']]);
});
});
describe.each`
type | expectedComponent
${REPORT_TYPE_DAST_PROFILES} | ${ManageDastProfiles}
${'foo'} | ${ManageGeneric}
type | expectedComponent
${REPORT_TYPE_DAST_PROFILES} | ${ManageDastProfiles}
${REPORT_TYPE_DEPENDENCY_SCANNING} | ${ManageViaMr}
${'foo'} | ${ManageGeneric}
`('given a $type feature', ({ type, expectedComponent }) => {
let feature;
let component;
......@@ -43,7 +62,6 @@ describe('ManageFeature component', () => {
[feature] = generateFeatures(1, { type });
createComponent({ propsData: { feature } });
component = wrapper.findComponent(expectedComponent);
});
......@@ -55,4 +73,21 @@ describe('ManageFeature component', () => {
expect(component.props()).toEqual({ feature });
});
});
it.each`
type | featureFlag
${REPORT_TYPE_DEPENDENCY_SCANNING} | ${'secDependencyScanningUiEnable'}
`('renders generic component for $type if $featureFlag is disabled', ({ type, featureFlag }) => {
const [feature] = generateFeatures(1, { type });
createComponent({
propsData: { feature },
provide: {
glFeatures: {
[featureFlag]: false,
},
},
});
expect(wrapper.findComponent(ManageGeneric).exists()).toBe(true);
});
});
import { GlButton } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import ManageViaMr from 'ee/security_configuration/components/manage_via_mr.vue';
import configureDependencyScanningMutation from 'ee/security_configuration/graphql/configure_dependency_scanning.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { redirectTo } from '~/lib/utils/url_utility';
import { REPORT_TYPE_DEPENDENCY_SCANNING } from '~/vue_shared/security_reports/constants';
import {
configureDependencyScanningSuccess,
configureDependencyScanningNoSuccessPath,
configureDependencyScanningError,
} from './apollo_mocks';
jest.mock('~/lib/utils/url_utility');
Vue.use(VueApollo);
describe('ManageViaMr component', () => {
let wrapper;
const findButton = () => wrapper.findComponent(GlButton);
const successHandler = async () => configureDependencyScanningSuccess;
const noSuccessPathHandler = async () => configureDependencyScanningNoSuccessPath;
const errorHandler = async () => configureDependencyScanningError;
const pendingHandler = () => new Promise(() => {});
function createMockApolloProvider(handler) {
const requestHandlers = [[configureDependencyScanningMutation, handler]];
return createMockApollo(requestHandlers);
}
function createComponent({ mockApollo, isFeatureConfigured = false } = {}) {
wrapper = extendedWrapper(
mount(ManageViaMr, {
apolloProvider: mockApollo,
propsData: {
feature: {
name: 'Dependency Scanning',
configured: isFeatureConfigured,
type: REPORT_TYPE_DEPENDENCY_SCANNING,
},
},
}),
);
}
afterEach(() => {
wrapper.destroy();
});
describe('when feature is configured', () => {
beforeEach(() => {
const mockApollo = createMockApolloProvider(successHandler);
createComponent({ mockApollo, isFeatureConfigured: true });
});
it('it does not render a button', () => {
expect(findButton().exists()).toBe(false);
});
});
describe('when feature is not configured', () => {
beforeEach(() => {
const mockApollo = createMockApolloProvider(successHandler);
createComponent({ mockApollo, isFeatureConfigured: false });
});
it('it does render a button', () => {
expect(findButton().exists()).toBe(true);
});
});
describe('given a pending response', () => {
beforeEach(() => {
const mockApollo = createMockApolloProvider(pendingHandler);
createComponent({ mockApollo });
});
it('renders spinner correctly', async () => {
const button = findButton();
expect(button.props('loading')).toBe(false);
await button.trigger('click');
expect(button.props('loading')).toBe(true);
});
});
describe('given a successful response', () => {
beforeEach(() => {
const mockApollo = createMockApolloProvider(successHandler);
createComponent({ mockApollo });
});
it('should call redirect helper with correct value', async () => {
await wrapper.trigger('click');
await waitForPromises();
expect(redirectTo).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith('testSuccessPath');
// This is done for UX reasons. If the loading prop is set to false
// on success, then there's a period where the button is clickable
// again. Instead, we want the button to display a loading indicator
// for the remainder of the lifetime of the page (i.e., until the
// browser can start painting the new page it's been redirected to).
expect(findButton().props().loading).toBe(true);
});
});
describe.each`
handler | message
${noSuccessPathHandler} | ${'Dependency Scanning merge request creation mutation failed'}
${errorHandler} | ${'foo'}
`('given an error response', ({ handler, message }) => {
beforeEach(() => {
const mockApollo = createMockApolloProvider(handler);
createComponent({ mockApollo });
});
it('should catch and emit error', async () => {
await wrapper.trigger('click');
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[message]]);
expect(findButton().props('loading')).toBe(false);
});
});
});
import { GlFormCheckbox, GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import AlertFilters from 'ee/threat_monitoring/components/alerts/alert_filters.vue';
import { DEFAULT_FILTERS } from 'ee/threat_monitoring/components/alerts/constants';
import { ALL, DEFAULT_FILTERS, STATUSES } from 'ee/threat_monitoring/components/alerts/constants';
import { trimText } from 'helpers/text_helper';
describe('AlertFilters component', () => {
let wrapper;
const findGlFormCheckbox = () => wrapper.find(GlFormCheckbox);
const findGlSearch = () => wrapper.find(GlSearchBoxByType);
const findDropdownItemAtIndex = (index) => wrapper.findAll(GlDropdownItem).at(index);
const clickDropdownItemAtIndex = (index) => findDropdownItemAtIndex(index).vm.$emit('click');
const findSearch = () => wrapper.findComponent(GlSearchBoxByType);
const findDropdownMessage = () =>
wrapper.find('[data-testid="policy-alert-status-filter"] .dropdown button').text();
const createWrapper = (filters = DEFAULT_FILTERS) => {
wrapper = shallowMount(AlertFilters, { propsData: { filters } });
const createWrapper = ({ filters = DEFAULT_FILTERS, method = shallowMount } = {}) => {
wrapper = method(AlertFilters, { propsData: { filters } });
};
afterEach(() => {
......@@ -20,19 +24,19 @@ describe('AlertFilters component', () => {
describe('Policy Name Filter', () => {
beforeEach(() => {
createWrapper();
createWrapper({});
});
describe('default state', () => {
it('shows policy name search box', () => {
const search = findGlSearch();
const search = findSearch();
expect(search.exists()).toBe(true);
expect(search.attributes('value')).toBe('');
});
it('does emit an event with a user-defined string', async () => {
const searchTerm = 'abc';
const search = findGlSearch();
const search = findSearch();
search.vm.$emit('input', searchTerm);
await wrapper.vm.$nextTick();
expect(wrapper.emitted('filter-change')).toStrictEqual([
......@@ -42,32 +46,58 @@ describe('AlertFilters component', () => {
});
});
describe('Hide Dismissed Filter', () => {
describe('default state', () => {
it('"hide dismissed checkbox" is checked', () => {
createWrapper();
const checkbox = findGlFormCheckbox();
expect(checkbox.exists()).toBe(true);
expect(checkbox.attributes('checked')).toBeTruthy();
});
describe('Status Filter', () => {
it('Displays the "All" status if no statuses are selected', () => {
createWrapper({ method: mount, filters: { statuses: [] } });
expect(findDropdownMessage()).toBe(ALL.value);
});
describe('dismissed alerts filter', () => {
it('emits an event with no filters on filter deselect', async () => {
createWrapper();
const checkbox = findGlFormCheckbox();
checkbox.vm.$emit('change', false);
await wrapper.vm.$nextTick();
expect(wrapper.emitted('filter-change')).toStrictEqual([[{ statuses: [] }]]);
});
it('Displays the status if only one status is selected', () => {
const status = 'TRIGGERED';
const translated = STATUSES[status];
createWrapper({ method: mount, filters: { statuses: [status] } });
expect(findDropdownMessage()).toBe(translated);
});
it('emits an event with the default filters on filter select', async () => {
createWrapper({});
const checkbox = findGlFormCheckbox();
checkbox.vm.$emit('change', true);
await wrapper.vm.$nextTick();
expect(wrapper.emitted('filter-change')).toEqual([[DEFAULT_FILTERS]]);
it('Displays the additional text if more than one status is selected', () => {
const status = 'TRIGGERED';
const translated = STATUSES[status];
createWrapper({ method: mount });
expect(trimText(findDropdownMessage())).toBe(`${translated} +1 more`);
});
it('Emits an event with the new filters on deselect', async () => {
createWrapper({});
clickDropdownItemAtIndex(2);
expect(wrapper.emitted('filter-change')).toHaveLength(1);
expect(wrapper.emitted('filter-change')[0][0]).toStrictEqual({ statuses: ['TRIGGERED'] });
});
it('Emits an event with the new filters on a select', () => {
createWrapper({});
clickDropdownItemAtIndex(4);
expect(wrapper.emitted('filter-change')).toHaveLength(1);
expect(wrapper.emitted('filter-change')[0][0]).toStrictEqual({
statuses: ['TRIGGERED', 'ACKNOWLEDGED', 'IGNORED'],
});
});
it('Emits an event with no filters on a select of all the filters', () => {
const MOST_STATUSES = [...Object.keys(STATUSES)].slice(1);
createWrapper({ filters: { statuses: MOST_STATUSES } });
clickDropdownItemAtIndex(1);
expect(wrapper.emitted('filter-change')).toHaveLength(1);
expect(wrapper.emitted('filter-change')[0][0]).toStrictEqual({ statuses: [] });
});
it('Checks "All" filter if no statuses are selected', () => {
createWrapper({ filters: { statuses: [] } });
expect(findDropdownItemAtIndex(0).props('isChecked')).toBe(true);
});
it('Unchecks "All" filter if a status is selected', () => {
createWrapper({});
expect(findDropdownItemAtIndex(0).props('isChecked')).toBe(false);
});
});
});
......@@ -23,7 +23,7 @@ module BulkImports
resource_url(resource),
headers: request_headers,
follow_redirects: false,
query: query.merge(request_query)
query: query.reverse_merge(request_query)
)
end
end
......
# frozen_string_literal: true
module BulkImports
module Common
module Extractors
class RestExtractor
def initialize(options = {})
@query = options[:query]
end
def extract(context)
client = http_client(context.configuration)
params = query.to_h(context)
response = client.get(params[:resource], params[:query])
BulkImports::Pipeline::ExtractedData.new(
data: response.parsed_response,
page_info: page_info(response.headers)
)
end
private
attr_reader :query
def http_client(configuration)
@http_client ||= BulkImports::Clients::Http.new(
uri: configuration.url,
token: configuration.access_token,
per_page: 100
)
end
def page_info(headers)
next_page = headers['x-next-page']
{
'has_next_page' => next_page.present?,
'next_page' => next_page
}
end
end
end
end
end
# frozen_string_literal: true
module BulkImports
module Groups
module Pipelines
class BadgesPipeline
include Pipeline
extractor BulkImports::Common::Extractors::RestExtractor,
query: BulkImports::Groups::Rest::GetBadgesQuery
transformer Common::Transformers::ProhibitedAttributesTransformer
def transform(_, data)
return if data.blank?
{
name: data['name'],
link_url: data['link_url'],
image_url: data['image_url']
}
end
def load(context, data)
return if data.blank?
context.group.badges.create!(data)
end
end
end
end
end
# frozen_string_literal: true
module BulkImports
module Groups
module Rest
module GetBadgesQuery
extend self
def to_h(context)
encoded_full_path = ERB::Util.url_encode(context.entity.source_full_path)
{
resource: ['groups', encoded_full_path, 'badges'].join('/'),
query: {
page: context.tracker.next_page
}
}
end
end
end
end
end
......@@ -34,7 +34,8 @@ module BulkImports
BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline,
BulkImports::Groups::Pipelines::MembersPipeline,
BulkImports::Groups::Pipelines::LabelsPipeline,
BulkImports::Groups::Pipelines::MilestonesPipeline
BulkImports::Groups::Pipelines::MilestonesPipeline,
BulkImports::Groups::Pipelines::BadgesPipeline
]
end
end
......
# frozen_string_literal: true
require 'date'
require 'pathname'
# https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page
namespace :gitlab do
namespace :docs do
desc 'GitLab | Docs | Create a doc redirect'
task :redirect, [:old_path, :new_path] do |_, args|
if args.old_path
old_path = args.old_path
else
puts '=> Enter the path of the OLD file:'
old_path = STDIN.gets.chomp
end
if args.new_path
new_path = args.new_path
else
puts '=> Enter the path of the NEW file:'
new_path = STDIN.gets.chomp
end
#
# If the new path is a relative URL, find the relative path between
# the old and new paths.
# The returned path is one level deeper, so remove the leading '../'.
#
unless new_path.start_with?('http')
old_pathname = Pathname.new(old_path)
new_pathname = Pathname.new(new_path)
relative_path = new_pathname.relative_path_from(old_pathname).to_s
(_, *last) = relative_path.split('/')
new_path = last.join('/')
end
#
# - If this is an external URL, move the date 1 year later.
# - If this is a relative URL, move the date 3 months later.
#
date = Time.now.utc.strftime('%Y-%m-%d')
date = new_path.start_with?('http') ? Date.parse(date) >> 12 : Date.parse(date) >> 3
puts "=> Creating new redirect from #{old_path} to #{new_path}"
File.open(old_path, 'w') do |post|
post.puts '---'
post.puts "redirect_to: '#{new_path}'"
post.puts '---'
post.puts
post.puts "This file was moved to [another location](#{new_path})."
post.puts
post.puts "<!-- This redirect file can be deleted after <#{date}>. -->"
post.puts "<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page -->"
end
end
end
end
......@@ -1074,6 +1074,9 @@ msgstr[1] ""
msgid "+%{approvers} more approvers"
msgstr ""
msgid "+%{extra} more"
msgstr ""
msgid "+%{more_assignees_count}"
msgstr ""
......@@ -20546,6 +20549,9 @@ msgstr ""
msgid "NetworkPolicy|Search by policy name"
msgstr ""
msgid "NetworkPolicy|Status"
msgstr ""
msgid "Never"
msgstr ""
......@@ -25470,7 +25476,7 @@ msgstr ""
msgid "Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software."
msgstr ""
msgid "Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}."
msgid "Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0.0%{codeEnd}, %{codeStart}v2.1.0-pre%{codeEnd}."
msgstr ""
msgid "Releases documentation"
......@@ -27056,6 +27062,9 @@ msgstr ""
msgid "SecurityApprovals|Requires approval for vulnerabilities of Critical, High, or Unknown severity. %{linkStart}Learn more.%{linkEnd}"
msgstr ""
msgid "SecurityConfiguration|%{featureName} merge request creation mutation failed"
msgstr ""
msgid "SecurityConfiguration|An error occurred while creating the merge request."
msgstr ""
......@@ -31819,6 +31828,15 @@ msgstr ""
msgid "To GitLab"
msgstr ""
msgid "To accept this invitation, create an account or sign in."
msgstr ""
msgid "To accept this invitation, sign in or create an account."
msgstr ""
msgid "To accept this invitation, sign in."
msgstr ""
msgid "To access this domain create a new DNS record"
msgstr ""
......
......@@ -54,6 +54,10 @@ module RuboCop
(send nil? :value ...)
PATTERN
def_node_matcher :resolver_kwarg, <<~PATTERN
(... (hash <(pair (sym :resolver) $_) ...>))
PATTERN
def_node_matcher :description_kwarg, <<~PATTERN
(... (hash <(pair (sym :description) $_) ...>))
PATTERN
......@@ -64,6 +68,7 @@ module RuboCop
def on_send(node)
return unless graphql_describable?(node)
return if resolver_kwarg(node) # Fields may inherit the description from their resolvers.
description = locate_description(node)
......
......@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe InvitesController do
let_it_be(:user) { create(:user) }
let(:member) { create(:project_member, :invited, invite_email: user.email) }
let_it_be(:member, reload: true) { create(:project_member, :invited, invite_email: user.email) }
let(:raw_invite_token) { member.raw_invite_token }
let(:project_members) { member.source.users }
let(:md5_member_global_id) { Digest::MD5.hexdigest(member.to_global_id.to_s) }
......@@ -77,10 +77,83 @@ RSpec.describe InvitesController do
context 'when not logged in' do
context 'when inviter is a member' do
it 'is redirected to a new session with invite email param' do
request
context 'when instance allows sign up' do
it 'indicates an account can be created in notice' do
request
expect(flash[:notice]).to include('or create an account')
end
context 'when user exists with the invited email' do
it 'is redirected to a new session with invite email param' do
request
expect(response).to redirect_to(new_user_session_path(invite_email: member.invite_email))
end
end
context 'when user exists with the invited email as secondary email' do
before do
secondary_email = create(:email, user: user, email: 'foo@example.com')
member.update!(invite_email: secondary_email.email)
end
it 'is redirected to a new session with invite email param' do
request
expect(response).to redirect_to(new_user_session_path(invite_email: member.invite_email))
end
end
context 'when user does not exist with the invited email' do
before do
member.update!(invite_email: 'bogus_email@example.com')
end
it 'indicates an account can be created in notice' do
request
expect(flash[:notice]).to include('create an account or sign in')
end
it 'is redirected to a new registration with invite email param' do
request
expect(response).to redirect_to(new_user_registration_path(invite_email: member.invite_email))
end
end
end
context 'when instance does not allow sign up' do
before do
stub_application_setting(allow_signup?: false)
end
it 'does not indicate an account can be created in notice' do
request
expect(flash[:notice]).not_to include('or create an account')
end
context 'when user exists with the invited email' do
it 'is redirected to a new session with invite email param' do
request
expect(response).to redirect_to(new_user_session_path(invite_email: member.invite_email))
end
end
context 'when user does not exist with the invited email' do
before do
member.update!(invite_email: 'bogus_email@example.com')
end
it 'is redirected to a new session with invite email param' do
request
expect(response).to redirect_to(new_user_session_path(invite_email: member.invite_email))
expect(response).to redirect_to(new_user_session_path(invite_email: member.invite_email))
end
end
end
end
......
......@@ -56,28 +56,6 @@ RSpec.describe Projects::RepositoriesController do
expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-archive:")
end
it 'handles legacy queries with no ref' do
get :archive, params: { namespace_id: project.namespace, project_id: project }, format: "zip"
expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-archive:")
end
it 'handles legacy queries with the ref specified as ref in params' do
get :archive, params: { namespace_id: project.namespace, project_id: project, ref: 'feature' }, format: 'zip'
expect(response).to have_gitlab_http_status(:ok)
expect(assigns(:ref)).to eq('feature')
expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-archive:")
end
it 'handles legacy queries with the ref specified as id in params' do
get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'feature' }, format: 'zip'
expect(response).to have_gitlab_http_status(:ok)
expect(assigns(:ref)).to eq('feature')
expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-archive:")
end
it 'prioritizes the id param over the ref param when both are specified' do
get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'feature', ref: 'feature_conflict' }, format: 'zip'
......
......@@ -50,21 +50,23 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
end
it 'renders sign in page with sign in notice' do
expect(current_path).to eq(new_user_session_path)
expect(page).to have_content('To accept this invitation, sign in')
expect(current_path).to eq(new_user_registration_path)
expect(page).to have_content('To accept this invitation, create an account or sign in')
end
it 'pre-fills the "Username or email" field on the sign in box with the invite_email from the invite' do
click_link 'Sign in'
expect(find_field('Username or email').value).to eq(group_invite.invite_email)
end
it 'pre-fills the Email field on the sign up box with the invite_email from the invite' do
click_link 'Register now'
expect(find_field('Email').value).to eq(group_invite.invite_email)
end
it 'sign in, grants access and redirects to group page' do
click_link 'Sign in'
fill_in_sign_in_form(user)
expect(current_path).to eq(group_path(group))
......@@ -85,20 +87,19 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
end
end
context 'when inviting a user' do
context 'when inviting an unregistered user' do
let(:new_user) { build_stubbed(:user) }
let(:invite_email) { new_user.email }
let(:group_invite) { create(:group_member, :invited, group: group, invite_email: invite_email, created_by: owner) }
let!(:project_invite) { create(:project_member, :invited, project: project, invite_email: invite_email) }
context 'when user has not signed in yet' do
context 'when registering using invitation email' do
before do
stub_application_setting(send_user_confirmation_email: send_email_confirmation)
visit invite_path(group_invite.raw_invite_token)
click_link 'Register now'
end
context 'with admin appoval required enabled' do
context 'with admin approval required enabled' do
before do
stub_application_setting(require_admin_approval_after_user_signup: true)
end
......
......@@ -117,12 +117,6 @@ RSpec.describe 'GFM autocomplete', :js do
end
end
it 'opens autocomplete menu when field starts with text' do
fill_in 'Comment', with: '@'
expect(find_autocomplete_menu).to be_visible
end
it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' do
issue_xss_title = 'This will execute alert<img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;'
create(:issue, project: project, title: issue_xss_title)
......@@ -153,74 +147,87 @@ RSpec.describe 'GFM autocomplete', :js do
expect(find_autocomplete_menu).to have_text('alert milestone')
end
it 'doesnt select the first item for non-assignee dropdowns' do
fill_in 'Comment', with: ':'
describe 'autocomplete highlighting' do
it 'auto-selects the first item when there is a query, and only for assignees with no query', :aggregate_failures do
fill_in 'Comment', with: ':'
wait_for_requests
expect(find_autocomplete_menu).not_to have_css('.cur')
wait_for_requests
fill_in 'Comment', with: ':1'
wait_for_requests
expect(find_autocomplete_menu).to have_css('.cur:first-of-type')
expect(find_autocomplete_menu).not_to have_css('.cur')
fill_in 'Comment', with: '@'
wait_for_requests
expect(find_autocomplete_menu).to have_css('.cur:first-of-type')
end
end
it 'selects the first item for assignee dropdowns' do
fill_in 'Comment', with: '@'
describe 'assignees' do
it 'does not wrap with quotes for assignee values' do
fill_in 'Comment', with: "@#{user.username[0]}"
wait_for_requests
find_highlighted_autocomplete_item.click
expect(find_autocomplete_menu).to have_css('.cur:first-of-type')
end
expect(find_field('Comment').value).to have_text("@#{user.username}")
end
it 'includes items for assignee dropdowns with non-ASCII characters in name' do
fill_in 'Comment', with: "@#{user.name[0...8]}"
it 'includes items for assignee dropdowns with non-ASCII characters in name' do
fill_in 'Comment', with: "@#{user.name[0...8]}"
wait_for_requests
wait_for_requests
expect(find_autocomplete_menu).to have_text(user.name)
end
expect(find_autocomplete_menu).to have_text(user.name)
end
it 'searches across full name for assignees' do
fill_in 'Comment', with: '@speciąlsome'
it 'searches across full name for assignees' do
fill_in 'Comment', with: '@speciąlsome'
wait_for_requests
wait_for_requests
expect(find_highlighted_autocomplete_item).to have_text(user.name)
end
expect(find_highlighted_autocomplete_item).to have_text(user.name)
end
it 'shows names that start with the query as the top result' do
fill_in 'Comment', with: '@mar'
it 'shows names that start with the query as the top result' do
fill_in 'Comment', with: '@mar'
wait_for_requests
wait_for_requests
expect(find_highlighted_autocomplete_item).to have_text(user2.name)
end
expect(find_highlighted_autocomplete_item).to have_text(user2.name)
end
it 'shows usernames that start with the query as the top result' do
fill_in 'Comment', with: '@msi'
it 'shows usernames that start with the query as the top result' do
fill_in 'Comment', with: '@msi'
wait_for_requests
wait_for_requests
expect(find_highlighted_autocomplete_item).to have_text(user2.name)
end
expect(find_highlighted_autocomplete_item).to have_text(user2.name)
end
# Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/321925
it 'shows username when pasting then pressing Enter' do
fill_in 'Comment', with: "@#{user.username}\n"
# Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/321925
it 'shows username when pasting then pressing Enter' do
fill_in 'Comment', with: "@#{user.username}\n"
expect(find_field('Comment').value).to have_text "@#{user.username}"
end
expect(find_field('Comment').value).to have_text "@#{user.username}"
end
it 'does not show `@undefined` when pressing `@` then Enter' do
fill_in 'Comment', with: "@\n"
it 'does not show `@undefined` when pressing `@` then Enter' do
fill_in 'Comment', with: "@\n"
expect(find_field('Comment').value).to have_text '@'
expect(find_field('Comment').value).not_to have_text '@undefined'
end
expect(find_field('Comment').value).to have_text '@'
expect(find_field('Comment').value).not_to have_text '@undefined'
end
it 'selects the first item for non-assignee dropdowns if a query is entered' do
fill_in 'Comment', with: ':1'
context 'when /assign quick action is selected' do
it 'triggers user autocomplete and lists users who are currently not assigned to the issue' do
fill_in 'Comment', with: '/as'
wait_for_requests
find_highlighted_autocomplete_item.click
expect(find_autocomplete_menu).to have_css('.cur:first-of-type')
expect(find_autocomplete_menu).not_to have_text(user.username)
expect(find_autocomplete_menu).to have_text(user2.username)
end
end
end
context 'if a selected value has special characters' do
......@@ -232,14 +239,6 @@ RSpec.describe 'GFM autocomplete', :js do
expect(find_field('Comment').value).to have_text("~\"#{label.title}\"")
end
it 'doesn\'t wrap for assignee values' do
fill_in 'Comment', with: "@#{user.username[0]}"
find_highlighted_autocomplete_item.click
expect(find_field('Comment').value).to have_text("@#{user.username}")
end
it 'doesn\'t wrap for emoji values' do
fill_in 'Comment', with: ':cartwheel_'
......@@ -263,17 +262,6 @@ RSpec.describe 'GFM autocomplete', :js do
end
end
context 'assignees' do
it 'lists users who are currently not assigned to the issue when using /assign' do
fill_in 'Comment', with: '/as'
find_highlighted_autocomplete_item.click
expect(find_autocomplete_menu).not_to have_text(user.username)
expect(find_autocomplete_menu).to have_text(user2.username)
end
end
context 'labels' do
it 'opens autocomplete menu for Labels when field starts with text with item escaping HTML characters' do
label_xss_title = 'alert label &lt;img src=x onerror="alert(\'Hello xss\');" a'
......@@ -498,12 +486,6 @@ RSpec.describe 'GFM autocomplete', :js do
end
end
it 'opens autocomplete menu when field starts with text' do
fill_in 'Comment', with: '@'
expect(find_tribute_autocomplete_menu).to be_visible
end
it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' do
issue_xss_title = 'This will execute alert<img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;'
create(:issue, project: project, title: issue_xss_title)
......@@ -534,41 +516,77 @@ RSpec.describe 'GFM autocomplete', :js do
expect(find_tribute_autocomplete_menu).to have_text('alert milestone')
end
it 'selects the first item for assignee dropdowns' do
fill_in 'Comment', with: '@'
wait_for_requests
describe 'autocomplete highlighting' do
it 'auto-selects the first item with query', :aggregate_failures do
fill_in 'Comment', with: ':1'
wait_for_requests
expect(find_tribute_autocomplete_menu).to have_css('.highlight:first-of-type')
expect(find_tribute_autocomplete_menu).to have_css('.highlight:first-of-type')
fill_in 'Comment', with: '@'
wait_for_requests
expect(find_tribute_autocomplete_menu).to have_css('.highlight:first-of-type')
end
end
it 'includes items for assignee dropdowns with non-ASCII characters in name' do
fill_in 'Comment', with: "@#{user.name[0...8]}"
describe 'assignees' do
it 'does not wrap with quotes for assignee values' do
fill_in 'Comment', with: "@#{user.username[0..2]}"
wait_for_requests
find_highlighted_tribute_autocomplete_menu.click
expect(find_tribute_autocomplete_menu).to have_text(user.name)
end
expect(find_field('Comment').value).to have_text("@#{user.username}")
end
it 'selects the first item for non-assignee dropdowns if a query is entered' do
fill_in 'Comment', with: ':1'
it 'includes items for assignee dropdowns with non-ASCII characters in name' do
fill_in 'Comment', with: "@#{user.name[0...8]}"
wait_for_requests
wait_for_requests
expect(find_tribute_autocomplete_menu).to have_css('.highlight:first-of-type')
end
expect(find_tribute_autocomplete_menu).to have_text(user.name)
end
context 'when autocompleting for groups' do
it 'shows the group when searching for the name of the group' do
fill_in 'Comment', with: '@mygroup'
context 'when autocompleting for groups' do
it 'shows the group when searching for the name of the group' do
fill_in 'Comment', with: '@mygroup'
expect(find_tribute_autocomplete_menu).to have_text('My group')
end
it 'does not show the group when searching for the name of the parent of the group' do
fill_in 'Comment', with: '@ancestor'
expect(find_tribute_autocomplete_menu).to have_text('My group')
expect(find_tribute_autocomplete_menu).not_to have_text('My group')
end
end
it 'does not show the group when searching for the name of the parent of the group' do
fill_in 'Comment', with: '@ancestor'
context 'when /assign quick action is selected' do
it 'lists users who are currently not assigned to the issue' do
note = find_field('Comment')
note.native.send_keys('/assign ')
# The `/assign` ajax response might replace the one by `@` below causing a failed test
# so we need to wait for the `/assign` ajax request to finish first
wait_for_requests
note.native.send_keys('@')
wait_for_requests
expect(find_tribute_autocomplete_menu).not_to have_text('My group')
expect(find_tribute_autocomplete_menu).not_to have_text(user.username)
expect(find_tribute_autocomplete_menu).to have_text(user2.username)
end
it 'lists users who are currently not assigned to the issue when using /assign on the second line' do
note = find_field('Comment')
note.native.send_keys('/assign @user2')
note.native.send_keys(:enter)
note.native.send_keys('/assign ')
# The `/assign` ajax response might replace the one by `@` below causing a failed test
# so we need to wait for the `/assign` ajax request to finish first
wait_for_requests
note.native.send_keys('@')
wait_for_requests
expect(find_tribute_autocomplete_menu).not_to have_text(user.username)
expect(find_tribute_autocomplete_menu).to have_text(user2.username)
end
end
end
......@@ -581,14 +599,6 @@ RSpec.describe 'GFM autocomplete', :js do
expect(find_field('Comment').value).to have_text("~\"#{label.title}\"")
end
it 'doesn\'t wrap for assignee values' do
fill_in 'Comment', with: "@#{user.username[0..2]}"
find_highlighted_tribute_autocomplete_menu.click
expect(find_field('Comment').value).to have_text("@#{user.username}")
end
it 'does not wrap for emoji values' do
fill_in 'Comment', with: ':cartwheel_'
......@@ -606,36 +616,6 @@ RSpec.describe 'GFM autocomplete', :js do
end
end
context 'assignees' do
it 'lists users who are currently not assigned to the issue when using /assign' do
note = find_field('Comment')
note.native.send_keys('/assign ')
# The `/assign` ajax response might replace the one by `@` below causing a failed test
# so we need to wait for the `/assign` ajax request to finish first
wait_for_requests
note.native.send_keys('@')
wait_for_requests
expect(find_tribute_autocomplete_menu).not_to have_text(user.username)
expect(find_tribute_autocomplete_menu).to have_text(user2.username)
end
it 'lists users who are currently not assigned to the issue when using /assign on the second line' do
note = find_field('Comment')
note.native.send_keys('/assign @user2')
note.native.send_keys(:enter)
note.native.send_keys('/assign ')
# The `/assign` ajax response might replace the one by `@` below causing a failed test
# so we need to wait for the `/assign` ajax request to finish first
wait_for_requests
note.native.send_keys('@')
wait_for_requests
expect(find_tribute_autocomplete_menu).not_to have_text(user.username)
expect(find_tribute_autocomplete_menu).to have_text(user2.username)
end
end
context 'labels' do
it 'opens autocomplete menu for Labels when field starts with text with item escaping HTML characters' do
label_xss_title = 'alert label &lt;img src=x onerror="alert(\'Hello xss\');" a'
......
......@@ -37,7 +37,7 @@ RSpec.describe 'User edits Release', :js do
end
it 'renders the edit Release form' do
expect(page).to have_content('Releases are based on Git tags. We recommend tags that use semantic versioning, for example v1.0, v2.0-pre.')
expect(page).to have_content('Releases are based on Git tags. We recommend tags that use semantic versioning, for example v1.0.0, v2.1.0-pre.')
expect(find_field('Tag name', disabled: true).value).to eq(release.tag)
expect(find_field('Release title').value).to eq(release.name)
......
import { GlButton } from '@gitlab/ui';
import { GlButton, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
......@@ -8,22 +8,29 @@ jest.mock('~/experimentation/experiment_tracking');
const displayText = 'Invite team members';
let wrapper;
let triggerProps;
let findButton;
const triggerComponent = {
button: GlButton,
anchor: GlLink,
};
const createComponent = (props = {}) => {
wrapper = shallowMount(InviteMembersTrigger, {
propsData: {
displayText,
...triggerProps,
...props,
},
});
};
describe('InviteMembersTrigger', () => {
const findButton = () => wrapper.findComponent(GlButton);
describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement) => {
triggerProps = { triggerElement };
findButton = () => wrapper.findComponent(triggerComponent[triggerElement]);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('displayText', () => {
......@@ -74,5 +81,19 @@ describe('InviteMembersTrigger', () => {
expect(ExperimentTracking).not.toHaveBeenCalledWith('_track_experiment_');
});
it('does not add tracking attributes', () => {
createComponent();
expect(findButton().attributes('data-track-event')).toBeUndefined();
expect(findButton().attributes('data-track-label')).toBeUndefined();
});
it('adds tracking attributes', () => {
createComponent({ label: '_label_', event: '_event_' });
expect(findButton().attributes('data-track-event')).toBe('_event_');
expect(findButton().attributes('data-track-label')).toBe('_label_');
});
});
});
......@@ -7,6 +7,8 @@ import PackageSearch from '~/packages/list/components/package_search.vue';
import PackageListApp from '~/packages/list/components/packages_list_app.vue';
import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import * as packageUtils from '~/packages_and_registries/shared/utils';
jest.mock('~/lib/utils/common_utils');
jest.mock('~/flash');
......@@ -61,6 +63,7 @@ describe('packages_list_app', () => {
beforeEach(() => {
createStore();
jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue({});
});
afterEach(() => {
......@@ -72,25 +75,6 @@ describe('packages_list_app', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('empty state', () => {
it('generate the correct empty list link', () => {
mountComponent();
const link = findListComponent().find(GlLink);
expect(link.attributes('href')).toBe(emptyListHelpUrl);
expect(link.text()).toBe('publish and share your packages');
});
it('includes the right content on the default tab', () => {
mountComponent();
const heading = findEmptyState().find('h1');
expect(heading.text()).toBe('There are no packages yet');
});
});
it('call requestPackagesList on page:changed', () => {
mountComponent();
store.dispatch.mockClear();
......@@ -108,10 +92,75 @@ describe('packages_list_app', () => {
expect(store.dispatch).toHaveBeenCalledWith('requestDeletePackage', 'foo');
});
it('does not call requestPackagesList two times on render', () => {
it('does call requestPackagesList only one time on render', () => {
mountComponent();
expect(store.dispatch).toHaveBeenCalledTimes(1);
expect(store.dispatch).toHaveBeenCalledTimes(3);
expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', expect.any(Object));
expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', expect.any(Array));
expect(store.dispatch).toHaveBeenNthCalledWith(3, 'requestPackagesList');
});
describe('url query string handling', () => {
const defaultQueryParamsMock = {
search: [1, 2],
type: 'npm',
sort: 'asc',
orderBy: 'created',
};
it('calls setSorting with the query string based sorting', () => {
jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue(defaultQueryParamsMock);
mountComponent();
expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', {
orderBy: defaultQueryParamsMock.orderBy,
sort: defaultQueryParamsMock.sort,
});
});
it('calls setFilter with the query string based filters', () => {
jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue(defaultQueryParamsMock);
mountComponent();
expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', [
{ type: 'type', value: { data: defaultQueryParamsMock.type } },
{ type: FILTERED_SEARCH_TERM, value: { data: defaultQueryParamsMock.search[0] } },
{ type: FILTERED_SEARCH_TERM, value: { data: defaultQueryParamsMock.search[1] } },
]);
});
it('calls setSorting and setFilters with the results of extractFilterAndSorting', () => {
jest
.spyOn(packageUtils, 'extractFilterAndSorting')
.mockReturnValue({ filters: ['foo'], sorting: { sort: 'desc' } });
mountComponent();
expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', { sort: 'desc' });
expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', ['foo']);
});
});
describe('empty state', () => {
it('generate the correct empty list link', () => {
mountComponent();
const link = findListComponent().find(GlLink);
expect(link.attributes('href')).toBe(emptyListHelpUrl);
expect(link.text()).toBe('publish and share your packages');
});
it('includes the right content on the default tab', () => {
mountComponent();
const heading = findEmptyState().find('h1');
expect(heading.text()).toBe('There are no packages yet');
});
});
describe('filter without results', () => {
......
......@@ -4,6 +4,7 @@ import component from '~/packages/list/components/package_search.vue';
import PackageTypeToken from '~/packages/list/components/tokens/package_type_token.vue';
import getTableHeaders from '~/packages/list/utils';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -12,7 +13,8 @@ describe('Package Search', () => {
let wrapper;
let store;
const findRegistrySearch = () => wrapper.find(RegistrySearch);
const findRegistrySearch = () => wrapper.findComponent(RegistrySearch);
const findUrlSync = () => wrapper.findComponent(UrlSync);
const createStore = (isGroupPage) => {
const state = {
......@@ -37,6 +39,9 @@ describe('Package Search', () => {
wrapper = shallowMount(component, {
localVue,
store,
stubs: {
UrlSync,
},
});
};
......@@ -104,4 +109,20 @@ describe('Package Search', () => {
expect(wrapper.emitted('update')).toEqual([[]]);
});
it('has a UrlSync component', () => {
mountComponent();
expect(findUrlSync().exists()).toBe(true);
});
it('on query:changed calls updateQuery from UrlSync', () => {
jest.spyOn(UrlSync.methods, 'updateQuery').mockImplementation(() => {});
mountComponent();
findRegistrySearch().vm.$emit('query:changed');
expect(UrlSync.methods.updateQuery).toHaveBeenCalled();
});
});
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import {
getQueryParams,
keyValueToFilterToken,
searchArrayToFilterTokens,
extractFilterAndSorting,
} from '~/packages_and_registries/shared/utils';
describe('Packages And Registries shared utils', () => {
......@@ -27,9 +29,31 @@ describe('Packages And Registries shared utils', () => {
const search = ['one', 'two'];
expect(searchArrayToFilterTokens(search)).toStrictEqual([
{ type: 'filtered-search-term', value: { data: 'one' } },
{ type: 'filtered-search-term', value: { data: 'two' } },
{ type: FILTERED_SEARCH_TERM, value: { data: 'one' } },
{ type: FILTERED_SEARCH_TERM, value: { data: 'two' } },
]);
});
});
describe('extractFilterAndSorting', () => {
it.each`
search | type | sort | orderBy | result
${['one']} | ${'myType'} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: 'type', value: { data: 'myType' } }, { type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }}
${['one']} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }}
${[]} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }}
${null} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }}
${null} | ${null} | ${null} | ${'foo'} | ${{ sorting: { orderBy: 'foo' }, filters: [] }}
${null} | ${null} | ${null} | ${null} | ${{ sorting: {}, filters: [] }}
`(
'returns sorting and filters objects in the correct form',
({ search, type, sort, orderBy, result }) => {
const queryObject = {
search,
type,
sort,
orderBy,
};
expect(extractFilterAndSorting(queryObject)).toStrictEqual(result);
},
);
});
});
import { GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
......@@ -61,7 +62,7 @@ describe('List Page', () => {
const waitForApolloRequestRender = async () => {
jest.runOnlyPendingTimers();
await waitForPromises();
await wrapper.vm.$nextTick();
await nextTick();
};
const mountComponent = ({
......@@ -70,6 +71,7 @@ describe('List Page', () => {
detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock),
mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock),
config = { isGroupPage: false },
query = {},
} = {}) => {
localVue.use(VueApollo);
......@@ -96,6 +98,7 @@ describe('List Page', () => {
$toast,
$route: {
name: 'foo',
query,
},
...mocks,
},
......@@ -159,9 +162,11 @@ describe('List Page', () => {
});
describe('isLoading is true', () => {
it('shows the skeleton loader', () => {
it('shows the skeleton loader', async () => {
mountComponent();
await nextTick();
expect(findSkeletonLoader().exists()).toBe(true);
});
......@@ -177,9 +182,11 @@ describe('List Page', () => {
expect(findCliCommands().exists()).toBe(false);
});
it('title has the metadataLoading props set to true', () => {
it('title has the metadataLoading props set to true', async () => {
mountComponent();
await nextTick();
expect(findRegistryHeader().props('metadataLoading')).toBe(true);
});
});
......@@ -312,7 +319,7 @@ describe('List Page', () => {
await selectImageForDeletion();
findDeleteImage().vm.$emit('success');
await wrapper.vm.$nextTick();
await nextTick();
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
......@@ -328,7 +335,7 @@ describe('List Page', () => {
await selectImageForDeletion();
findDeleteImage().vm.$emit('error');
await wrapper.vm.$nextTick();
await nextTick();
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
......@@ -349,7 +356,7 @@ describe('List Page', () => {
findRegistrySearch().vm.$emit('filter:submit');
await wrapper.vm.$nextTick();
await nextTick();
};
it('has a search box element', async () => {
......@@ -374,7 +381,7 @@ describe('List Page', () => {
await waitForApolloRequestRender();
findRegistrySearch().vm.$emit('sorting:changed', { sort: 'asc' });
await wrapper.vm.$nextTick();
await nextTick();
expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sort: 'UPDATED_DESC' }));
});
......@@ -417,7 +424,7 @@ describe('List Page', () => {
await waitForApolloRequestRender();
findImageList().vm.$emit('prev-page');
await wrapper.vm.$nextTick();
await nextTick();
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ before: pageInfo.startCursor }),
......@@ -437,7 +444,7 @@ describe('List Page', () => {
await waitForApolloRequestRender();
findImageList().vm.$emit('next-page');
await wrapper.vm.$nextTick();
await nextTick();
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ after: pageInfo.endCursor }),
......@@ -458,11 +465,10 @@ describe('List Page', () => {
expect(findDeleteModal().exists()).toBe(true);
});
it('contains a description with the path of the item to delete', () => {
it('contains a description with the path of the item to delete', async () => {
findImageList().vm.$emit('delete', { path: 'foo' });
return wrapper.vm.$nextTick().then(() => {
expect(findDeleteModal().html()).toContain('foo');
});
await nextTick();
expect(findDeleteModal().html()).toContain('foo');
});
});
......@@ -498,4 +504,60 @@ describe('List Page', () => {
testTrackingCall('confirm_delete');
});
});
describe('url query string handling', () => {
const defaultQueryParams = {
search: [1, 2],
sort: 'asc',
orderBy: 'CREATED',
};
const queryChangePayload = 'foo';
it('query:updated event pushes the new query to the router', async () => {
const push = jest.fn();
mountComponent({ mocks: { $router: { push } } });
await nextTick();
findRegistrySearch().vm.$emit('query:changed', queryChangePayload);
expect(push).toHaveBeenCalledWith({ query: queryChangePayload });
});
it('graphql API call has the variables set from the URL', async () => {
const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
mountComponent({ query: defaultQueryParams, resolver });
await nextTick();
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({
name: 1,
sort: 'CREATED_ASC',
}),
);
});
it.each`
sort | orderBy | search | payload
${'ASC'} | ${undefined} | ${undefined} | ${{ sort: 'UPDATED_ASC' }}
${undefined} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_DESC' }}
${'ASC'} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_ASC' }}
${undefined} | ${undefined} | ${undefined} | ${{}}
${undefined} | ${undefined} | ${['one']} | ${{ name: 'one' }}
${undefined} | ${undefined} | ${['one', 'two']} | ${{ name: 'one' }}
${undefined} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_DESC' }}
${'ASC'} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_ASC' }}
`(
'with sort equal to $sort, orderBy equal to $orderBy, search set to $search API call has the variables set as $payload',
async ({ sort, orderBy, search, payload }) => {
const resolver = jest.fn().mockResolvedValue({ sort, orderBy });
mountComponent({ query: { sort, orderBy, search }, resolver });
await nextTick();
expect(resolver).toHaveBeenCalledWith(expect.objectContaining(payload));
},
);
});
});
......@@ -112,7 +112,7 @@ describe('Release edit/new component', () => {
it('renders the description text at the top of the page', () => {
expect(wrapper.find('.js-subtitle-text').text()).toBe(
'Releases are based on Git tags. We recommend tags that use semantic versioning, for example v1.0, v2.0-pre.',
'Releases are based on Git tags. We recommend tags that use semantic versioning, for example v1.0.0, v2.1.0-pre.',
);
});
......
......@@ -76,6 +76,7 @@ describe('Blob content viewer component', () => {
expect(findBlobContent().props('loading')).toEqual(false);
expect(findBlobContent().props('content')).toEqual('raw content');
expect(findBlobContent().props('isRawContent')).toBe(true);
expect(findBlobContent().props('activeViewer')).toEqual({
fileType: 'text',
tooLarge: false,
......
import { shallowMount } from '@vue/test-utils';
import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
import BlobPage from '~/repository/pages/blob.vue';
jest.mock('~/repository/utils/dom');
describe('Repository blob page component', () => {
let wrapper;
const findBlobContentViewer = () => wrapper.find(BlobContentViewer);
const path = 'file.js';
beforeEach(() => {
wrapper = shallowMount(BlobPage, { propsData: { path } });
});
afterEach(() => {
wrapper.destroy();
});
it('has a Blob Content Viewer component', () => {
expect(findBlobContentViewer().exists()).toBe(true);
expect(findBlobContentViewer().props('path')).toBe(path);
});
});
......@@ -29,7 +29,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
>
<markdown-header-stub
linecontent=""
suggestionstartindex="-1"
suggestionstartindex="0"
/>
<div
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Blob Simple Viewer component rendering matches the snapshot 1`] = `
<div
class="file-content code js-syntax-highlight"
data-qa-selector="file_content"
>
<div>
<div
class="line-numbers"
class="file-content code js-syntax-highlight"
data-qa-selector="file_content"
>
<a
class="diff-line-num js-line-number"
data-line-number="1"
href="#LC1"
id="L1"
<div
class="line-numbers"
>
<gl-icon-stub
name="link"
size="12"
/>
<a
class="diff-line-num js-line-number"
data-line-number="1"
href="#LC1"
id="L1"
>
<gl-icon-stub
name="link"
size="12"
/>
1
1
</a>
<a
class="diff-line-num js-line-number"
data-line-number="2"
href="#LC2"
id="L2"
>
<gl-icon-stub
name="link"
size="12"
/>
</a>
<a
class="diff-line-num js-line-number"
data-line-number="2"
href="#LC2"
id="L2"
>
<gl-icon-stub
name="link"
size="12"
/>
2
2
</a>
<a
class="diff-line-num js-line-number"
data-line-number="3"
href="#LC3"
id="L3"
>
<gl-icon-stub
name="link"
size="12"
/>
</a>
<a
class="diff-line-num js-line-number"
data-line-number="3"
href="#LC3"
id="L3"
>
<gl-icon-stub
name="link"
size="12"
/>
3
3
</a>
</div>
<div
class="blob-content"
>
<pre
class="code highlight"
</a>
</div>
<div
class="blob-content"
>
<code
data-blob-hash="foo-bar"
<pre
class="code highlight"
>
<span
id="LC1"
<code
data-blob-hash="foo-bar"
>
First
</span>
<span
id="LC1"
>
First
</span>
<span
id="LC2"
>
Second
</span>
<span
id="LC2"
>
Second
</span>
<span
id="LC3"
>
Third
</span>
</code>
</pre>
<span
id="LC3"
>
Third
</span>
</code>
</pre>
</div>
</div>
</div>
`;
import { shallowMount } from '@vue/test-utils';
import { HIGHLIGHT_CLASS_NAME } from '~/vue_shared/components/blob_viewers/constants';
import SimpleViewer from '~/vue_shared/components/blob_viewers/simple_viewer.vue';
import EditorLite from '~/vue_shared/components/editor_lite.vue';
describe('Blob Simple Viewer component', () => {
let wrapper;
const contentMock = `<span id="LC1">First</span>\n<span id="LC2">Second</span>\n<span id="LC3">Third</span>`;
const blobHash = 'foo-bar';
function createComponent(content = contentMock) {
function createComponent(content = contentMock, isRawContent = false) {
wrapper = shallowMount(SimpleViewer, {
provide: {
blobHash,
......@@ -15,6 +16,8 @@ describe('Blob Simple Viewer component', () => {
propsData: {
content,
type: 'text',
fileName: 'test.js',
isRawContent,
},
});
}
......@@ -83,4 +86,18 @@ describe('Blob Simple Viewer component', () => {
});
});
});
describe('raw content', () => {
const findEditorLite = () => wrapper.find(EditorLite);
const isRawContent = true;
it('uses the Editor Lite component in readonly mode when viewing raw content', () => {
createComponent('raw content', isRawContent);
expect(findEditorLite().exists()).toBe(true);
expect(findEditorLite().props('value')).toBe('raw content');
expect(findEditorLite().props('fileName')).toBe('test.js');
expect(findEditorLite().props('editorOptions')).toEqual({ readOnly: true });
});
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BulkImports::Common::Extractors::RestExtractor do
let(:http_client) { instance_double(BulkImports::Clients::Http) }
let(:options) { { query: double(to_h: { resource: nil, query: nil }) } }
let(:response) { double(parsed_response: { 'data' => { 'foo' => 'bar' } }, headers: { 'x-next-page' => '2' }) }
subject { described_class.new(options) }
describe '#extract' do
before do
allow(subject).to receive(:http_client).and_return(http_client)
allow(http_client).to receive(:get).and_return(response)
end
it 'returns instance of ExtractedData' do
entity = create(:bulk_import_entity)
tracker = create(:bulk_import_tracker, entity: entity)
context = BulkImports::Pipeline::Context.new(tracker)
extracted_data = subject.extract(context)
expect(extracted_data).to be_instance_of(BulkImports::Pipeline::ExtractedData)
expect(extracted_data.data).to contain_exactly(response.parsed_response)
expect(extracted_data.next_page).to eq(response.headers['x-next-page'])
expect(extracted_data.has_next_page?).to eq(true)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BulkImports::Groups::Pipelines::BadgesPipeline do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:entity) do
create(
:bulk_import_entity,
source_full_path: 'source/full/path',
destination_name: 'My Destination Group',
destination_namespace: group.full_path,
group: group
)
end
let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) }
let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) }
subject { described_class.new(context) }
describe '#run' do
it 'imports a group badge' do
first_page = extracted_data(has_next_page: true)
last_page = extracted_data(name: 'badge2')
allow_next_instance_of(BulkImports::Common::Extractors::RestExtractor) do |extractor|
allow(extractor)
.to receive(:extract)
.and_return(first_page, last_page)
end
expect { subject.run }.to change(Badge, :count).by(2)
badge = group.badges.last
expect(badge.name).to eq('badge2')
expect(badge.link_url).to eq(badge_data['link_url'])
expect(badge.image_url).to eq(badge_data['image_url'])
end
describe '#load' do
it 'creates a badge' do
expect { subject.load(context, badge_data) }.to change(Badge, :count).by(1)
badge = group.badges.first
badge_data.each do |key, value|
expect(badge[key]).to eq(value)
end
end
it 'does nothing when the data is blank' do
expect { subject.load(context, nil) }.not_to change(Badge, :count)
end
end
describe '#transform' do
it 'return transformed badge hash' do
badge = subject.transform(context, badge_data)
expect(badge[:name]).to eq('badge')
expect(badge[:link_url]).to eq(badge_data['link_url'])
expect(badge[:image_url]).to eq(badge_data['image_url'])
expect(badge.keys).to contain_exactly(:name, :link_url, :image_url)
end
context 'when data is blank' do
it 'does nothing when the data is blank' do
expect(subject.transform(context, nil)).to be_nil
end
end
end
describe 'pipeline parts' do
it { expect(described_class).to include_module(BulkImports::Pipeline) }
it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) }
it 'has extractors' do
expect(described_class.get_extractor)
.to eq(
klass: BulkImports::Common::Extractors::RestExtractor,
options: {
query: BulkImports::Groups::Rest::GetBadgesQuery
}
)
end
it 'has transformers' do
expect(described_class.transformers)
.to contain_exactly(
{ klass: BulkImports::Common::Transformers::ProhibitedAttributesTransformer, options: nil }
)
end
end
def badge_data(name = 'badge')
{
'name' => name,
'link_url' => 'https://gitlab.example.com',
'image_url' => 'https://gitlab.example.com/image.png'
}
end
def extracted_data(name: 'badge', has_next_page: false)
page_info = {
'has_next_page' => has_next_page,
'next_page' => has_next_page ? '2' : nil
}
BulkImports::Pipeline::ExtractedData.new(data: [badge_data(name)], page_info: page_info)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BulkImports::Groups::Rest::GetBadgesQuery do
describe '.to_h' do
it 'returns query resource and page info' do
entity = create(:bulk_import_entity)
tracker = create(:bulk_import_tracker, entity: entity)
context = BulkImports::Pipeline::Context.new(tracker)
encoded_full_path = ERB::Util.url_encode(entity.source_full_path)
expected = {
resource: ['groups', encoded_full_path, 'badges'].join('/'),
query: {
page: context.tracker.next_page
}
}
expect(described_class.to_h(context)).to eq(expected)
end
end
end
......@@ -23,6 +23,7 @@ RSpec.describe BulkImports::Importers::GroupImporter do
expect_to_run_pipeline BulkImports::Groups::Pipelines::MembersPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::LabelsPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::MilestonesPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::BadgesPipeline, context: context
if Gitlab.ee?
expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::EpicsPipeline'.constantize, context: context)
......
......@@ -126,26 +126,6 @@ RSpec.describe 'project routing' do
it 'to #archive with "/" in route' do
expect(get('/gitlab/gitlabhq/-/archive/improve/awesome/gitlabhq-improve-awesome.tar.gz')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'tar.gz', id: 'improve/awesome/gitlabhq-improve-awesome')
end
it 'to #archive_alternative' do
expect(get('/gitlab/gitlabhq/-/repository/archive')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', append_sha: true)
end
it 'to #archive_deprecated' do
expect(get('/gitlab/gitlabhq/-/repository/master/archive')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master', append_sha: true)
end
it 'to #archive_deprecated format:zip' do
expect(get('/gitlab/gitlabhq/-/repository/master/archive.zip')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'zip', id: 'master', append_sha: true)
end
it 'to #archive_deprecated format:tar.bz2' do
expect(get('/gitlab/gitlabhq/-/repository/master/archive.tar.bz2')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'tar.bz2', id: 'master', append_sha: true)
end
it 'to #archive_deprecated with "/" in route' do
expect(get('/gitlab/gitlabhq/-/repository/improve/awesome/archive')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'improve/awesome', append_sha: true)
end
end
describe Projects::BranchesController, 'routing' do
......
......@@ -6,7 +6,7 @@ require_relative '../../../../rubocop/cop/graphql/descriptions'
RSpec.describe RuboCop::Cop::Graphql::Descriptions do
subject(:cop) { described_class.new }
context 'fields' do
context 'with fields' do
it 'adds an offense when there is no description' do
expect_offense(<<~TYPE)
module Types
......@@ -46,9 +46,19 @@ RSpec.describe RuboCop::Cop::Graphql::Descriptions do
end
TYPE
end
it 'does not add an offense when there is a resolver' do
expect_no_offenses(<<~TYPE.strip)
module Types
class FakeType < BaseObject
field :a_thing, resolver: ThingResolver
end
end
TYPE
end
end
context 'arguments' do
context 'with arguments' do
it 'adds an offense when there is no description' do
expect_offense(<<~TYPE)
module Types
......@@ -90,7 +100,7 @@ RSpec.describe RuboCop::Cop::Graphql::Descriptions do
end
end
context 'enum values' do
context 'with enum values' do
it 'adds an offense when there is no description' do
expect_offense(<<~TYPE)
module Types
......
......@@ -2,7 +2,7 @@
RSpec.shared_examples 'issuable invite members experiments' do
context 'when a privileged user can invite' do
it 'shows a link for inviting members and follows through to the members page' do
it 'shows a link for inviting members and launches invite modal' do
project.add_maintainer(user)
visit issuable_path
......@@ -11,14 +11,14 @@ RSpec.shared_examples 'issuable invite members experiments' do
wait_for_requests
page.within '.dropdown-menu-user' do
expect(page).to have_link('Invite Members', href: project_project_members_path(project))
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(current_path).to eq project_project_members_path(project)
expect(page).to have_content("You're inviting members to the")
end
end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment