Commit 1b2af232 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 5f4e92a2 bcdeb60e
<script>
import { GlPagination } from '@gitlab/ui';
import { GlKeysetPagination } from '@gitlab/ui';
import ImageListRow from './image_list_row.vue';
export default {
name: 'ImageList',
components: {
GlPagination,
GlKeysetPagination,
ImageListRow,
},
props: {
......@@ -13,19 +13,14 @@ export default {
type: Array,
required: true,
},
pagination: {
pageInfo: {
type: Object,
required: true,
},
},
computed: {
currentPage: {
get() {
return this.pagination.page;
},
set(page) {
this.$emit('pageChange', page);
},
showPagination() {
return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
},
},
};
......@@ -40,13 +35,15 @@ export default {
:first="index === 0"
@delete="$emit('delete', $event)"
/>
<gl-pagination
v-model="currentPage"
:per-page="pagination.perPage"
:total-items="pagination.total"
align="center"
class="w-100 gl-mt-3"
/>
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
v-if="showPagination"
:has-next-page="pageInfo.hasNextPage"
:has-previous-page="pageInfo.hasPreviousPage"
class="gl-mt-3"
@prev="$emit('prev-page')"
@next="$emit('next-page')"
/>
</div>
</div>
</template>
<script>
import { GlTooltipDirective, GlIcon, GlSprintf } from '@gitlab/ui';
import { n__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import DeleteButton from '../delete_button.vue';
......@@ -11,6 +13,8 @@ import {
REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION,
CLEANUP_TIMED_OUT_ERROR_MESSAGE,
IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_FAILED_DELETED_STATUS,
} from '../../constants/index';
export default {
......@@ -38,19 +42,29 @@ export default {
},
computed: {
disabledDelete() {
return !this.item.destroy_path || this.item.deleting;
return !this.item.canDelete || this.deleting;
},
id() {
return getIdFromGraphQLId(this.item.id);
},
deleting() {
return this.item.status === IMAGE_DELETE_SCHEDULED_STATUS;
},
failedDelete() {
return this.item.status === IMAGE_FAILED_DELETED_STATUS;
},
tagsCountText() {
return n__(
'ContainerRegistry|%{count} Tag',
'ContainerRegistry|%{count} Tags',
this.item.tags_count,
this.item.tagsCount,
);
},
warningIconText() {
if (this.item.failedDelete) {
if (this.failedDelete) {
return ASYNC_DELETE_IMAGE_ERROR_MESSAGE;
} else if (this.item.cleanup_policy_started_at) {
}
if (this.item.expirationPolicyStartedAt) {
return CLEANUP_TIMED_OUT_ERROR_MESSAGE;
}
return null;
......@@ -63,23 +77,23 @@ export default {
<list-item
v-gl-tooltip="{
placement: 'left',
disabled: !item.deleting,
disabled: !deleting,
title: $options.i18n.ROW_SCHEDULED_FOR_DELETION,
}"
v-bind="$attrs"
:disabled="item.deleting"
:disabled="deleting"
>
<template #left-primary>
<router-link
class="gl-text-body gl-font-weight-bold"
data-testid="details-link"
:to="{ name: 'details', params: { id: item.id } }"
:to="{ name: 'details', params: { id } }"
>
{{ item.path }}
</router-link>
<clipboard-button
v-if="item.location"
:disabled="item.deleting"
:disabled="deleting"
:text="item.location"
:title="item.location"
category="tertiary"
......@@ -97,7 +111,7 @@ export default {
<gl-icon name="tag" class="gl-mr-2" />
<gl-sprintf :message="tagsCountText">
<template #count>
{{ item.tags_count }}
{{ item.tagsCount }}
</template>
</gl-sprintf>
</span>
......@@ -106,7 +120,7 @@ export default {
<delete-button
:title="$options.i18n.REMOVE_REPOSITORY_LABEL"
:disabled="disabledDelete"
:tooltip-disabled="Boolean(item.destroy_path)"
:tooltip-disabled="item.canDelete"
:tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED"
@delete="$emit('delete', item)"
/>
......
......@@ -44,5 +44,6 @@ export const EMPTY_RESULT_MESSAGE = s__(
// Parameters
export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled';
export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed';
export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED';
export const IMAGE_FAILED_DELETED_STATUS = 'DELETE_FAILED';
export const GRAPHQL_PAGE_SIZE = 10;
fragment ContainerRepositoryFields on ContainerRepository {
id
name
path
status
location
canDelete
createdAt
tagsCount
expirationPolicyStartedAt
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
assumeImmutableResults: true,
},
),
});
mutation destroyContainerRepository($id: ContainerRepositoryID!) {
destroyContainerRepository(input: { id: $id }) {
containerRepository {
id
status
}
errors
}
}
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "../fragments/container_repository.fragment.graphql"
query getProjectContainerRepositories(
$fullPath: ID!
$name: String
$first: Int
$last: Int
$after: String
$before: String
) {
group(fullPath: $fullPath) {
containerRepositoriesCount
containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
nodes {
...ContainerRepositoryFields
}
pageInfo {
...PageInfo
}
}
}
}
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "../fragments/container_repository.fragment.graphql"
query getProjectContainerRepositories(
$fullPath: ID!
$name: String
$first: Int
$last: Int
$after: String
$before: String
) {
project(fullPath: $fullPath) {
containerRepositoriesCount
containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
nodes {
...ContainerRepositoryFields
}
pageInfo {
...PageInfo
}
}
}
}
......@@ -5,6 +5,7 @@ import RegistryExplorer from './pages/index.vue';
import RegistryBreadcrumb from './components/registry_breadcrumb.vue';
import { createStore } from './stores';
import createRouter from './router';
import { apolloProvider } from './graphql/index';
Vue.use(Translate);
Vue.use(GlToast);
......@@ -27,6 +28,7 @@ export default () => {
el,
store,
router,
apolloProvider,
components: {
RegistryExplorer,
},
......
<script>
import { mapState, mapActions } from 'vuex';
import { mapState } from 'vuex';
import {
GlEmptyState,
GlTooltipDirective,
......@@ -11,6 +11,7 @@ import {
GlSearchBoxByClick,
} from '@gitlab/ui';
import Tracking from '~/tracking';
import createFlash from '~/flash';
import ProjectEmptyState from '../components/list_page/project_empty_state.vue';
import GroupEmptyState from '../components/list_page/group_empty_state.vue';
......@@ -18,6 +19,10 @@ import RegistryHeader from '../components/list_page/registry_header.vue';
import ImageList from '../components/list_page/image_list.vue';
import CliCommands from '../components/list_page/cli_commands.vue';
import getProjectContainerRepositories from '../graphql/queries/get_project_container_repositories.graphql';
import getGroupContainerRepositories from '../graphql/queries/get_group_container_repositories.graphql';
import deleteContainerRepository from '../graphql/mutations/delete_container_repository.graphql';
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
......@@ -29,6 +34,8 @@ import {
IMAGE_REPOSITORY_LIST_LABEL,
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
} from '../constants/index';
export default {
......@@ -66,21 +73,63 @@ export default {
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
},
apollo: {
images: {
query() {
return this.graphQlQuery;
},
variables() {
return this.queryVariables;
},
update(data) {
return data[this.graphqlResource]?.containerRepositories.nodes;
},
result({ data }) {
this.pageInfo = data[this.graphqlResource]?.containerRepositories?.pageInfo;
this.containerRepositoriesCount = data[this.graphqlResource]?.containerRepositoriesCount;
},
error() {
createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
},
},
},
data() {
return {
images: [],
pageInfo: {},
containerRepositoriesCount: 0,
itemToDelete: {},
deleteAlertType: null,
search: null,
isEmpty: false,
searchValue: null,
name: null,
mutationLoading: false,
};
},
computed: {
...mapState(['config', 'isLoading', 'images', 'pagination']),
...mapState(['config']),
graphqlResource() {
return this.config.isGroupPage ? 'group' : 'project';
},
graphQlQuery() {
return this.config.isGroupPage
? getGroupContainerRepositories
: getProjectContainerRepositories;
},
queryVariables() {
return {
name: this.name,
fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath,
first: GRAPHQL_PAGE_SIZE,
};
},
tracking() {
return {
label: 'registry_repository_delete',
};
},
isLoading() {
return this.$apollo.queries.images.loading || this.mutationLoading;
},
showCommands() {
return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length);
},
......@@ -93,19 +142,7 @@ export default {
: DELETE_IMAGE_ERROR_MESSAGE;
},
},
mounted() {
this.loadImageList(this.$route.name);
},
methods: {
...mapActions(['requestImagesList', 'requestDeleteImage']),
loadImageList(fromName) {
if (!fromName || !this.images?.length) {
return this.requestImagesList().then(() => {
this.isEmpty = this.images.length === 0;
});
}
return Promise.resolve();
},
deleteImage(item) {
this.track('click_button');
this.itemToDelete = item;
......@@ -113,18 +150,59 @@ export default {
},
handleDeleteImage() {
this.track('confirm_delete');
return this.requestDeleteImage(this.itemToDelete)
.then(() => {
this.deleteAlertType = 'success';
this.mutationLoading = true;
return this.$apollo
.mutate({
mutation: deleteContainerRepository,
variables: {
id: this.itemToDelete.id,
},
})
.then(({ data }) => {
if (data?.destroyContainerRepository?.errors[0]) {
this.deleteAlertType = 'danger';
} else {
this.deleteAlertType = 'success';
}
})
.catch(() => {
this.deleteAlertType = 'danger';
})
.finally(() => {
this.mutationLoading = false;
});
},
dismissDeleteAlert() {
this.deleteAlertType = null;
this.itemToDelete = {};
},
fetchNextPage() {
if (this.pageInfo?.hasNextPage) {
this.$apollo.queries.images.fetchMore({
variables: {
after: this.pageInfo?.endCursor,
first: GRAPHQL_PAGE_SIZE,
},
updateQuery(previousResult, { fetchMoreResult }) {
return fetchMoreResult;
},
});
}
},
fetchPreviousPage() {
if (this.pageInfo?.hasPreviousPage) {
this.$apollo.queries.images.fetchMore({
variables: {
first: null,
before: this.pageInfo?.startCursor,
last: GRAPHQL_PAGE_SIZE,
},
updateQuery(previousResult, { fetchMoreResult }) {
return fetchMoreResult;
},
});
}
},
},
};
</script>
......@@ -134,7 +212,7 @@ export default {
<gl-alert
v-if="showDeleteAlert"
:variant="deleteAlertType"
class="mt-2"
class="gl-mt-5"
dismissible
@dismiss="dismissDeleteAlert"
>
......@@ -165,7 +243,7 @@ export default {
<template v-else>
<registry-header
:images-count="pagination.total"
:images-count="containerRepositoriesCount"
:expiration-policy="config.expirationPolicy"
:help-page-path="config.helpPagePath"
:expiration-policy-help-page-path="config.expirationPolicyHelpPagePath"
......@@ -176,7 +254,7 @@ export default {
</template>
</registry-header>
<div v-if="isLoading" class="mt-2">
<div v-if="isLoading" class="gl-mt-5">
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
:key="index"
......@@ -190,16 +268,17 @@ export default {
</gl-skeleton-loader>
</div>
<template v-else>
<template v-if="!isEmpty">
<template v-if="images.length > 0 || name">
<div class="gl-display-flex gl-p-1 gl-mt-3" data-testid="listHeader">
<div class="gl-flex-fill-1">
<h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5>
</div>
<div>
<gl-search-box-by-click
v-model="search"
v-model="searchValue"
:placeholder="$options.i18n.SEARCH_PLACEHOLDER_TEXT"
@submit="requestImagesList({ name: $event })"
@clear="name = null"
@submit="name = $event"
/>
</div>
</div>
......@@ -207,9 +286,10 @@ export default {
<image-list
v-if="images.length"
:images="images"
:pagination="pagination"
@pageChange="requestImagesList({ pagination: { page: $event }, name: search })"
:page-info="pageInfo"
@delete="deleteImage"
@prev-page="fetchPreviousPage"
@next-page="fetchNextPage"
/>
<gl-empty-state
......
import { __ } from '~/locale';
export const ANY_GROUP = Object.freeze({
id: null,
name: __('Any'),
});
export const GROUP_QUERY_PARAM = 'group_id';
export const PROJECT_QUERY_PARAM = 'project_id';
import { queryToObject } from '~/lib/utils/url_utility';
import createStore from './store';
import { initTopbar } from './topbar';
import { initSidebar } from './sidebar';
import initGroupFilter from './group_filter';
export const initSearchApp = () => {
// Similar to url_utility.decodeUrlParameter
......@@ -9,6 +9,6 @@ export const initSearchApp = () => {
const sanitizedSearch = window.location.search.replace(/\+/g, '%20');
const store = createStore({ query: queryToObject(sanitizedSearch) });
initTopbar(store);
initSidebar(store);
initGroupFilter(store);
};
<script>
import { mapState, mapActions } from 'vuex';
import { isEmpty } from 'lodash';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import SearchableDropdown from './searchable_dropdown.vue';
import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants';
export default {
name: 'GroupFilter',
components: {
SearchableDropdown,
},
props: {
initialData: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
...mapState(['groups', 'fetchingGroups']),
selectedGroup() {
return isEmpty(this.initialData) ? ANY_OPTION : this.initialData;
},
},
methods: {
...mapActions(['fetchGroups']),
handleGroupChange(group) {
visitUrl(
setUrlParams({ [GROUP_DATA.queryParam]: group.id, [PROJECT_DATA.queryParam]: null }),
);
},
},
GROUP_DATA,
};
</script>
<template>
<searchable-dropdown
:header-text="$options.GROUP_DATA.headerText"
:selected-display-value="$options.GROUP_DATA.selectedDisplayValue"
:items-display-value="$options.GROUP_DATA.itemsDisplayValue"
:loading="fetchingGroups"
:selected-item="selectedGroup"
:items="groups"
@search="fetchGroups"
@change="handleGroupChange"
/>
</template>
......@@ -5,115 +5,135 @@ import {
GlSearchBoxByType,
GlLoadingIcon,
GlIcon,
GlButton,
GlSkeletonLoader,
GlTooltipDirective,
} from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { isEmpty } from 'lodash';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { ANY_GROUP, GROUP_QUERY_PARAM, PROJECT_QUERY_PARAM } from '../constants';
import { ANY_OPTION } from '../constants';
export default {
name: 'GroupFilter',
name: 'SearchableDropdown',
components: {
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
GlIcon,
GlButton,
GlSkeletonLoader,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
initialGroup: {
headerText: {
type: String,
required: false,
default: "__('Filter')",
},
selectedDisplayValue: {
type: String,
required: false,
default: 'name',
},
itemsDisplayValue: {
type: String,
required: false,
default: 'name',
},
loading: {
type: Boolean,
required: false,
default: false,
},
selectedItem: {
type: Object,
required: true,
},
items: {
type: Array,
required: false,
default: () => ({}),
default: () => [],
},
},
data() {
return {
groupSearch: '',
searchText: '',
};
},
computed: {
...mapState(['groups', 'fetchingGroups']),
selectedGroup: {
get() {
return isEmpty(this.initialGroup) ? ANY_GROUP : this.initialGroup;
},
set(group) {
visitUrl(setUrlParams({ [GROUP_QUERY_PARAM]: group.id, [PROJECT_QUERY_PARAM]: null }));
},
},
},
methods: {
...mapActions(['fetchGroups']),
isGroupSelected(group) {
return group.id === this.selectedGroup.id;
isSelected(selected) {
return selected.id === this.selectedItem.id;
},
handleGroupChange(group) {
this.selectedGroup = group;
openDropdown() {
this.$emit('search', this.searchText);
},
resetDropdown() {
this.$emit('change', ANY_OPTION);
},
},
ANY_GROUP,
ANY_OPTION,
};
</script>
<template>
<gl-dropdown
ref="groupFilter"
class="gl-w-full"
menu-class="gl-w-full!"
toggle-class="gl-text-truncate gl-reset-line-height!"
:header-text="__('Filter results by group')"
@show="fetchGroups(groupSearch)"
:header-text="headerText"
@show="$emit('search', searchText)"
@shown="$refs.searchBox.focusInput()"
>
<template #button-content>
<span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate">
{{ selectedGroup.name }}
{{ selectedItem[selectedDisplayValue] }}
</span>
<gl-loading-icon v-if="fetchingGroups" inline class="mr-2" />
<gl-icon
v-if="!isGroupSelected($options.ANY_GROUP)"
<gl-loading-icon v-if="loading" inline class="gl-mr-3" />
<gl-button
v-if="!isSelected($options.ANY_OPTION)"
v-gl-tooltip
name="clear"
category="tertiary"
:title="__('Clear')"
class="gl-text-gray-200! gl-hover-text-blue-800!"
@click.stop="handleGroupChange($options.ANY_GROUP)"
/>
class="gl-p-0! gl-mr-2"
@keydown.enter.stop="resetDropdown"
@click.stop="resetDropdown"
>
<gl-icon name="clear" class="gl-text-gray-200! gl-hover-text-blue-800!" />
</gl-button>
<gl-icon name="chevron-down" />
</template>
<div class="gl-sticky gl-top-0 gl-z-index-1 gl-bg-white">
<gl-search-box-by-type
v-model="groupSearch"
class="m-2"
ref="searchBox"
v-model="searchText"
class="gl-m-3"
:debounce="500"
@input="fetchGroups"
@input="$emit('search', searchText)"
/>
<gl-dropdown-item
class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2"
:is-check-item="true"
:is-checked="isGroupSelected($options.ANY_GROUP)"
@click="handleGroupChange($options.ANY_GROUP)"
:is-checked="isSelected($options.ANY_OPTION)"
@click="resetDropdown"
>
{{ $options.ANY_GROUP.name }}
{{ $options.ANY_OPTION.name }}
</gl-dropdown-item>
</div>
<div v-if="!fetchingGroups">
<div v-if="!loading">
<gl-dropdown-item
v-for="group in groups"
:key="group.id"
v-for="item in items"
:key="item.id"
:is-check-item="true"
:is-checked="isGroupSelected(group)"
@click="handleGroupChange(group)"
:is-checked="isSelected(item)"
@click="$emit('change', item)"
>
{{ group.full_name }}
{{ item[itemsDisplayValue] }}
</gl-dropdown-item>
</div>
<div v-if="fetchingGroups" class="mx-3 mt-2">
<div v-if="loading" class="gl-mx-4 gl-mt-3">
<gl-skeleton-loader :height="100">
<rect y="0" width="90%" height="20" rx="4" />
<rect y="40" width="70%" height="20" rx="4" />
......
import { __ } from '~/locale';
export const ANY_OPTION = Object.freeze({
id: null,
name: __('Any'),
name_with_namespace: __('Any'),
});
export const GROUP_DATA = {
headerText: __('Filter results by group'),
queryParam: 'group_id',
selectedDisplayValue: 'name',
itemsDisplayValue: 'full_name',
};
export const PROJECT_DATA = {
headerText: __('Filter results by project'),
queryParam: 'project_id',
selectedDisplayValue: 'name_with_namespace',
itemsDisplayValue: 'name_with_namespace',
};
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import GroupFilter from './components/group_filter.vue';
Vue.use(Translate);
export default store => {
let initialGroup;
const el = document.getElementById('js-search-group-dropdown');
const mountSearchableDropdown = (store, { id, component }) => {
const el = document.getElementById(id);
const { initialGroupData } = el.dataset;
if (!el) {
return false;
}
initialGroup = JSON.parse(initialGroupData);
initialGroup = convertObjectPropsToCamelCase(initialGroup, { deep: true });
let { initialData } = el.dataset;
initialData = JSON.parse(initialData);
return new Vue({
el,
store,
render(createElement) {
return createElement(GroupFilter, {
return createElement(component, {
props: {
initialGroup,
initialData,
},
});
},
});
};
const searchableDropdowns = [
{
id: 'js-search-group-dropdown',
component: GroupFilter,
},
];
export const initTopbar = store =>
searchableDropdowns.map(dropdown => mountSearchableDropdown(store, dropdown));
......@@ -38,7 +38,8 @@ module SystemNoteHelper
'status' => 'status',
'alert_issue_added' => 'issues',
'new_alert_added' => 'warning',
'severity' => 'information-o'
'severity' => 'information-o',
'cloned' => 'documents'
}.freeze
def system_note_icon_name(note)
......
......@@ -308,6 +308,7 @@ class Issue < ApplicationRecord
!moved? && persisted? &&
user.can?(:admin_issue, self.project)
end
alias_method :can_clone?, :can_move?
def to_branch_name
if self.confidential?
......
......@@ -14,12 +14,13 @@ class SystemNoteMetadata < ApplicationRecord
moved merge
label milestone
relate unrelate
cloned
].freeze
ICON_TYPES = %w[
commit description merge confidential visible label assignee cross_reference
designs_added designs_modified designs_removed designs_discussion_added
title time_tracking branch milestone discussion task moved
title time_tracking branch milestone discussion task moved cloned
opened closed merged duplicate locked unlocked outdated reviewer
tag due_date pinned_embed cherry_pick health_status approved unapproved
status alert_issue_added relate unrelate new_alert_added severity
......
# frozen_string_literal: true
module Issues
class CloneService < Issuable::Clone::BaseService
CloneError = Class.new(StandardError)
def execute(issue, target_project)
@target_project = target_project
unless issue.can_clone?(current_user, @target_project)
raise CloneError, s_('CloneIssue|Cannot clone issue due to insufficient permissions!')
end
if target_project.pending_delete?
raise CloneError, s_('CloneIssue|Cannot clone issue to target project as it is pending deletion.')
end
super(issue, target_project)
queue_copy_designs
new_entity
end
private
attr_reader :target_project
def update_new_entity
# we don't call `super` because we want to be able to decide whether or not to copy all comments over.
update_new_entity_description
update_new_entity_attributes
copy_award_emoji
end
def update_old_entity
# no-op
# The base_service closes the old issue, we don't want that, so we override here so nothing happens.
end
def create_new_entity
new_params = {
id: nil,
iid: nil,
project: target_project,
author: original_entity.author,
assignee_ids: original_entity.assignee_ids
}
new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params)
# Skip creation of system notes for existing attributes of the issue. The system notes of the old
# issue are copied over so we don't want to end up with duplicate notes.
CreateService.new(@target_project, @current_user, new_params).execute(skip_system_notes: true)
end
def queue_copy_designs
return unless original_entity.designs.present?
response = DesignManagement::CopyDesignCollection::QueueService.new(
current_user,
original_entity,
new_entity
).execute
log_error(response.message) if response.error?
end
def add_note_from
SystemNoteService.noteable_cloned(new_entity, target_project,
original_entity, current_user,
direction: :from)
end
def add_note_to
SystemNoteService.noteable_cloned(original_entity, old_project,
new_entity, current_user,
direction: :to)
end
end
end
......@@ -9,7 +9,7 @@ module Issues
handle_move_between_ids(issue)
filter_spam_check_params
change_issue_duplicate(issue)
move_issue_to_new_project(issue) || update_task_event(issue) || update(issue)
move_issue_to_new_project(issue) || clone_issue(issue) || update_task_event(issue) || update(issue)
end
def update(issue)
......@@ -127,6 +127,17 @@ module Issues
private
def clone_issue(issue)
target_project = params.delete(:target_clone_project)
return unless target_project &&
issue.can_clone?(current_user, target_project)
# we've pre-empted this from running in #execute, so let's go ahead and update the Issue now.
update(issue)
Issues::CloneService.new(project, current_user).execute(issue, target_project)
end
def create_merge_request_from_quick_action
create_merge_request_params = params.delete(:create_merge_request)
return unless create_merge_request_params
......
......@@ -226,6 +226,10 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_moved(noteable_ref, direction)
end
def noteable_cloned(noteable, project, noteable_ref, author, direction:)
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_cloned(noteable_ref, direction)
end
def mark_duplicate_issue(noteable, project, author, canonical_issue)
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).mark_duplicate_issue(canonical_issue)
end
......
......@@ -242,6 +242,27 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'moved'))
end
# Called when noteable has been cloned
#
# noteable_ref - Referenced noteable
# direction - symbol, :to or :from
#
# Example Note text:
#
# "cloned to some_namespace/project_new#11"
#
# Returns the created Note object
def noteable_cloned(noteable_ref, direction)
unless [:to, :from].include?(direction)
raise ArgumentError, "Invalid direction `#{direction}`"
end
cross_reference = noteable_ref.to_reference(project)
body = "cloned #{direction} #{cross_reference}"
create_note(NoteSummary.new(noteable, project, author, body, action: 'cloned'))
end
# Called when the confidentiality changes
#
# Example Note text:
......
......@@ -16,4 +16,5 @@
"cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
"is_admin": current_user&.admin.to_s,
is_group_page: "true",
"group_path": @group.full_path,
character_error: @character_error.to_s } }
......@@ -17,6 +17,6 @@
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
"run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'),
"cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
"project_path": @project.full_path,
"is_admin": current_user&.admin.to_s,
character_error: @character_error.to_s } }
......@@ -5,7 +5,7 @@
.dropdown.form-group.mb-lg-0.mx-lg-1.gl-p-0{ data: { testid: "group-filter" } }
%label.d-block{ for: "dashboard_search_group" }
= _("Group")
%input#js-search-group-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-group-data": @group.to_json } }
%input#js-search-group-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-data": @group.to_json } }
.dropdown.form-group.mb-lg-0.mx-lg-1{ data: { testid: "project-filter" } }
%label.d-block{ for: "dashboard_search_project" }
= _("Project")
......
---
title: Refactor container registry list page to grapqhl
merge_request: 48602
author:
type: changed
---
title: Implement a /clone quick-action to quickly clone an Issue
merge_request: 48394
author:
type: added
......@@ -99,6 +99,7 @@ exceptions:
- SEO
- SHA
- SLA
- SMS
- SMTP
- SQL
- SSD
......
......@@ -484,6 +484,7 @@ sudo
swimlane
swimlanes
syslog
tanuki
tcpdump
Thanos
Tiller
......
......@@ -144,7 +144,7 @@ package (highly recommended), follow the steps below:
Before beginning, you should already have a working GitLab instance. [Learn how
to install GitLab](https://about.gitlab.com/install/).
Provision a PostgreSQL server (PostgreSQL 11 or newer).
Provision a PostgreSQL server (PostgreSQL 11 or newer).
Prepare all your new nodes by [installing
GitLab](https://about.gitlab.com/install/).
......
......@@ -185,7 +185,7 @@ Feature.enable(:ci_enable_live_trace)
```
NOTE: **Note:**
The transition period is handled gracefully. Upcoming logs are
The transition period is handled gracefully. Upcoming logs are
generated with the incremental architecture, and on-going logs stay with the
legacy architecture, which means that on-going logs aren't forcibly
re-generated with the incremental architecture.
......
......@@ -55,7 +55,7 @@ guides you through the process.
NOTE: **Note:**
After the Packages feature is enabled, the repositories are available
for all new projects by default. To enable it for existing projects, users
for all new projects by default. To enable it for existing projects, users
explicitly do so in the project's settings.
To enable the Packages feature:
......
......@@ -94,7 +94,7 @@ Upload a logo to your GitLab instance.
To upload an avatar from your file system, use the `--form` argument. This causes
cURL to post data using the header `Content-Type: multipart/form-data`. The
`file=` parameter must point to an image file on your file system and be
`file=` parameter must point to an image file on your file system and be
preceded by `@`.
```plaintext
......
......@@ -533,7 +533,9 @@ tenses, words, and phrases:
content is accessible to more readers.
- Don't write in the first person singular.
(Tested in [`FirstPerson.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/.vale/gitlab/FirstPerson.yml).)
<!-- vale gitlab.FirstPerson = NO -->
- Instead of _I_ or _me_, use _we_, _you_, _us_, or _one_.
<!-- vale gitlab.FirstPerson = YES -->
- When possible, stay user focused by writing in the second person (_you_ or
the imperative).
- Don't overuse "that". In many cases, you can remove "that" from a sentence
......@@ -795,6 +797,8 @@ Items nested in lists should always align with the first character of the list
item. In unordered lists (using `-`), this means two spaces for each level of
indentation:
<!-- vale off -->
````markdown
- Unordered list item 1
......@@ -816,8 +820,12 @@ indentation:
![an image that will nest inside list item 4](image.png)
````
<!-- vale on -->
For ordered lists, use three spaces for each level of indentation:
<!-- vale off -->
````markdown
1. Ordered list item 1
......@@ -839,6 +847,8 @@ For ordered lists, use three spaces for each level of indentation:
![an image that will nest inside list item 4](image.png)
````
<!-- vale on -->
You can nest full lists inside other lists using the same rules as above. If you
want to mix types, that's also possible, if you don't mix items at the same
level:
......@@ -904,7 +914,7 @@ Valid for Markdown content only, not for front matter entries:
- Standard quotes: double quotes (`"`). Example: "This is wrapped in double
quotes".
- Quote inside a quote: double quotes (`"`) wrap single quotes (`'`). Example:
"I am 'quoting' something in a quote".
"This sentence 'quotes' something in a quote".
For other punctuation rules, refer to the
[GitLab UX guide](https://design.gitlab.com/content/punctuation/).
......@@ -1367,6 +1377,8 @@ hidden on the documentation site, but is displayed by `/help`.
- For regular fenced code blocks, always use a highlighting class corresponding to
the language for better readability. Examples:
<!-- vale off -->
````markdown
```ruby
Ruby code
......@@ -1385,6 +1397,8 @@ hidden on the documentation site, but is displayed by `/help`.
```
````
<!-- vale on -->
Syntax highlighting is required for fenced code blocks added to the GitLab
documentation. Refer to the following table for the most common language classes,
or check the [complete list](https://github.com/rouge-ruby/rouge/wiki/List-of-supported-languages-and-lexers)
......@@ -1771,8 +1785,7 @@ for use in GitLab X.X, and is planned for [removal](link-to-issue) in GitLab X.X
```
After the feature or product is officially deprecated and removed, remove
its information from the GitLab documentation based on
the GitLab version where it's actually removed.
its information from the GitLab documentation.
### Versions in the past or future
......@@ -1926,6 +1939,8 @@ Configuration settings include:
When you document a list of steps, it may entail editing the configuration file
and reconfiguring or restarting GitLab. In that case, use these styles:
<!-- vale off -->
````markdown
**For Omnibus installations**
......@@ -1953,6 +1968,8 @@ and reconfiguring or restarting GitLab. In that case, use these styles:
GitLab for the changes to take effect.
````
<!-- vale on -->
In this case:
- Before each step list the installation method is declared in bold.
......
......@@ -119,21 +119,21 @@ browser's developer console while on any page within GitLab.
```
Note that `waitForCSSLoaded()` methods supports receiving the action in different ways:
- With a callback:
```javascript
waitForCSSLoaded(action)
```
- With `then()`:
```javascript
waitForCSSLoaded().then(action);
```
- With `await` followed by `action`:
```javascript
await waitForCSSLoaded;
action();
......
......@@ -131,6 +131,8 @@ Once [recursive includes](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/568
become available, you can share job templates like this
[analyzer](https://gitlab.com/gitlab-org/security-products/ci-templates/raw/master/includes-dev/analyzer.yml).
Go GitLab linter plugins are maintained in the [`gitlab-org/language-tools/go/linters`](https://gitlab.com/gitlab-org/language-tools/go/linters/) namespace.
## Dependencies
Dependencies should be kept to the minimum. The introduction of a new
......
......@@ -243,8 +243,8 @@ end
The iteration uses the primary key index (on the `id` column) which makes it safe from statement
timeouts. The filter (`sign_in_count: 0`) is applied on the `relation` where the `id` is already constrained (range). The number of rows are limited.
Slow iteration generally takes more time to finish. The iteration count is higher and
one iteration could yield fewer records than the batch size. Iterations may even yield
Slow iteration generally takes more time to finish. The iteration count is higher and
one iteration could yield fewer records than the batch size. Iterations may even yield
0 records. This is not an optimal solution; however, in some cases (especially when
dealing with large tables) this is the only viable option.
......@@ -346,7 +346,7 @@ Here, we expect that the `relation` query reads the `BATCH_SIZE` of user records
filters down the results according to the provided queries. The planner might decide that
using a bitmap index lookup with the index on the `confidential` column is a better way to
execute the query. This can cause unexpectedly high amount of rows to be read and the query
could time out.
could time out.
Problem: we know for sure that the relation is returning maximum `BATCH_SIZE` of records, however the planner does not know this.
......
......@@ -462,8 +462,9 @@ class MyMigration < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
INDEX_NAME = 'index_name'
def up
remove_concurrent_index :table_name, :column_name, name: :index_name
remove_concurrent_index :table_name, :column_name, name: INDEX_NAME
end
end
```
......
......@@ -118,7 +118,7 @@ sequenceDiagram
1. `GitLab::UsageData.to_json` [cascades down](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/usage_data.rb#L22) to ~400+ other counter method calls.
1. The response of all methods calls are [merged together](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/usage_data.rb#L14) into a single JSON payload in `GitLab::UsageData.to_json`.
1. The JSON payload is then [posted to the Versions application]( https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/services/submit_usage_ping_service.rb#L20)
If a firewall exception is needed, the required URL depends on several things. If
If a firewall exception is needed, the required URL depends on several things. If
the hostname is `version.gitlab.com`, the protocol is `TCP`, and the port number is `443`,
the required URL is <https://version.gitlab.com/>.
......@@ -477,11 +477,11 @@ Next, get the unique events for the current week.
We have the following recommendations for [Adding new events](#adding-new-events):
- Event aggregation: weekly.
- Key expiry time:
- Key expiry time:
- Daily: 29 days.
- Weekly: 42 days.
- When adding new metrics, use a [feature flag](../../operations/feature_flags.md) to control the impact.
- For feature flags triggered by another service, set `default_enabled: false`,
- For feature flags triggered by another service, set `default_enabled: false`,
- Events can be triggered using the `UsageData` API, which helps when there are > 10 events per change
##### Enable/Disable Redis HLL tracking
......
......@@ -27,7 +27,7 @@ After adding a new queue, run `bin/rake
gitlab:sidekiq:all_queues_yml:generate` to regenerate
`app/workers/all_queues.yml` or `ee/app/workers/all_queues.yml` so that
it can be picked up by
[`sidekiq-cluster`](../administration/operations/extra_sidekiq_processes.md).
[`sidekiq-cluster`](../administration/operations/extra_sidekiq_processes.md).
Additionally, run
`bin/rake gitlab:sidekiq:sidekiq_queues_yml:generate` to regenerate
`config/sidekiq_queues.yml`.
......
......@@ -106,7 +106,7 @@ Remember that the performance of each test depends on the environment.
### Timout error due to async components
If your component is fetching some other components asynchroneously based on some conditions, it might happen so that your Jest suite for this component will become flaky timing out from time to time.
If your component is fetching some other components asynchroneously based on some conditions, it might happen so that your Jest suite for this component will become flaky timing out from time to time.
```javascript
// ide.vue
......
......@@ -227,7 +227,7 @@ The storage requirements for Redis are minimal, about 25kB per user.
Sidekiq processes the background jobs with a multithreaded process.
This process starts with the entire Rails stack (200MB+) but it can grow over time due to memory leaks.
On a very active server (10,000 billable users) the Sidekiq process can use 1GB+ of memory.
## Prometheus and its exporters
As of Omnibus GitLab 9.0, [Prometheus](https://prometheus.io) and its related
......
......@@ -196,14 +196,14 @@ WebHook Error => execution expired
```
If those are present, the request is exceeding the
[webhook timeout](../user/project/integrations/webhooks.md#receiving-duplicate-or-multiple-webhook-requests-triggered-by-one-event),
[webhook timeout](../user/project/integrations/webhooks.md#webhook-fails-or-multiple-webhook-requests-are-triggered),
which is set to 10 seconds by default.
To fix this the `gitlab_rails['webhook_timeout']` value must be increased
in the `gitlab.rb` config file, followed by the [`gitlab-ctl reconfigure` command](../administration/restart_gitlab.md).
If you don't find the errors above, but do find *duplicate* entries like below (in `/var/log/gitlab/gitlab-rail`), this
could also indicate that [webhook requests are timing out](../user/project/integrations/webhooks.md#receiving-duplicate-or-multiple-webhook-requests-triggered-by-one-event):
could also indicate that [webhook requests are timing out](../user/project/integrations/webhooks.md#webhook-fails-or-multiple-webhook-requests-are-triggered):
```plaintext
2019-10-25_04:22:41.25630 2019-10-25T04:22:41.256Z 1584 TID-ovowh4tek WebHookWorker JID-941fb7f40b69dff3d833c99b INFO: start
......
......@@ -64,7 +64,7 @@ Feature.enable(:sourcegraph, Project.find_by_full_path('my_group/my_project'))
If you are new to Sourcegraph, head over to the [Sourcegraph installation documentation](https://docs.sourcegraph.com/admin) and get your instance up and running.
If you are using an HTTPS connection to GitLab, you will need to [configure HTTPS](https://docs.sourcegraph.com/admin/http_https_configuration) for your Sourcegraph instance.
If you are using an HTTPS connection to GitLab, you will need to [configure HTTPS](https://docs.sourcegraph.com/admin/http_https_configuration) for your Sourcegraph instance.
### Connect your Sourcegraph instance to your GitLab instance
......
......@@ -266,12 +266,12 @@ You can exclude specific directories from the backup by adding the environment v
- `lfs` (LFS objects)
- `registry` (Container Registry images)
- `pages` (Pages content)
- `repositories` (Git repositories data)
- `repositories` (Git repositories data)
All wikis will be backed up as part of the `repositories` group. Non-existent wikis will be skipped during a backup.
NOTE: **Note:**
When [backing up and restoring Helm Charts](https://docs.gitlab.com/charts/architecture/backup-restore.html), there is an additional option `packages`, which refers to any packages managed by the GitLab [package registry](../user/packages/package_registry/index.md).
When [backing up and restoring Helm Charts](https://docs.gitlab.com/charts/architecture/backup-restore.html), there is an additional option `packages`, which refers to any packages managed by the GitLab [package registry](../user/packages/package_registry/index.md).
For more information see [command line arguments](https://docs.gitlab.com/charts/architecture/backup-restore.html#command-line-arguments).
All wikis are backed up as part of the `repositories` group. Non-existent
......
......@@ -130,13 +130,13 @@ always take the latest Secret Detection artifact available.
> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/4639) in GitLab 13.6.
Upon detection of a secret, GitLab supports post processing hooks. These can be used to take actions like notifying the cloud service who issued the secret. The cloud provider can confirm the credentials and take remediation actions like revoking or reissuing a new secret and notifying the creator of the secret. Post-processing workflows vary by supported cloud providers.
Upon detection of a secret, GitLab supports post processing hooks. These can be used to take actions like notifying the cloud service who issued the secret. The cloud provider can confirm the credentials and take remediation actions like revoking or reissuing a new secret and notifying the creator of the secret. Post-processing workflows vary by supported cloud providers.
GitLab currently supports post-processing for following service providers:
- Amazon Web Services (AWS)
Third party cloud and SaaS providers can [express integration interest by filling out this form](https://forms.gle/wWpvrtLRK21Q2WJL9). Learn more about the [technical details of post-processing secrets](https://gitlab.com/groups/gitlab-org/-/epics/4639).
Third party cloud and SaaS providers can [express integration interest by filling out this form](https://forms.gle/wWpvrtLRK21Q2WJL9). Learn more about the [technical details of post-processing secrets](https://gitlab.com/groups/gitlab-org/-/epics/4639).
### Customizing settings
......@@ -286,14 +286,14 @@ For information on this, see the [general Application Security troubleshooting s
### Error: `Couldn't run the gitleaks command: exit status 2`
This error is usually caused by the `GIT_DEPTH` value of 50 that is set for all [projects by default](../../../ci/pipelines/settings.md#git-shallow-clone).
This error is usually caused by the `GIT_DEPTH` value of 50 that is set for all [projects by default](../../../ci/pipelines/settings.md#git-shallow-clone).
For example, if a pipeline is triggered from a Merge Request containing 60 commits while the `GIT_DEPTH` is set to 50, the Secret Detection job will fail as the clone will not have been deep enough to contain all of the relevant commits.
For example, if a pipeline is triggered from a Merge Request containing 60 commits while the `GIT_DEPTH` is set to 50, the Secret Detection job will fail as the clone will not have been deep enough to contain all of the relevant commits.
You can confirm this to be the cause of the error by implementing a [logging level](../../application_security/secret_detection/index.md#logging-level) of `debug`. Once implemented, the logs should look similar to the following example, wherein an "object not found" error can be seen:
```plaintext
ERRO[2020-11-18T18:05:52Z] object not found
ERRO[2020-11-18T18:05:52Z] object not found
[ERRO] [secrets] [2020-11-18T18:05:52Z] ▶ Couldn't run the gitleaks command: exit status 2
[ERRO] [secrets] [2020-11-18T18:05:52Z] ▶ Gitleaks analysis failed: exit status 2
```
......
......@@ -241,7 +241,7 @@ Users can unlink SAML for a group from their profile page. This can be helpful i
- Your SAML NameID has changed and so GitLab can no longer find your user.
CAUTION: **Warning:**
Unlinking an account removes all roles assigned to that user within the group.
Unlinking an account removes all roles assigned to that user within the group.
If a user relinks their account, roles need to be reassigned.
For example, to unlink the `MyOrg` account, the following **Disconnect** button is available under **Profile > Accounts**:
......@@ -274,14 +274,14 @@ To link the SAML `Freelancers` group in the attribute statement example above:
1. Enter `Freelancers` in the `SAML Group Name` field.
1. Choose the desired `Access Level`.
1. **Save** the group link.
1. Repeat to add additional group links if desired.
1. **Save** the group link.
1. Repeat to add additional group links if desired.
![SAML Group Links](img/saml_group_links_v13_6.png)
If a user is a member of multiple SAML groups mapped to the same GitLab group,
If a user is a member of multiple SAML groups mapped to the same GitLab group,
the user gets the highest access level from the groups. For example, if one group
is linked as `Guest` and another `Maintainer`, a user in both groups gets `Maintainer`
is linked as `Guest` and another `Maintainer`, a user in both groups gets `Maintainer`
access.
## Glossary
......
......@@ -292,7 +292,7 @@ Prerequisites:
- [Authentication](#authenticate-to-the-package-registry) with the
Package Registry must be configured.
1. In the project where you want to install the package as a dependency, open
1. In the project where you want to install the package as a dependency, open
`conanfile.txt`. Or, in the root of your project, create a file called
`conanfile.txt`.
......
......@@ -32,7 +32,7 @@ You can also use the [API](../../api/packages.md) to administer the Package Regi
## Accepting contributions
The below table lists formats that are not supported, but are accepting Community contributions for. Consider contributing to GitLab. This [development documentation](../../development/packages.md)
The below table lists formats that are not supported, but are accepting Community contributions for. Consider contributing to GitLab. This [development documentation](../../development/packages.md)
guides you through the process.
| Format | Status |
......
......@@ -23,7 +23,7 @@ access to your Jira projects. This is covered in the process below.
1. The next step is to create a new user (e.g., `gitlab`) who has write access
to projects in Jira. Enter the user's name and a _valid_ e-mail address
since Jira sends a verification e-mail to set up the password.
Jira creates the username automatically by using the e-mail
prefix. You can change it later, if needed. Our integration does not support SSO (such as SAML). You
need to create an HTTP basic authentication password. You can do this by visiting the user
......
......@@ -11,8 +11,8 @@ a new issue is created. You can configure webhooks to listen for specific events
like pushes, issues or merge requests. GitLab sends a POST request with data
to the webhook URL.
In most cases, you need to set up your own [webhook receiver](#example-webhook-receiver)
to receive information from GitLab, and send it to another app, according to your needs.
You usually need to set up your own [webhook receiver](#example-webhook-receiver)
to receive information from GitLab and send it to another app, according to your requirements.
We already have a [built-in receiver](slack.md)
for sending [Slack](https://api.slack.com/incoming-webhooks) notifications _per project_.
......@@ -33,7 +33,7 @@ and **per project and per group** for **GitLab Enterprise Edition**.
Navigate to the webhooks page at your project's **Settings > Webhooks**.
NOTE: **Note:**
NOTE:
On GitLab.com, the [maximum number of webhooks and their size](../../../user/gitlab_com/index.md#webhooks) per project, and per group, is limited.
## Version history
......@@ -54,7 +54,7 @@ Starting from GitLab 11.2:
`![](/uploads/...)`) have their target URL changed to an absolute URL. See
[image URL rewriting](#image-url-rewriting) for more details.
## Use-cases
## Possible uses for webhooks
- You can set up a webhook in GitLab to send a notification to
[Slack](https://api.slack.com/incoming-webhooks) every time a job fails.
......@@ -65,12 +65,12 @@ Starting from GitLab 11.2:
## Webhook endpoint tips
If you are writing your own endpoint (web server) to receive
GitLab webhooks, keep in mind the following things:
GitLab webhooks, keep in mind the following:
- Your endpoint should send its HTTP response as fast as possible. If
you wait too long (by default, a timeout of 10 seconds), GitLab may decide
the hook failed and retry it. You can configure this timeout with
`gitlab_rails['webhook_timeout']`.
- Your endpoint should send its HTTP response as fast as possible. If the response takes longer than
the configured timeout, GitLab decides the hook failed and retries it. For information on
customizing this timeout, see
[Webhook fails or multiple webhook requests are triggered](#webhook-fails-or-multiple-webhook-requests-are-triggered).
- Your endpoint should ALWAYS return a valid HTTP response. If you do
not do this then GitLab thinks the hook failed and retries it.
Most HTTP libraries take care of this for you automatically but if
......@@ -86,7 +86,7 @@ that the request is legitimate.
## SSL verification
By default, the SSL certificate of the webhook endpoint is verified based on
an internal list of Certificate Authorities, which means the certificate cannot
an internal list of Certificate Authorities. This means the certificate cannot
be self-signed.
You can turn this off in the webhook settings in your GitLab projects.
......@@ -109,7 +109,7 @@ Below are described the supported events.
Triggered when you push to the repository except when pushing tags.
NOTE: **Note:**
NOTE:
When more than 20 commits are pushed at once, the `commits` webhook
attribute only contains the first 20 for performance reasons. Loading
detailed commit data is expensive. Note that despite only 20 commits being
......@@ -204,7 +204,7 @@ X-Gitlab-Event: Push Hook
Triggered when you create (or delete) tags to the repository.
NOTE: **Note:**
NOTE:
If a single push includes changes for more than three (by default, depending on
[`push_event_hooks_limit` setting](../../../api/settings.md#list-of-settings-that-can-be-accessed-via-api-calls))
tags, this hook is not executed.
......@@ -409,12 +409,12 @@ X-Gitlab-Event: Issue Hook
}
```
> **Note**: `assignee` and `assignee_id` keys are deprecated and now show the first assignee only.
NOTE: `assignee` and `assignee_id` keys are deprecated and now show the first assignee only.
### Comment events
Triggered when a new comment is made on commits, merge requests, issues, and code snippets.
The note data is stored in `object_attributes` (e.g. `note`, `noteable_type`). The
The note data is stored in `object_attributes` (for example, `note` or `noteable_type`). The
payload also includes information about the target of the comment. For example,
a comment on an issue includes the specific issue information under the `issue` key.
Valid target types:
......@@ -734,7 +734,7 @@ X-Gitlab-Event: Note Hook
}
```
> **Note**: `assignee_id` field is deprecated and now shows the first assignee only.
NOTE: `assignee_id` field is deprecated and now shows the first assignee only.
#### Comment on code snippet
......@@ -1531,29 +1531,36 @@ You can find records for last 2 days in "Recent Deliveries" section on the edit
![Recent deliveries](img/webhook_logs.png)
In this section you can see HTTP status code (green for 200-299 codes, red for the others, `internal error` for failed deliveries ), triggered event, a time when the event was called, elapsed time of the request.
In this section you can see:
- HTTP status code (green for `200-299` codes, red for the others, `internal error` for failed deliveries).
- Triggered event.
- A time when the event was called.
- Elapsed time of the request.
If you need more information about execution, you can click `View details` link.
On this page, you can see data that GitLab sends (request headers and body) and data that it received (response headers and body).
From this page, you can repeat delivery with the same data by clicking `Resend Request` button.
NOTE: **Note:**
NOTE:
If URL or secret token of the webhook were updated, data is delivered to the new address.
### Receiving duplicate or multiple webhook requests triggered by one event
### Webhook fails or multiple webhook requests are triggered
When GitLab sends a webhook, it expects a response in 10 seconds (set default value). If it does not receive one, it retries the webhook.
If the endpoint doesn't send its HTTP response within those 10 seconds, GitLab may decide the hook failed and retry it.
When GitLab sends a webhook, it expects a response in 10 seconds by default. If it does not receive
one, it retries the webhook. If the endpoint doesn't send its HTTP response within those 10 seconds,
GitLab may decide the hook failed and retry it.
If you are receiving multiple requests, you can try increasing the default value to wait for the HTTP response after sending the webhook
by uncommenting or adding the following setting to your `/etc/gitlab/gitlab.rb`:
If your webhooks are failing or you are receiving multiple requests, you can try changing the
default value. You can do this by uncommenting or adding the following setting to your
`/etc/gitlab/gitlab.rb` file:
```ruby
gitlab_rails['webhook_timeout'] = 10
```
### Troubleshooting: "Unable to get local issuer certificate"
### Unable to get local issuer certificate
When SSL verification is enabled, this error indicates that GitLab isn't able to verify the SSL certificate of the webhook endpoint.
Typically, this is because the root certificate isn't issued by a trusted certification authority as
......@@ -1584,7 +1591,7 @@ end
server.start
```
Pick an unused port (e.g. 8000) and start the script: `ruby print_http_body.rb
Pick an unused port (for example, `8000`) and start the script: `ruby print_http_body.rb
8000`. Then add your server as a webhook receiver in GitLab as
`http://my.host:8000/`.
......@@ -1597,5 +1604,6 @@ example.com - - [14/May/2014:07:45:26 EDT] "POST / HTTP/1.1" 200 0
- -> /
```
NOTE: **Note:**
You may need to [allow requests to the local network](../../../security/webhooks.md) for this receiver to be added.
NOTE:
You may need to [allow requests to the local network](../../../security/webhooks.md) for this
receiver to be added.
......@@ -34,6 +34,7 @@ The following quick actions are applicable to descriptions, discussions and thre
| `/award :emoji:` | ✓ | ✓ | ✓ | Toggle emoji award. |
| `/child_epic <epic>` | | | ✓ | Add child epic to `<epic>`. The `<epic>` value should be in the format of `&epic`, `group&epic`, or a URL to an epic ([introduced in GitLab 12.0](https://gitlab.com/gitlab-org/gitlab/-/issues/7330)). **(ULTIMATE)** |
| `/clear_weight` | ✓ | | | Clear weight. **(STARTER)** |
| `/clone <path/to/project>` | ✓ | | | Clone the issue to given project, or the current one if no arguments are given ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9421) in GitLab 13.7). Copies as much data as possible as long as the target project contains equivalent labels, milestones, etc. Does not copy comments or system notes. |
| `/close` | ✓ | ✓ | ✓ | Close. |
| `/confidential` | ✓ | | | Make confidential. |
| `/copy_metadata <!merge_request>` | ✓ | ✓ | | Copy labels and milestone from another merge request in the project. |
......
......@@ -79,7 +79,7 @@ To create a new release through the GitLab UI:
[release notes](#release-notes-description), or [assets links](#links).
1. Click **Create release**.
### Create release from GitLab CI
### Create release from GitLab CI
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/19298) in GitLab 12.7.
......
......@@ -16,6 +16,9 @@ Project access tokens are supported for self-managed instances on Core and above
> - [Became available on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/235765) in 13.5.
> - It's recommended for production use.
CAUTION: **Warning:**
This feature might not be available to you. Check the **version history** note above for details.
Project access tokens are scoped to a project and can be used to authenticate with the [GitLab API](../../../api/README.md#personalproject-access-tokens). You can also use project access tokens with Git to authenticate over HTTP.
Project access tokens expire on the date you define, at midnight UTC.
......@@ -75,3 +78,33 @@ the following table.
| `write_registry` | Allows write-access (push) to [container registry](../../packages/container_registry/index.md). |
| `read_repository` | Allows read-only access (pull) to the repository. |
| `write_repository` | Allows read-write access (pull, push) to the repository. |
### Enable or disable project access tokens
Project access tokens are deployed behind a feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
can disable it for your instance, globally or by project.
To disable it globally:
```ruby
Feature.disable(:resource_access_token)
```
To disable it for a specific project:
```ruby
Feature.disable(:resource_access_token, project)
```
To enable it globally:
```ruby
Feature.enable(:resource_access_token)
```
To enable it for a specific project:
```ruby
Feature.enable(:resource_access_token, project)
```
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Issues::CloneService do
let_it_be(:user) { create(:user) }
let_it_be(:author) { create(:user) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:epic) { create(:epic, group: group) }
let_it_be(:sub_group_1) { create(:group, :private, parent: group) }
let_it_be(:old_project) { create(:project, namespace: group) }
let_it_be(:new_project) { create(:project, namespace: sub_group_1) }
let_it_be(:old_issue) { create(:issue, project: old_project, author: author, epic: epic) }
subject(:clone_service) do
described_class.new(old_project, user)
end
let(:new_issue) { clone_service.execute(old_issue, new_project) }
context 'user has enough permissions' do
before do
old_project.add_reporter(user)
new_project.add_reporter(user)
end
it 'does not copy epic' do
expect(new_issue.epic).to be_nil
end
end
end
......@@ -102,6 +102,30 @@ module Gitlab
@execution_message[:duplicate] = message
end
desc _('Clone this issue')
explanation do |project = quick_action_target.project.full_path|
_("Clones this issue, without comments, to %{project}.") % { project: project }
end
params 'path/to/project'
types Issue
condition do
quick_action_target.persisted? &&
current_user.can?(:"admin_#{quick_action_target.to_ability_name}", project)
end
command :clone do |target_project_path = nil|
target_project = target_project_path.present? ? Project.find_by_full_path(target_project_path) : quick_action_target.project
if target_project.present?
@updates[:target_clone_project] = target_project
message = _("Cloned this issue to %{path_to_project}.") % { path_to_project: target_project_path || quick_action_target.project.full_path }
else
message = _("Failed to clone this issue because target project doesn't exist.")
end
@execution_message[:clone] = message
end
desc _('Move this issue to another project.')
explanation do |path_to_project|
_("Moves this issue to %{path_to_project}.") % { path_to_project: path_to_project }
......
......@@ -5665,6 +5665,9 @@ msgstr ""
msgid "Clone repository"
msgstr ""
msgid "Clone this issue"
msgstr ""
msgid "Clone with %{http_label}"
msgstr ""
......@@ -5677,6 +5680,18 @@ msgstr ""
msgid "Clone with SSH"
msgstr ""
msgid "CloneIssue|Cannot clone issue due to insufficient permissions!"
msgstr ""
msgid "CloneIssue|Cannot clone issue to target project as it is pending deletion."
msgstr ""
msgid "Cloned this issue to %{path_to_project}."
msgstr ""
msgid "Clones this issue, without comments, to %{project}."
msgstr ""
msgid "Close"
msgstr ""
......@@ -11424,6 +11439,9 @@ msgstr ""
msgid "Failed to check related branches."
msgstr ""
msgid "Failed to clone this issue because target project doesn't exist."
msgstr ""
msgid "Failed to create Merge Request. Please try again."
msgstr ""
......
......@@ -51,13 +51,6 @@ RSpec.describe 'Container Registry', :js do
expect(page).to have_content 'my/image'
end
it 'image repository delete is disabled' do
visit_container_registry
delete_btn = find('[title="Remove repository"]')
expect(delete_btn).to be_disabled
end
it 'navigates to repo details' do
visit_container_registry_details('my/image')
......
......@@ -43,5 +43,6 @@ RSpec.describe 'Issues > User uses quick actions', :js do
it_behaves_like 'create_merge_request quick action'
it_behaves_like 'move quick action'
it_behaves_like 'zoom quick actions'
it_behaves_like 'clone quick action'
end
end
......@@ -94,7 +94,8 @@ RSpec.describe 'Container Registry', :js do
end
it('pagination navigate to the second page') do
visit_second_page
visit_details_second_page
expect(page).to have_content '20'
end
end
......@@ -116,22 +117,23 @@ RSpec.describe 'Container Registry', :js do
context 'when there are more than 10 images' do
before do
create_list(:container_repository, 12, project: project)
project.container_repositories << container_repository
create_list(:container_repository, 12, project: project)
visit_container_registry
end
it 'shows pagination' do
expect(page).to have_css '.gl-pagination'
expect(page).to have_css '.gl-keyset-pagination'
end
it 'pagination goes to second page' do
visit_second_page
visit_list_next_page
expect(page).to have_content 'my/image'
end
it 'pagination is preserved after navigating back from details' do
visit_second_page
visit_list_next_page
click_link 'my/image'
breadcrumb = find '.breadcrumbs'
breadcrumb.click_link 'Container Registry'
......@@ -148,7 +150,12 @@ RSpec.describe 'Container Registry', :js do
click_link name
end
def visit_second_page
def visit_list_next_page
pagination = find '.gl-keyset-pagination'
pagination.click_button 'Next'
end
def visit_details_second_page
pagination = find '.gl-pagination'
pagination.click_link '2'
end
......
import { shallowMount } from '@vue/test-utils';
import { GlIcon, GlSprintf } from '@gitlab/ui';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Component from '~/registry/explorer/components/list_page/image_list_row.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
......@@ -11,13 +12,15 @@ import {
REMOVE_REPOSITORY_LABEL,
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
CLEANUP_TIMED_OUT_ERROR_MESSAGE,
IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_FAILED_DELETED_STATUS,
} from '~/registry/explorer/constants';
import { RouterLink } from '../../stubs';
import { imagesListResponse } from '../../mock_data';
describe('Image List Row', () => {
let wrapper;
const item = imagesListResponse.data[0];
const [item] = imagesListResponse;
const findDetailsLink = () => wrapper.find('[data-testid="details-link"]');
const findTagsCount = () => wrapper.find('[data-testid="tagsCount"]');
......@@ -50,13 +53,15 @@ describe('Image List Row', () => {
describe('main tooltip', () => {
it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => {
mountComponent();
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION);
});
it('is disabled when item is being deleted', () => {
mountComponent({ item: { ...item, deleting: true } });
mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } });
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
expect(tooltip.value.disabled).toBe(false);
});
......@@ -65,12 +70,13 @@ describe('Image List Row', () => {
describe('image title and path', () => {
it('contains a link to the details page', () => {
mountComponent();
const link = findDetailsLink();
expect(link.html()).toContain(item.path);
expect(link.props('to')).toMatchObject({
name: 'details',
params: {
id: item.id,
id: getIdFromGraphQLId(item.id),
},
});
});
......@@ -85,16 +91,18 @@ describe('Image List Row', () => {
describe('warning icon', () => {
it.each`
failedDelete | cleanup_policy_started_at | shown | title
${true} | ${true} | ${true} | ${ASYNC_DELETE_IMAGE_ERROR_MESSAGE}
${false} | ${true} | ${true} | ${CLEANUP_TIMED_OUT_ERROR_MESSAGE}
${false} | ${false} | ${false} | ${''}
status | expirationPolicyStartedAt | shown | title
${IMAGE_FAILED_DELETED_STATUS} | ${true} | ${true} | ${ASYNC_DELETE_IMAGE_ERROR_MESSAGE}
${''} | ${true} | ${true} | ${CLEANUP_TIMED_OUT_ERROR_MESSAGE}
${''} | ${false} | ${false} | ${''}
`(
'when failedDelete is $failedDelete and cleanup_policy_started_at is $cleanup_policy_started_at',
({ cleanup_policy_started_at, failedDelete, shown, title }) => {
mountComponent({ item: { ...item, failedDelete, cleanup_policy_started_at } });
'when status is $status and expirationPolicyStartedAt is $expirationPolicyStartedAt',
({ expirationPolicyStartedAt, status, shown, title }) => {
mountComponent({ item: { ...item, status, expirationPolicyStartedAt } });
const icon = findWarningIcon();
expect(icon.exists()).toBe(shown);
if (shown) {
const tooltip = getBinding(icon.element, 'gl-tooltip');
expect(tooltip.value.title).toBe(title);
......@@ -112,30 +120,33 @@ describe('Image List Row', () => {
it('has the correct props', () => {
mountComponent();
expect(findDeleteBtn().attributes()).toMatchObject({
expect(findDeleteBtn().props()).toMatchObject({
title: REMOVE_REPOSITORY_LABEL,
tooltipdisabled: `${Boolean(item.destroy_path)}`,
tooltiptitle: LIST_DELETE_BUTTON_DISABLED,
tooltipDisabled: item.canDelete,
tooltipTitle: LIST_DELETE_BUTTON_DISABLED,
});
});
it('emits a delete event', () => {
mountComponent();
findDeleteBtn().vm.$emit('delete');
expect(wrapper.emitted('delete')).toEqual([[item]]);
});
it.each`
destroy_path | deleting | state
${null} | ${null} | ${'true'}
${null} | ${true} | ${'true'}
${'foo'} | ${true} | ${'true'}
${'foo'} | ${false} | ${undefined}
canDelete | status | state
${false} | ${''} | ${true}
${false} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true}
${true} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true}
${true} | ${''} | ${false}
`(
'disabled is $state when destroy_path is $destroy_path and deleting is $deleting',
({ destroy_path, deleting, state }) => {
mountComponent({ item: { ...item, destroy_path, deleting } });
expect(findDeleteBtn().attributes('disabled')).toBe(state);
'disabled is $state when canDelete is $canDelete and status is $status',
({ canDelete, status, state }) => {
mountComponent({ item: { ...item, canDelete, status } });
expect(findDeleteBtn().props('disabled')).toBe(state);
},
);
});
......@@ -155,11 +166,13 @@ describe('Image List Row', () => {
describe('tags count text', () => {
it('with one tag in the image', () => {
mountComponent({ item: { ...item, tags_count: 1 } });
mountComponent({ item: { ...item, tagsCount: 1 } });
expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag');
});
it('with more than one tag in the image', () => {
mountComponent({ item: { ...item, tags_count: 3 } });
mountComponent({ item: { ...item, tagsCount: 3 } });
expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags');
});
});
......
import { shallowMount } from '@vue/test-utils';
import { GlPagination } from '@gitlab/ui';
import { GlKeysetPagination } from '@gitlab/ui';
import Component from '~/registry/explorer/components/list_page/image_list.vue';
import ImageListRow from '~/registry/explorer/components/list_page/image_list_row.vue';
import { imagesListResponse, imagePagination } from '../../mock_data';
import { imagesListResponse, pageInfo as defaultPageInfo } from '../../mock_data';
describe('Image List', () => {
let wrapper;
const findRow = () => wrapper.findAll(ImageListRow);
const findPagination = () => wrapper.find(GlPagination);
const findPagination = () => wrapper.find(GlKeysetPagination);
const mountComponent = () => {
const mountComponent = (pageInfo = defaultPageInfo) => {
wrapper = shallowMount(Component, {
propsData: {
images: imagesListResponse.data,
pagination: imagePagination,
images: imagesListResponse,
pageInfo,
},
});
};
beforeEach(() => {
mountComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
......@@ -31,10 +27,14 @@ describe('Image List', () => {
describe('list', () => {
it('contains one list element for each image', () => {
expect(findRow().length).toBe(imagesListResponse.data.length);
mountComponent();
expect(findRow().length).toBe(imagesListResponse.length);
});
it('when delete event is emitted on the row it emits up a delete event', () => {
mountComponent();
findRow()
.at(0)
.vm.$emit('delete', 'foo');
......@@ -44,19 +44,41 @@ describe('Image List', () => {
describe('pagination', () => {
it('exists', () => {
mountComponent();
expect(findPagination().exists()).toBe(true);
});
it('is wired to the correct pagination props', () => {
const pagination = findPagination();
expect(pagination.props('perPage')).toBe(imagePagination.perPage);
expect(pagination.props('totalItems')).toBe(imagePagination.total);
expect(pagination.props('value')).toBe(imagePagination.page);
it.each`
hasNextPage | hasPreviousPage | isVisible
${true} | ${true} | ${true}
${true} | ${false} | ${true}
${false} | ${true} | ${true}
`(
'when hasNextPage is $hasNextPage and hasPreviousPage is $hasPreviousPage: is $isVisible that the component is visible',
({ hasNextPage, hasPreviousPage, isVisible }) => {
mountComponent({ hasNextPage, hasPreviousPage });
expect(findPagination().exists()).toBe(isVisible);
expect(findPagination().props('hasPreviousPage')).toBe(hasPreviousPage);
expect(findPagination().props('hasNextPage')).toBe(hasNextPage);
},
);
it('emits "prev-page" when the user clicks the back page button', () => {
mountComponent({ hasPreviousPage: true });
findPagination().vm.$emit('prev');
expect(wrapper.emitted('prev-page')).toEqual([[]]);
});
it('emits a pageChange event when the page change', () => {
findPagination().vm.$emit(GlPagination.model.event, 2);
expect(wrapper.emitted('pageChange')).toEqual([[2]]);
it('emits "next-page" when the user clicks the forward page button', () => {
mountComponent({ hasNextPage: true });
findPagination().vm.$emit('next');
expect(wrapper.emitted('next-page')).toEqual([[]]);
});
});
});
......@@ -45,21 +45,32 @@ export const registryServerResponse = [
},
];
export const imagesListResponse = {
data: [
{
path: 'foo',
location: 'location',
destroy_path: 'path',
},
{
path: 'bar',
location: 'location-2',
destroy_path: 'path-2',
},
],
headers,
};
export const imagesListResponse = [
{
__typename: 'ContainerRepository',
id: 'gid://gitlab/ContainerRepository/26',
name: 'rails-12009',
path: 'gitlab-org/gitlab-test/rails-12009',
status: null,
location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-12009',
canDelete: true,
createdAt: '2020-11-03T13:29:21Z',
tagsCount: 18,
expirationPolicyStartedAt: null,
},
{
__typename: 'ContainerRepository',
id: 'gid://gitlab/ContainerRepository/11',
name: 'rails-20572',
path: 'gitlab-org/gitlab-test/rails-20572',
status: null,
location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-20572',
canDelete: true,
createdAt: '2020-09-21T06:57:43Z',
tagsCount: 1,
expirationPolicyStartedAt: null,
},
];
export const tagsListResponse = {
data: [
......@@ -90,12 +101,12 @@ export const tagsListResponse = {
headers,
};
export const imagePagination = {
perPage: 10,
page: 1,
total: 14,
totalPages: 2,
nextPage: 2,
export const pageInfo = {
hasNextPage: true,
hasPreviousPage: true,
startCursor: 'eyJpZCI6IjI2In0',
endCursor: 'eyJpZCI6IjgifQ',
__typename: 'ContainerRepositoryConnection',
};
export const imageDetailsMock = {
......@@ -108,3 +119,76 @@ export const imageDetailsMock = {
cleanup_policy_started_at: null,
delete_api_path: 'http://0.0.0.0:3000/api/v4/projects/1/registry/repositories/1',
};
export const graphQLImageListMock = {
data: {
project: {
__typename: 'Project',
containerRepositoriesCount: 2,
containerRepositories: {
__typename: 'ContainerRepositoryConnection',
nodes: imagesListResponse,
pageInfo,
},
},
},
};
export const graphQLEmptyImageListMock = {
data: {
project: {
__typename: 'Project',
containerRepositoriesCount: 2,
containerRepositories: {
__typename: 'ContainerRepositoryConnection',
nodes: [],
pageInfo,
},
},
},
};
export const graphQLEmptyGroupImageListMock = {
data: {
group: {
__typename: 'Group',
containerRepositoriesCount: 2,
containerRepositories: {
__typename: 'ContainerRepositoryConnection',
nodes: [],
pageInfo,
},
},
},
};
export const deletedContainerRepository = {
id: 'gid://gitlab/ContainerRepository/11',
status: 'DELETE_SCHEDULED',
path: 'gitlab-org/gitlab-test/rails-12009',
__typename: 'ContainerRepository',
};
export const graphQLImageDeleteMock = {
data: {
destroyContainerRepository: {
containerRepository: {
...deletedContainerRepository,
},
errors: [],
__typename: 'DestroyContainerRepositoryPayload',
},
},
};
export const graphQLImageDeleteMockError = {
data: {
destroyContainerRepository: {
containerRepository: {
...deletedContainerRepository,
},
errors: ['foo'],
__typename: 'DestroyContainerRepositoryPayload',
},
},
};
......@@ -2,8 +2,8 @@ import { initSearchApp } from '~/search';
import createStore from '~/search/store';
jest.mock('~/search/store');
jest.mock('~/search/topbar');
jest.mock('~/search/sidebar');
jest.mock('~/search/group_filter');
describe('initSearchApp', () => {
let defaultLocation;
......
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import GroupFilter from '~/search/topbar/components/group_filter.vue';
import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue';
import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/topbar/constants';
const localVue = createLocalVue();
localVue.use(Vuex);
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
setUrlParams: jest.fn(),
}));
describe('GroupFilter', () => {
let wrapper;
const actionSpies = {
fetchGroups: jest.fn(),
};
const defaultProps = {
initialData: null,
};
const createComponent = (initialState, props) => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
...initialState,
},
actions: actionSpies,
});
wrapper = shallowMount(GroupFilter, {
localVue,
store,
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findSearchableDropdown = () => wrapper.find(SearchableDropdown);
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders SearchableDropdown always', () => {
expect(findSearchableDropdown().exists()).toBe(true);
});
});
describe('events', () => {
describe('when @search is emitted', () => {
const search = 'test';
beforeEach(() => {
createComponent();
findSearchableDropdown().vm.$emit('search', search);
});
it('calls fetchGroups with the search paramter', () => {
expect(actionSpies.fetchGroups).toHaveBeenCalledTimes(1);
expect(actionSpies.fetchGroups).toHaveBeenCalledWith(expect.any(Object), search);
});
});
describe('when @change is emitted', () => {
beforeEach(() => {
createComponent();
findSearchableDropdown().vm.$emit('change', MOCK_GROUP);
});
it('calls calls setUrlParams with group id, project id null, and visitUrl', () => {
expect(setUrlParams).toHaveBeenCalledWith({
[GROUP_DATA.queryParam]: MOCK_GROUP.id,
[PROJECT_DATA.queryParam]: null,
});
expect(visitUrl).toHaveBeenCalled();
});
});
});
describe('computed', () => {
describe('selectedGroup', () => {
describe('when initialData is null', () => {
beforeEach(() => {
createComponent();
});
it('sets selectedGroup to ANY_OPTION', () => {
expect(wrapper.vm.selectedGroup).toBe(ANY_OPTION);
});
});
describe('when initialData is set', () => {
beforeEach(() => {
createComponent({}, { initialData: MOCK_GROUP });
});
it('sets selectedGroup to ANY_OPTION', () => {
expect(wrapper.vm.selectedGroup).toBe(MOCK_GROUP);
});
});
});
});
});
import Vuex from 'vuex';
import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
import * as urlUtils from '~/lib/utils/url_utility';
import GroupFilter from '~/search/group_filter/components/group_filter.vue';
import { GROUP_QUERY_PARAM, PROJECT_QUERY_PARAM, ANY_GROUP } from '~/search/group_filter/constants';
import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from '../../mock_data';
import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data';
import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue';
import { ANY_OPTION, GROUP_DATA } from '~/search/topbar/constants';
const localVue = createLocalVue();
localVue.use(Vuex);
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
setUrlParams: jest.fn(),
}));
describe('Global Search Group Filter', () => {
describe('Global Search Searchable Dropdown', () => {
let wrapper;
const actionSpies = {
fetchGroups: jest.fn(),
};
const defaultProps = {
initialGroup: null,
headerText: GROUP_DATA.headerText,
selectedDisplayValue: GROUP_DATA.selectedDisplayValue,
itemsDisplayValue: GROUP_DATA.itemsDisplayValue,
loading: false,
selectedItem: ANY_OPTION,
items: [],
};
const createComponent = (initialState, props = {}, mountFn = shallowMount) => {
const createComponent = (initialState, props, mountFn = shallowMount) => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
...initialState,
},
actions: actionSpies,
});
wrapper = mountFn(GroupFilter, {
wrapper = mountFn(SearchableDropdown, {
localVue,
store,
propsData: {
......@@ -78,22 +71,22 @@ describe('Global Search Group Filter', () => {
});
describe('onSearch', () => {
const groupSearch = 'test search';
const search = 'test search';
beforeEach(() => {
findGlDropdownSearch().vm.$emit('input', groupSearch);
findGlDropdownSearch().vm.$emit('input', search);
});
it('calls fetchGroups when input event is fired from GlSearchBoxByType', () => {
expect(actionSpies.fetchGroups).toHaveBeenCalledWith(expect.any(Object), groupSearch);
it('$emits @search when input event is fired from GlSearchBoxByType', () => {
expect(wrapper.emitted('search')[0]).toEqual([search]);
});
});
});
describe('findDropdownItems', () => {
describe('when fetchingGroups is false', () => {
describe('when loading is false', () => {
beforeEach(() => {
createComponent({ groups: MOCK_GROUPS });
createComponent({}, { items: MOCK_GROUPS });
});
it('does not render loader', () => {
......@@ -101,14 +94,14 @@ describe('Global Search Group Filter', () => {
});
it('renders an instance for each namespace', () => {
const groupsIncludingAny = ['Any'].concat(MOCK_GROUPS.map(n => n.full_name));
expect(findDropdownItemsText()).toStrictEqual(groupsIncludingAny);
const resultsIncludeAny = ['Any'].concat(MOCK_GROUPS.map(n => n.full_name));
expect(findDropdownItemsText()).toStrictEqual(resultsIncludeAny);
});
});
describe('when fetchingGroups is true', () => {
describe('when loading is true', () => {
beforeEach(() => {
createComponent({ fetchingGroups: true, groups: MOCK_GROUPS });
createComponent({}, { loading: true, items: MOCK_GROUPS });
});
it('does render loader', () => {
......@@ -119,26 +112,36 @@ describe('Global Search Group Filter', () => {
expect(findDropdownItemsText()).toStrictEqual(['Any']);
});
});
describe('when item is selected', () => {
beforeEach(() => {
createComponent({}, { items: MOCK_GROUPS, selectedItem: MOCK_GROUPS[0] });
});
it('marks the dropdown as checked', () => {
expect(findFirstGroupDropdownItem().attributes('ischecked')).toBe('true');
});
});
});
describe('Dropdown Text', () => {
describe('when initialGroup is null', () => {
describe('when selectedItem is any', () => {
beforeEach(() => {
createComponent({}, {}, mount);
});
it('sets dropdown text to Any', () => {
expect(findDropdownText().text()).toBe(ANY_GROUP.name);
expect(findDropdownText().text()).toBe(ANY_OPTION.name);
});
});
describe('initialGroup is set', () => {
describe('selectedItem is set', () => {
beforeEach(() => {
createComponent({}, { initialGroup: MOCK_GROUP }, mount);
createComponent({}, { selectedItem: MOCK_GROUP }, mount);
});
it('sets dropdown text to group name', () => {
expect(findDropdownText().text()).toBe(MOCK_GROUP.name);
it('sets dropdown text to the selectedItem selectedDisplayValue', () => {
expect(findDropdownText().text()).toBe(MOCK_GROUP[GROUP_DATA.selectedDisplayValue]);
});
});
});
......@@ -146,27 +149,19 @@ describe('Global Search Group Filter', () => {
describe('actions', () => {
beforeEach(() => {
createComponent({ groups: MOCK_GROUPS });
createComponent({}, { items: MOCK_GROUPS });
});
it('clicking "Any" dropdown item calls setUrlParams with group id null, project id null,and visitUrl', () => {
it('clicking "Any" dropdown item $emits @change with ANY_OPTION', () => {
findAnyDropdownItem().vm.$emit('click');
expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
[GROUP_QUERY_PARAM]: ANY_GROUP.id,
[PROJECT_QUERY_PARAM]: null,
});
expect(urlUtils.visitUrl).toHaveBeenCalled();
expect(wrapper.emitted('change')[0]).toEqual([ANY_OPTION]);
});
it('clicking group dropdown item calls setUrlParams with group id, project id null, and visitUrl', () => {
it('clicking result dropdown item $emits @change with result', () => {
findFirstGroupDropdownItem().vm.$emit('click');
expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
[GROUP_QUERY_PARAM]: MOCK_GROUPS[0].id,
[PROJECT_QUERY_PARAM]: null,
});
expect(urlUtils.visitUrl).toHaveBeenCalled();
expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Issues::CloneService do
include DesignManagementTestHelpers
let_it_be(:user) { create(:user) }
let_it_be(:author) { create(:user) }
let_it_be(:title) { 'Some issue' }
let_it_be(:description) { "Some issue description with mention to #{user.to_reference}" }
let_it_be(:group) { create(:group, :private) }
let_it_be(:sub_group_1) { create(:group, :private, parent: group) }
let_it_be(:sub_group_2) { create(:group, :private, parent: group) }
let_it_be(:old_project) { create(:project, namespace: sub_group_1) }
let_it_be(:new_project) { create(:project, namespace: sub_group_2) }
let(:old_issue) do
create(:issue, title: title, description: description, project: old_project, author: author)
end
subject(:clone_service) do
described_class.new(old_project, user)
end
shared_context 'user can clone issue' do
before do
old_project.add_reporter(user)
new_project.add_reporter(user)
end
end
describe '#execute' do
context 'issue movable' do
include_context 'user can clone issue'
context 'generic issue' do
let!(:new_issue) { clone_service.execute(old_issue, new_project) }
it 'creates a new issue in the selected project' do
expect do
clone_service.execute(old_issue, new_project)
end.to change { new_project.issues.count }.by(1)
end
it 'copies issue title' do
expect(new_issue.title).to eq title
end
it 'copies issue description' do
expect(new_issue.description).to eq description
end
it 'adds system note to old issue at the end' do
expect(old_issue.notes.last.note).to start_with 'cloned to'
end
it 'adds system note to new issue at the end' do
expect(new_issue.notes.last.note).to start_with 'cloned from'
end
it 'keeps old issue open' do
expect(old_issue.open?).to be true
end
it 'persists new issue' do
expect(new_issue.persisted?).to be true
end
it 'persists all changes' do
expect(old_issue.changed?).to be false
expect(new_issue.changed?).to be false
end
it 'preserves author' do
expect(new_issue.author).to eq author
end
it 'creates a new internal id for issue' do
expect(new_issue.iid).to be_present
end
it 'preserves create time' do
expect(old_issue.created_at.strftime('%D')).to eq new_issue.created_at.strftime('%D')
end
it 'does not copy system notes' do
expect(new_issue.notes.count).to eq(1)
end
it 'does not set moved_issue' do
expect(old_issue.moved?).to eq(false)
end
end
context 'issue with award emoji' do
let!(:award_emoji) { create(:award_emoji, awardable: old_issue) }
it 'copies the award emoji' do
old_issue.reload
new_issue = clone_service.execute(old_issue, new_project)
expect(old_issue.award_emoji.first.name).to eq new_issue.reload.award_emoji.first.name
end
end
context 'issue with milestone' do
let(:milestone) { create(:milestone, group: sub_group_1) }
let(:new_project) { create(:project, namespace: sub_group_1) }
let(:old_issue) do
create(:issue, title: title, description: description, project: old_project, author: author, milestone: milestone)
end
before do
create(:resource_milestone_event, issue: old_issue, milestone: milestone, action: :add)
end
it 'does not create extra milestone events' do
new_issue = clone_service.execute(old_issue, new_project)
expect(new_issue.resource_milestone_events.count).to eq(old_issue.resource_milestone_events.count)
end
end
context 'issue with due date' do
let(:date) { Date.parse('2020-01-10') }
let(:old_issue) do
create(:issue, title: title, description: description, project: old_project, author: author, due_date: date)
end
before do
SystemNoteService.change_due_date(old_issue, old_project, author, old_issue.due_date)
end
it 'keeps the same due date' do
new_issue = clone_service.execute(old_issue, new_project)
expect(new_issue.due_date).to eq(date)
end
end
context 'issue with assignee' do
let_it_be(:assignee) { create(:user) }
before do
old_issue.assignees = [assignee]
end
it 'preserves assignee with access to the new issue' do
new_project.add_reporter(assignee)
new_issue = clone_service.execute(old_issue, new_project)
expect(new_issue.assignees).to eq([assignee])
end
it 'ignores assignee without access to the new issue' do
new_issue = clone_service.execute(old_issue, new_project)
expect(new_issue.assignees).to be_empty
end
end
context 'issue is confidential' do
before do
old_issue.update_columns(confidential: true)
end
it 'preserves the confidential flag' do
new_issue = clone_service.execute(old_issue, new_project)
expect(new_issue.confidential).to be true
end
end
context 'moving to same project' do
it 'also works' do
new_issue = clone_service.execute(old_issue, old_project)
expect(new_issue.project).to eq(old_project)
expect(new_issue.iid).not_to eq(old_issue.iid)
end
end
context 'project issue hooks' do
let!(:hook) { create(:project_hook, project: old_project, issues_events: true) }
it 'executes project issue hooks' do
allow_next_instance_of(WebHookService) do |instance|
allow(instance).to receive(:execute)
end
# Ideally, we'd test that `WebHookWorker.jobs.size` increased by 1,
# but since the entire spec run takes place in a transaction, we never
# actually get to the `after_commit` hook that queues these jobs.
expect { clone_service.execute(old_issue, new_project) }
.not_to raise_error # Sidekiq::Worker::EnqueueFromTransactionError
end
end
context 'issue with a design', :clean_gitlab_redis_shared_state do
let_it_be(:new_project) { create(:project) }
let!(:design) { create(:design, :with_lfs_file, issue: old_issue) }
let!(:note) { create(:diff_note_on_design, noteable: design, issue: old_issue, project: old_issue.project) }
let(:subject) { clone_service.execute(old_issue, new_project) }
before do
enable_design_management
end
it 'calls CopyDesignCollection::QueueService' do
expect(DesignManagement::CopyDesignCollection::QueueService).to receive(:new)
.with(user, old_issue, kind_of(Issue))
.and_call_original
subject
end
it 'logs if QueueService returns an error', :aggregate_failures do
error_message = 'error'
expect_next_instance_of(DesignManagement::CopyDesignCollection::QueueService) do |service|
expect(service).to receive(:execute).and_return(
ServiceResponse.error(message: error_message)
)
end
expect(Gitlab::AppLogger).to receive(:error).with(error_message)
subject
end
# Perform a small integration test to ensure the services and worker
# can correctly create designs.
it 'copies the design and its notes', :sidekiq_inline, :aggregate_failures do
new_issue = subject
expect(new_issue.designs.size).to eq(1)
expect(new_issue.designs.first.notes.size).to eq(1)
end
end
end
describe 'clone permissions' do
let(:clone) { clone_service.execute(old_issue, new_project) }
context 'target project is pending deletion' do
include_context 'user can clone issue'
before do
new_project.update_columns(pending_delete: true)
end
after do
new_project.update_columns(pending_delete: false)
end
it { expect { clone }.to raise_error(Issues::CloneService::CloneError, /pending deletion/) }
end
context 'user is reporter in both projects' do
include_context 'user can clone issue'
it { expect { clone }.not_to raise_error }
end
context 'user is reporter only in new project' do
before do
new_project.add_reporter(user)
end
it { expect { clone }.to raise_error(StandardError, /permissions/) }
end
context 'user is reporter only in old project' do
before do
old_project.add_reporter(user)
end
it { expect { clone }.to raise_error(StandardError, /permissions/) }
end
context 'user is reporter in one project and guest in another' do
before do
new_project.add_guest(user)
old_project.add_reporter(user)
end
it { expect { clone }.to raise_error(StandardError, /permissions/) }
end
context 'issue is not persisted' do
include_context 'user can clone issue'
let(:old_issue) { build(:issue, project: old_project, author: author) }
it { expect { clone }.to raise_error(StandardError, /permissions/) }
end
end
end
end
......@@ -968,6 +968,26 @@ RSpec.describe Issues::UpdateService, :mailer do
end
end
context 'clone an issue' do
context 'valid project' do
let(:target_project) { create(:project) }
before do
target_project.add_maintainer(user)
end
it 'calls the move service with the proper issue and project' do
clone_stub = instance_double(Issues::CloneService)
allow(Issues::CloneService).to receive(:new).and_return(clone_stub)
allow(clone_stub).to receive(:execute).with(issue, target_project).and_return(issue)
expect(clone_stub).to receive(:execute).with(issue, target_project)
update_issue(target_clone_project: target_project)
end
end
end
context 'when moving an issue ' do
it 'raises an error for invalid move ids within a project' do
opts = { move_between_ids: [9000, non_existing_record_id] }
......
......@@ -333,6 +333,19 @@ RSpec.describe SystemNoteService do
end
end
describe '.noteable_cloned' do
let(:noteable_ref) { double }
let(:direction) { double }
it 'calls IssuableService' do
expect_next_instance_of(::SystemNotes::IssuablesService) do |service|
expect(service).to receive(:noteable_cloned).with(noteable_ref, direction)
end
described_class.noteable_cloned(double, double, noteable_ref, double, direction: direction)
end
end
describe 'Jira integration' do
include JiraServiceHelper
......
......@@ -522,6 +522,67 @@ RSpec.describe ::SystemNotes::IssuablesService do
end
end
describe '#noteable_cloned' do
let(:new_project) { create(:project) }
let(:new_noteable) { create(:issue, project: new_project) }
subject do
service.noteable_cloned(new_noteable, direction)
end
shared_examples 'cross project mentionable' do
include MarkupHelper
it 'contains cross reference to new noteable' do
expect(subject.note).to include cross_project_reference(new_project, new_noteable)
end
it 'mentions referenced noteable' do
expect(subject.note).to include new_noteable.to_reference
end
it 'mentions referenced project' do
expect(subject.note).to include new_project.full_path
end
end
context 'cloned to' do
let(:direction) { :to }
it_behaves_like 'cross project mentionable'
it_behaves_like 'a system note' do
let(:action) { 'cloned' }
end
it 'notifies about noteable being cloned to' do
expect(subject.note).to match('cloned to')
end
end
context 'cloned from' do
let(:direction) { :from }
it_behaves_like 'cross project mentionable'
it_behaves_like 'a system note' do
let(:action) { 'cloned' }
end
it 'notifies about noteable being cloned from' do
expect(subject.note).to match('cloned from')
end
end
context 'invalid direction' do
let(:direction) { :invalid }
it 'raises error' do
expect { subject }.to raise_error StandardError, /Invalid direction/
end
end
end
describe '#mark_duplicate_issue' do
subject { service.mark_duplicate_issue(canonical_issue) }
......
# frozen_string_literal: true
RSpec.shared_examples 'clone quick action' do
context 'clone the issue to another project' do
let(:target_project) { create(:project, :public) }
context 'when no target is given' do
it 'clones the issue in the current project' do
add_note("/clone")
expect(page).to have_content "Cloned this issue to #{project.full_path}."
expect(issue.reload).to be_open
visit project_issue_path(project, issue)
expect(page).to have_content 'Issues 2'
end
end
context 'when the project is valid' do
before do
target_project.add_maintainer(user)
end
it 'clones the issue' do
add_note("/clone #{target_project.full_path}")
expect(page).to have_content "Cloned this issue to #{target_project.full_path}."
expect(issue.reload).to be_open
visit project_issue_path(target_project, issue)
expect(page).to have_content 'Issues 1'
end
end
context 'when the project is valid but the user not authorized' do
let(:project_unauthorized) { create(:project, :public) }
it 'does not clone the issue' do
add_note("/clone #{project_unauthorized.full_path}")
wait_for_requests
expect(page).to have_content "Cloned this issue to #{project_unauthorized.full_path}."
expect(issue.reload).to be_open
visit project_issue_path(target_project, issue)
expect(page).not_to have_content 'Issues 1'
end
end
context 'when the project is invalid' do
it 'does not clone the issue' do
add_note("/clone not/valid")
wait_for_requests
expect(page).to have_content "Failed to clone this issue because target project doesn't exist."
expect(issue.reload).to be_open
end
end
context 'when the user issues multiple commands' do
let(:milestone) { create(:milestone, title: '1.0', project: project) }
let(:bug) { create(:label, project: project, title: 'bug') }
let(:wontfix) { create(:label, project: project, title: 'wontfix') }
let!(:target_milestone) { create(:milestone, title: '1.0', project: target_project) }
before do
target_project.add_maintainer(user)
end
shared_examples 'applies the commands to issues in both projects, target and source' do
it "applies quick actions" do
expect(page).to have_content "Cloned this issue to #{target_project.full_path}."
expect(issue.reload).to be_open
visit project_issue_path(target_project, issue)
expect(page).to have_content 'bug'
expect(page).to have_content 'wontfix'
expect(page).to have_content '1.0'
visit project_issue_path(project, issue)
expect(page).to have_content 'bug'
expect(page).to have_content 'wontfix'
expect(page).to have_content '1.0'
end
end
context 'applies multiple commands with clone command in the end' do
before do
add_note("/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"\n\n/clone #{target_project.full_path}")
end
it_behaves_like 'applies the commands to issues in both projects, target and source'
end
context 'applies multiple commands with clone command in the begining' do
before do
add_note("/clone #{target_project.full_path}\n\n/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"")
end
it_behaves_like 'applies the commands to issues in both projects, target and source'
end
end
context 'when editing comments' do
let(:target_project) { create(:project, :public) }
before do
target_project.add_maintainer(user)
sign_in(user)
visit project_issue_path(project, issue)
wait_for_all_requests
end
it 'clones the issue after quickcommand note was updated' do
# misspelled quick action
add_note("test note.\n/cloe #{target_project.full_path}")
expect(issue.reload).not_to be_closed
edit_note("/cloe #{target_project.full_path}", "test note.\n/clone #{target_project.full_path}")
wait_for_all_requests
expect(page).to have_content 'test note.'
expect(issue.reload).to be_open
visit project_issue_path(target_project, issue)
wait_for_all_requests
expect(page).to have_content 'Issues 1'
end
it 'deletes the note if it was updated to just contain a command' do
# missspelled quick action
add_note("test note.\n/cloe #{target_project.full_path}")
expect(page).not_to have_content 'Commands applied'
edit_note("/cloe #{target_project.full_path}", "/clone #{target_project.full_path}")
wait_for_all_requests
expect(page).not_to have_content "/clone #{target_project.full_path}"
expect(issue.reload).to be_open
visit project_issue_path(target_project, issue)
wait_for_all_requests
expect(page).to have_content 'Issues 1'
end
end
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