Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-02-09 03:09:18 +00:00
parent 1c0289261b
commit 9c8d620e48
78 changed files with 2706 additions and 479 deletions

View File

@ -345,7 +345,7 @@ group :development do
gem 'better_errors', '~> 2.7.1'
# thin instead webrick
gem 'thin', '~> 1.7.0'
gem 'thin', '~> 1.8.0'
end
group :development, :test do

View File

@ -207,7 +207,7 @@ GEM
git
css_parser (1.7.0)
addressable
daemons (1.2.6)
daemons (1.3.1)
danger (8.0.6)
claide (~> 1.0)
claide-plugins (>= 0.9.2)
@ -1180,7 +1180,7 @@ GEM
execjs (>= 0.3.0, < 3)
test-prof (0.12.0)
text (1.3.1)
thin (1.7.2)
thin (1.8.0)
daemons (~> 1.0, >= 1.0.9)
eventmachine (~> 1.0, >= 1.0.4)
rack (>= 1, < 3)
@ -1534,7 +1534,7 @@ DEPENDENCIES
sys-filesystem (~> 1.1.6)
terser (= 1.0.2)
test-prof (~> 0.12.0)
thin (~> 1.7.0)
thin (~> 1.8.0)
timecop (~> 0.9.1)
toml-rb (~> 1.0.0)
truncato (~> 0.7.11)

View File

@ -5,8 +5,8 @@ import { deprecatedCreateFlash as Flash } from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility';
import { getParameterByName } from '~/lib/utils/common_utils';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import boardsStore from '~/boards/stores/boards_store';
import { fullLabelId, fullBoardId } from '../boards_util';
import { formType } from '../constants';
import updateBoardMutation from '../graphql/board_update.mutation.graphql';
import createBoardMutation from '../graphql/board_create.mutation.graphql';
@ -26,12 +26,6 @@ const boardDefaults = {
hide_closed_list: false,
};
const formType = {
new: 'new',
delete: 'delete',
edit: 'edit',
};
export default {
i18n: {
[formType.new]: { title: s__('Board|Create new board'), btnText: s__('Board|Create board') },
@ -100,11 +94,14 @@ export default {
type: Object,
required: true,
},
currentPage: {
type: String,
required: true,
},
},
data() {
return {
board: { ...boardDefaults, ...this.currentBoard },
currentPage: boardsStore.state.currentPage,
isLoading: false,
};
},
@ -256,7 +253,7 @@ export default {
}
},
cancel() {
boardsStore.showPage('');
this.$emit('cancel');
},
resetFormState() {
if (this.isNewForm) {

View File

@ -12,11 +12,12 @@ import {
import httpStatusCodes from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import projectQuery from '../graphql/project_boards.query.graphql';
import groupQuery from '../graphql/group_boards.query.graphql';
import boardsStore from '../stores/boards_store';
import eventHub from '../eventhub';
import BoardForm from './board_form.vue';
const MIN_BOARDS_TO_VIEW_RECENT = 10;
@ -35,6 +36,7 @@ export default {
directives: {
GlModalDirective,
},
inject: ['fullPath', 'recentBoardsEndpoint'],
props: {
currentBoard: {
type: Object,
@ -99,12 +101,11 @@ export default {
scrollFadeInitialized: false,
boards: [],
recentBoards: [],
state: boardsStore.state,
throttledSetScrollFade: throttle(this.setScrollFade, this.throttleDuration),
contentClientHeight: 0,
maxPosition: 0,
store: boardsStore,
filterTerm: '',
currentPage: '',
};
},
computed: {
@ -114,16 +115,13 @@ export default {
loading() {
return this.loadingRecentBoards || Boolean(this.loadingBoards);
},
currentPage() {
return this.state.currentPage;
},
filteredBoards() {
return this.boards.filter((board) =>
board.name.toLowerCase().includes(this.filterTerm.toLowerCase()),
);
},
board() {
return this.state.currentBoard;
return this.currentBoard;
},
showDelete() {
return this.boards.length > 1;
@ -148,11 +146,17 @@ export default {
},
},
created() {
boardsStore.setCurrentBoard(this.currentBoard);
eventHub.$on('showBoardModal', this.showPage);
},
beforeDestroy() {
eventHub.$off('showBoardModal', this.showPage);
},
methods: {
showPage(page) {
boardsStore.showPage(page);
this.currentPage = page;
},
cancel() {
this.showPage('');
},
loadBoards(toggleDropdown = true) {
if (toggleDropdown && this.boards.length > 0) {
@ -161,7 +165,7 @@ export default {
this.$apollo.addSmartQuery('boards', {
variables() {
return { fullPath: this.state.endpoints.fullPath };
return { fullPath: this.fullPath };
},
query() {
return this.groupId ? groupQuery : projectQuery;
@ -179,8 +183,10 @@ export default {
});
this.loadingRecentBoards = true;
boardsStore
.recentBoards()
// Follow up to fetch recent boards using GraphQL
// https://gitlab.com/gitlab-org/gitlab/-/issues/300985
axios
.get(this.recentBoardsEndpoint)
.then((res) => {
this.recentBoards = res.data;
})
@ -346,6 +352,8 @@ export default {
:weights="weights"
:enable-scoped-labels="enabledScopedLabels"
:current-board="currentBoard"
:current-page="currentPage"
@cancel="cancel"
/>
</span>
</div>

View File

@ -0,0 +1,357 @@
<script>
import { throttle } from 'lodash';
import {
GlLoadingIcon,
GlSearchBoxByType,
GlDropdown,
GlDropdownDivider,
GlDropdownSectionHeader,
GlDropdownItem,
GlModalDirective,
} from '@gitlab/ui';
import httpStatusCodes from '~/lib/utils/http_status';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import projectQuery from '../graphql/project_boards.query.graphql';
import groupQuery from '../graphql/group_boards.query.graphql';
import boardsStore from '../stores/boards_store';
import BoardForm from './board_form.vue';
const MIN_BOARDS_TO_VIEW_RECENT = 10;
export default {
name: 'BoardsSelector',
components: {
BoardForm,
GlLoadingIcon,
GlSearchBoxByType,
GlDropdown,
GlDropdownDivider,
GlDropdownSectionHeader,
GlDropdownItem,
},
directives: {
GlModalDirective,
},
props: {
currentBoard: {
type: Object,
required: true,
},
throttleDuration: {
type: Number,
default: 200,
required: false,
},
boardBaseUrl: {
type: String,
required: true,
},
hasMissingBoards: {
type: Boolean,
required: true,
},
canAdminBoard: {
type: Boolean,
required: true,
},
multipleIssueBoardsAvailable: {
type: Boolean,
required: true,
},
labelsPath: {
type: String,
required: true,
},
labelsWebUrl: {
type: String,
required: true,
},
projectId: {
type: Number,
required: true,
},
groupId: {
type: Number,
required: true,
},
scopedIssueBoardFeatureEnabled: {
type: Boolean,
required: true,
},
weights: {
type: Array,
required: true,
},
enabledScopedLabels: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
hasScrollFade: false,
loadingBoards: 0,
loadingRecentBoards: false,
scrollFadeInitialized: false,
boards: [],
recentBoards: [],
state: boardsStore.state,
throttledSetScrollFade: throttle(this.setScrollFade, this.throttleDuration),
contentClientHeight: 0,
maxPosition: 0,
store: boardsStore,
filterTerm: '',
};
},
computed: {
parentType() {
return this.groupId ? 'group' : 'project';
},
loading() {
return this.loadingRecentBoards || Boolean(this.loadingBoards);
},
currentPage() {
return this.state.currentPage;
},
filteredBoards() {
return this.boards.filter((board) =>
board.name.toLowerCase().includes(this.filterTerm.toLowerCase()),
);
},
board() {
return this.state.currentBoard;
},
showDelete() {
return this.boards.length > 1;
},
scrollFadeClass() {
return {
'fade-out': !this.hasScrollFade,
};
},
showRecentSection() {
return (
this.recentBoards.length &&
this.boards.length > MIN_BOARDS_TO_VIEW_RECENT &&
!this.filterTerm.length
);
},
},
watch: {
filteredBoards() {
this.scrollFadeInitialized = false;
this.$nextTick(this.setScrollFade);
},
},
created() {
boardsStore.setCurrentBoard(this.currentBoard);
},
methods: {
showPage(page) {
boardsStore.showPage(page);
},
cancel() {
this.showPage('');
},
loadBoards(toggleDropdown = true) {
if (toggleDropdown && this.boards.length > 0) {
return;
}
this.$apollo.addSmartQuery('boards', {
variables() {
return { fullPath: this.state.endpoints.fullPath };
},
query() {
return this.groupId ? groupQuery : projectQuery;
},
loadingKey: 'loadingBoards',
update(data) {
if (!data?.[this.parentType]) {
return [];
}
return data[this.parentType].boards.edges.map(({ node }) => ({
id: getIdFromGraphQLId(node.id),
name: node.name,
}));
},
});
this.loadingRecentBoards = true;
boardsStore
.recentBoards()
.then((res) => {
this.recentBoards = res.data;
})
.catch((err) => {
/**
* If user is unauthorized we'd still want to resolve the
* request to display all boards.
*/
if (err?.response?.status === httpStatusCodes.UNAUTHORIZED) {
this.recentBoards = []; // recent boards are empty
return;
}
throw err;
})
.then(() => this.$nextTick()) // Wait for boards list in DOM
.then(() => {
this.setScrollFade();
})
.catch(() => {})
.finally(() => {
this.loadingRecentBoards = false;
});
},
isScrolledUp() {
const { content } = this.$refs;
if (!content) {
return false;
}
const currentPosition = this.contentClientHeight + content.scrollTop;
return currentPosition < this.maxPosition;
},
initScrollFade() {
const { content } = this.$refs;
if (!content) {
return;
}
this.scrollFadeInitialized = true;
this.contentClientHeight = content.clientHeight;
this.maxPosition = content.scrollHeight;
},
setScrollFade() {
if (!this.scrollFadeInitialized) this.initScrollFade();
this.hasScrollFade = this.isScrolledUp();
},
},
};
</script>
<template>
<div class="boards-switcher js-boards-selector gl-mr-3">
<span class="boards-selector-wrapper js-boards-selector-wrapper">
<gl-dropdown
data-qa-selector="boards_dropdown"
toggle-class="dropdown-menu-toggle js-dropdown-toggle"
menu-class="flex-column dropdown-extended-height"
:text="board.name"
@show="loadBoards"
>
<p class="gl-new-dropdown-header-top" @mousedown.prevent>
{{ s__('IssueBoards|Switch board') }}
</p>
<gl-search-box-by-type ref="searchBox" v-model="filterTerm" class="m-2" />
<div
v-if="!loading"
ref="content"
data-qa-selector="boards_dropdown_content"
class="dropdown-content flex-fill"
@scroll.passive="throttledSetScrollFade"
>
<gl-dropdown-item
v-show="filteredBoards.length === 0"
class="gl-pointer-events-none text-secondary"
>
{{ s__('IssueBoards|No matching boards found') }}
</gl-dropdown-item>
<gl-dropdown-section-header v-if="showRecentSection">
{{ __('Recent') }}
</gl-dropdown-section-header>
<template v-if="showRecentSection">
<gl-dropdown-item
v-for="recentBoard in recentBoards"
:key="`recent-${recentBoard.id}`"
class="js-dropdown-item"
:href="`${boardBaseUrl}/${recentBoard.id}`"
>
{{ recentBoard.name }}
</gl-dropdown-item>
</template>
<gl-dropdown-divider v-if="showRecentSection" />
<gl-dropdown-section-header v-if="showRecentSection">
{{ __('All') }}
</gl-dropdown-section-header>
<gl-dropdown-item
v-for="otherBoard in filteredBoards"
:key="otherBoard.id"
class="js-dropdown-item"
:href="`${boardBaseUrl}/${otherBoard.id}`"
>
{{ otherBoard.name }}
</gl-dropdown-item>
<gl-dropdown-item v-if="hasMissingBoards" class="no-pointer-events">
{{
s__(
'IssueBoards|Some of your boards are hidden, activate a license to see them again.',
)
}}
</gl-dropdown-item>
</div>
<div
v-show="filteredBoards.length > 0"
class="dropdown-content-faded-mask"
:class="scrollFadeClass"
></div>
<gl-loading-icon v-if="loading" />
<div v-if="canAdminBoard">
<gl-dropdown-divider />
<gl-dropdown-item
v-if="multipleIssueBoardsAvailable"
v-gl-modal-directive="'board-config-modal'"
data-qa-selector="create_new_board_button"
@click.prevent="showPage('new')"
>
{{ s__('IssueBoards|Create new board') }}
</gl-dropdown-item>
<gl-dropdown-item
v-if="showDelete"
v-gl-modal-directive="'board-config-modal'"
class="text-danger js-delete-board"
@click.prevent="showPage('delete')"
>
{{ s__('IssueBoards|Delete board') }}
</gl-dropdown-item>
</div>
</gl-dropdown>
<board-form
v-if="currentPage"
:labels-path="labelsPath"
:labels-web-url="labelsWebUrl"
:project-id="projectId"
:group-id="groupId"
:can-admin-board="canAdminBoard"
:scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled"
:weights="weights"
:enable-scoped-labels="enabledScopedLabels"
:current-board="currentBoard"
:current-page="state.currentPage"
@cancel="cancel"
/>
</span>
</div>
</template>

View File

@ -21,6 +21,12 @@ export const ListTypeTitles = {
label: __('Label'),
};
export const formType = {
new: 'new',
delete: 'delete',
edit: 'edit',
};
export const inactiveId = 0;
export const ISSUABLE = 'issuable';

View File

@ -358,5 +358,6 @@ export default () => {
mountMultipleBoardsSwitcher({
fullPath: $boardApp.dataset.fullPath,
rootPath: $boardApp.dataset.boardsEndpoint,
recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint,
});
};

View File

@ -1,8 +1,12 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mapGetters } from 'vuex';
import store from '~/boards/stores';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import BoardsSelector from '~/boards/components/boards_selector.vue';
import BoardsSelectorDeprecated from '~/boards/components/boards_selector_deprecated.vue';
Vue.use(VueApollo);
@ -16,11 +20,15 @@ export default (params = {}) => {
el: boardsSwitcherElement,
components: {
BoardsSelector,
BoardsSelectorDeprecated,
},
mixins: [glFeatureFlagMixin()],
apolloProvider,
store,
provide: {
fullPath: params.fullPath,
rootPath: params.rootPath,
recentBoardsEndpoint: params.recentBoardsEndpoint,
},
data() {
const { dataset } = boardsSwitcherElement;
@ -39,8 +47,16 @@ export default (params = {}) => {
return { boardsSelectorProps };
},
computed: {
...mapGetters(['shouldUseGraphQL']),
},
render(createElement) {
return createElement(BoardsSelector, {
if (this.shouldUseGraphQL) {
return createElement(BoardsSelector, {
props: this.boardsSelectorProps,
});
}
return createElement(BoardsSelectorDeprecated, {
props: this.boardsSelectorProps,
});
},

View File

@ -1,28 +0,0 @@
# frozen_string_literal: true
module Mutations
module Boards
module Lists
class Base < BaseMutation
include Mutations::ResolvesIssuable
argument :board_id, ::Types::GlobalIDType[::Board],
required: true,
description: 'Global ID of the issue board to mutate.'
field :list,
Types::BoardListType,
null: true,
description: 'List of the issue board.'
authorize :admin_list
private
def find_object(id:)
GitlabSchema.object_from_id(id, expected_type: ::Board)
end
end
end
end
end

View File

@ -0,0 +1,55 @@
# frozen_string_literal: true
module Mutations
module Boards
module Lists
class BaseCreate < BaseMutation
argument :backlog, GraphQL::BOOLEAN_TYPE,
required: false,
description: 'Create the backlog list.'
argument :label_id, ::Types::GlobalIDType[::Label],
required: false,
description: 'Global ID of an existing label.'
def ready?(**args)
if args.slice(*mutually_exclusive_args).size != 1
arg_str = mutually_exclusive_args.map { |x| x.to_s.camelize(:lower) }.join(' or ')
raise Gitlab::Graphql::Errors::ArgumentError, "one and only one of #{arg_str} is required"
end
super
end
def resolve(**args)
board = authorized_find!(id: args[:board_id])
params = create_list_params(args)
response = create_list(board, params)
{
list: response.success? ? response.payload[:list] : nil,
errors: response.errors
}
end
private
def create_list(board, params)
raise NotImplementedError
end
def create_list_params(args)
params = args.slice(*mutually_exclusive_args).with_indifferent_access
params[:label_id] &&= ::GitlabSchema.parse_gid(params[:label_id], expected_type: ::Label).model_id
params
end
def mutually_exclusive_args
[:backlog, :label_id]
end
end
end
end
end

View File

@ -3,59 +3,32 @@
module Mutations
module Boards
module Lists
class Create < Base
class Create < BaseCreate
graphql_name 'BoardListCreate'
argument :backlog, GraphQL::BOOLEAN_TYPE,
required: false,
description: 'Create the backlog list.'
argument :board_id, ::Types::GlobalIDType[::Board],
required: true,
description: 'Global ID of the issue board to mutate.'
argument :label_id, ::Types::GlobalIDType[::Label],
required: false,
description: 'Global ID of an existing label.'
field :list,
Types::BoardListType,
null: true,
description: 'Issue list in the issue board.'
def ready?(**args)
if args.slice(*mutually_exclusive_args).size != 1
arg_str = mutually_exclusive_args.map { |x| x.to_s.camelize(:lower) }.join(' or ')
raise Gitlab::Graphql::Errors::ArgumentError, "one and only one of #{arg_str} is required"
end
super
end
def resolve(**args)
board = authorized_find!(id: args[:board_id])
params = create_list_params(args)
response = create_list(board, params)
{
list: response.success? ? response.payload[:list] : nil,
errors: response.errors
}
end
authorize :admin_list
private
def find_object(id:)
GitlabSchema.object_from_id(id, expected_type: ::Board)
end
def create_list(board, params)
create_list_service =
::Boards::Lists::CreateService.new(board.resource_parent, current_user, params)
create_list_service.execute(board)
end
# Overridden in EE
def create_list_params(args)
params = args.slice(*mutually_exclusive_args).with_indifferent_access
params[:label_id] &&= ::GitlabSchema.parse_gid(params[:label_id], expected_type: ::Label).model_id
params
end
# Overridden in EE
def mutually_exclusive_args
[:backlog, :label_id]
end
end
end
end

View File

@ -7,6 +7,12 @@ module Packages
included do
belongs_to :distribution, class_name: "Packages::Debian::#{container_type.capitalize}Distribution", inverse_of: :architectures
# files must be destroyed by ruby code in order to properly remove carrierwave uploads
has_many :files,
class_name: "Packages::Debian::#{container_type.capitalize}ComponentFile",
foreign_key: :architecture_id,
inverse_of: :architecture,
dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
validates :distribution,
presence: true

View File

@ -7,6 +7,12 @@ module Packages
included do
belongs_to :distribution, class_name: "Packages::Debian::#{container_type.capitalize}Distribution", inverse_of: :components
# files must be destroyed by ruby code in order to properly remove carrierwave uploads
has_many :files,
class_name: "Packages::Debian::#{container_type.capitalize}ComponentFile",
foreign_key: :component_id,
inverse_of: :component,
dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
validates :distribution,
presence: true

View File

@ -0,0 +1,101 @@
# frozen_string_literal: true
module Packages
module Debian
module ComponentFile
extend ActiveSupport::Concern
included do
include Sortable
include FileStoreMounter
def self.container_foreign_key
"#{container_type}_id".to_sym
end
def self.distribution_class
"::Packages::Debian::#{container_type.capitalize}Distribution".constantize
end
belongs_to :component, class_name: "Packages::Debian::#{container_type.capitalize}Component", inverse_of: :files
belongs_to :architecture, class_name: "Packages::Debian::#{container_type.capitalize}Architecture", inverse_of: :files, optional: true
enum file_type: { packages: 1, source: 2, di_packages: 3 }
enum compression_type: { gz: 1, bz2: 2, xz: 3 }
validates :component, presence: true
validates :file_type, presence: true
validates :architecture, presence: true, unless: :source?
validates :architecture, absence: true, if: :source?
validates :file, length: { minimum: 0, allow_nil: false }
validates :size, presence: true
validates :file_store, presence: true
validates :file_md5, presence: true
validates :file_sha256, presence: true
scope :with_container, ->(container) do
joins(component: :distribution)
.where("packages_debian_#{container_type}_distributions" => { container_foreign_key => container.id })
end
scope :with_codename_or_suite, ->(codename_or_suite) do
joins(component: :distribution)
.merge(distribution_class.with_codename_or_suite(codename_or_suite))
end
scope :with_component_name, ->(component_name) do
joins(:component)
.where("packages_debian_#{container_type}_components" => { name: component_name })
end
scope :with_file_type, ->(file_type) { where(file_type: file_type) }
scope :with_architecture_name, ->(architecture_name) do
left_outer_joins(:architecture)
.where("packages_debian_#{container_type}_architectures" => { name: architecture_name })
end
scope :with_compression_type, ->(compression_type) { where(compression_type: compression_type) }
scope :with_file_sha256, ->(file_sha256) { where(file_sha256: file_sha256) }
scope :preload_distribution, -> { includes(component: :distribution) }
mount_file_store_uploader Packages::Debian::ComponentFileUploader
before_validation :update_size_from_file
def file_name
case file_type
when 'di_packages'
'Packages'
else
file_type.capitalize
end
end
def relative_path
case file_type
when 'packages'
"#{component.name}/binary-#{architecture.name}/#{file_name}#{extension}"
when 'source'
"#{component.name}/source/#{file_name}#{extension}"
when 'di_packages'
"#{component.name}/debian-installer/binary-#{architecture.name}/#{file_name}#{extension}"
end
end
private
def extension
return '' unless compression_type
".#{compression_type}"
end
def update_size_from_file
self.size ||= file.size
end
end
end
end
end

View File

@ -18,10 +18,16 @@ module Packages
belongs_to container_type
belongs_to :creator, class_name: 'User'
# component_files must be destroyed by ruby code in order to properly remove carrierwave uploads
has_many :components,
class_name: "Packages::Debian::#{container_type.capitalize}Component",
foreign_key: :distribution_id,
inverse_of: :distribution
inverse_of: :distribution,
dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :component_files,
through: :components,
source: :files,
class_name: "Packages::Debian::#{container_type.capitalize}ComponentFile"
has_many :architectures,
class_name: "Packages::Debian::#{container_type.capitalize}Architecture",
foreign_key: :distribution_id,

View File

@ -75,7 +75,7 @@ class Group < Namespace
has_many :dependency_proxy_blobs, class_name: 'DependencyProxy::Blob'
has_many :dependency_proxy_manifests, class_name: 'DependencyProxy::Manifest'
# debian_distributions must be destroyed by ruby code in order to properly remove carrierwave uploads
# debian_distributions and associated component_files must be destroyed by ruby code in order to properly remove carrierwave uploads
has_many :debian_distributions, class_name: 'Packages::Debian::GroupDistribution', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
accepts_nested_attributes_for :variables, allow_destroy: true

View File

@ -6,6 +6,7 @@ module Operations
include AtomicInternalId
include IidRoutes
include Limitable
include Referable
self.table_name = 'operations_feature_flags'
self.limit_scope = :project
@ -65,6 +66,31 @@ module Operations
.reorder(:id)
.references(:operations_scopes)
end
def reference_prefix
'[feature_flag:'
end
def reference_pattern
@reference_pattern ||= %r{
#{Regexp.escape(reference_prefix)}(#{::Project.reference_pattern}\/)?(?<feature_flag>\d+)#{Regexp.escape(reference_postfix)}
}x
end
def link_reference_pattern
@link_reference_pattern ||= super("feature_flags", /(?<feature_flag>\d+)\/edit/)
end
def reference_postfix
']'
end
end
def to_reference(from = nil, full: false)
project
.to_reference_base(from, full: full)
.then { |reference_base| reference_base.present? ? "#{reference_base}/" : nil }
.then { |reference_base| "#{self.class.reference_prefix}#{reference_base}#{iid}#{self.class.reference_postfix}" }
end
def related_issues(current_user, preload:)

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class Packages::Debian::GroupComponentFile < ApplicationRecord
def self.container_type
:group
end
include Packages::Debian::ComponentFile
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class Packages::Debian::ProjectComponentFile < ApplicationRecord
def self.container_type
:project
end
include Packages::Debian::ComponentFile
end

View File

@ -200,7 +200,7 @@ class Project < ApplicationRecord
# Packages
has_many :packages, class_name: 'Packages::Package'
has_many :package_files, through: :packages, class_name: 'Packages::PackageFile'
# debian_distributions must be destroyed by ruby code in order to properly remove carrierwave uploads
# debian_distributions and associated component_files must be destroyed by ruby code in order to properly remove carrierwave uploads
has_many :debian_distributions, class_name: 'Packages::Debian::ProjectDistribution', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project

View File

@ -0,0 +1,71 @@
# frozen_string_literal: true
module Boards
module Lists
# This class is used by issue and epic board lists
# for creating new list
class BaseCreateService < Boards::BaseService
include Gitlab::Utils::StrongMemoize
def execute(board)
list = case type
when :backlog
create_backlog(board)
else
target = target(board)
position = next_position(board)
return ServiceResponse.error(message: _('%{board_target} not found') % { board_target: type.to_s.capitalize }) if target.blank?
create_list(board, type, target, position)
end
return ServiceResponse.error(message: list.errors.full_messages) unless list.persisted?
ServiceResponse.success(payload: { list: list })
end
private
def type
# We don't ever expect to have more than one list
# type param at once.
if params.key?('backlog')
:backlog
else
:label
end
end
def target(board)
strong_memoize(:target) do
available_labels.find_by(id: params[:label_id]) # rubocop: disable CodeReuse/ActiveRecord
end
end
def available_labels
::Labels::AvailableLabelsService.new(current_user, parent, {})
.available_labels
end
def next_position(board)
max_position = board.lists.movable.maximum(:position)
max_position.nil? ? 0 : max_position.succ
end
def create_list(board, type, target, position)
board.lists.create(create_list_attributes(type, target, position))
end
def create_list_attributes(type, target, position)
{ type => target, list_type: type, position: position }
end
def create_backlog(board)
return board.lists.backlog.first if board.lists.backlog.exists?
board.lists.create(list_type: :backlog, position: nil)
end
end
end
end

View File

@ -2,68 +2,7 @@
module Boards
module Lists
class CreateService < Boards::BaseService
include Gitlab::Utils::StrongMemoize
def execute(board)
list = case type
when :backlog
create_backlog(board)
else
target = target(board)
position = next_position(board)
return ServiceResponse.error(message: _('%{board_target} not found') % { board_target: type.to_s.capitalize }) if target.blank?
create_list(board, type, target, position)
end
return ServiceResponse.error(message: list.errors.full_messages) unless list.persisted?
ServiceResponse.success(payload: { list: list })
end
private
def type
# We don't ever expect to have more than one list
# type param at once.
if params.key?('backlog')
:backlog
else
:label
end
end
def target(board)
strong_memoize(:target) do
available_labels.find_by(id: params[:label_id]) # rubocop: disable CodeReuse/ActiveRecord
end
end
def available_labels
::Labels::AvailableLabelsService.new(current_user, parent, {})
.available_labels
end
def next_position(board)
max_position = board.lists.movable.maximum(:position)
max_position.nil? ? 0 : max_position.succ
end
def create_list(board, type, target, position)
board.lists.create(create_list_attributes(type, target, position))
end
def create_list_attributes(type, target, position)
{ type => target, list_type: type, position: position }
end
def create_backlog(board)
return board.lists.backlog.first if board.lists.backlog.exists?
board.lists.create(list_type: :backlog, position: nil)
end
class CreateService < Boards::Lists::BaseCreateService
end
end
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
class Packages::Debian::ComponentFileUploader < GitlabUploader
extend Workhorse::UploadPath
include ObjectStorage::Concern
storage_options Gitlab.config.packages
after :store, :schedule_background_upload
alias_method :upload, :model
def filename
model.file_name
end
def store_dir
dynamic_segment
end
private
def dynamic_segment
raise ObjectNotReadyError, 'Package model not ready' unless model.id && model.component.distribution.container_id
Gitlab::HashedPath.new("debian_#{model.class.container_type}_component_file", model.id, root_hash: model.component.distribution.container_id)
end
end

View File

@ -0,0 +1,5 @@
---
title: Fix Net::HTTP proxy encoding username and password
merge_request: 52368
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Debian Group and Project Distribution Component Files
merge_request: 52885
author: Mathieu Parent
type: added

View File

@ -0,0 +1,5 @@
---
title: Add GFM reference format for feature flags
merge_request: 53021
author:
type: added

View File

@ -0,0 +1,8 @@
---
name: feature_flag_contextual_issue
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53021
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/320741
milestone: '13.9'
type: development
group: group::release
default_enabled: false

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
# Monkey patch Net::HTTP to fix missing URL decoding for username and password in proxy settings
#
# See proposed upstream fix https://github.com/ruby/net-http/pull/5
# See Ruby-lang issue https://bugs.ruby-lang.org/issues/17542
# See issue on GitLab https://gitlab.com/gitlab-org/gitlab/-/issues/289836
module Net
class HTTP < Protocol
def proxy_user
if environment_variable_is_multiuser_safe? && @proxy_from_env
user = proxy_uri&.user
CGI.unescape(user) unless user.nil?
else
@proxy_user
end
end
def proxy_pass
if environment_variable_is_multiuser_safe? && @proxy_from_env
pass = proxy_uri&.password
CGI.unescape(pass) unless pass.nil?
else
@proxy_pass
end
end
def environment_variable_is_multiuser_safe?
ENVIRONMENT_VARIABLE_IS_MULTIUSER_SAFE
end
end
end

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
class CreatePackagesDebianProjectComponentFiles < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_ARCHITECTURE = 'idx_packages_debian_project_component_files_on_architecture_id'
disable_ddl_transaction!
def up
with_lock_retries do
unless table_exists?(:packages_debian_project_component_files)
create_table :packages_debian_project_component_files do |t|
t.timestamps_with_timezone
t.references :component,
foreign_key: { to_table: :packages_debian_project_components, on_delete: :restrict },
null: false,
index: true
t.references :architecture,
foreign_key: { to_table: :packages_debian_project_architectures, on_delete: :restrict },
index: { name: INDEX_ARCHITECTURE }
t.integer :size, null: false
t.integer :file_type, limit: 2, null: false
t.integer :compression_type, limit: 2
t.integer :file_store, limit: 2, default: 1, null: false
t.text :file, null: false
t.binary :file_md5, null: false
t.binary :file_sha256, null: false
end
end
end
add_text_limit :packages_debian_project_component_files, :file, 255
end
def down
drop_table :packages_debian_project_component_files
end
end

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
class CreatePackagesDebianGroupComponentFiles < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_ARCHITECTURE = 'idx_packages_debian_group_component_files_on_architecture_id'
disable_ddl_transaction!
def up
with_lock_retries do
unless table_exists?(:packages_debian_group_component_files)
create_table :packages_debian_group_component_files do |t|
t.timestamps_with_timezone
t.references :component,
foreign_key: { to_table: :packages_debian_group_components, on_delete: :restrict },
null: false,
index: true
t.references :architecture,
foreign_key: { to_table: :packages_debian_group_architectures, on_delete: :restrict },
index: { name: INDEX_ARCHITECTURE }
t.integer :size, null: false
t.integer :file_type, limit: 2, null: false
t.integer :compression_type, limit: 2
t.integer :file_store, limit: 2, default: 1, null: false
t.text :file, null: false
t.binary :file_md5, null: false
t.binary :file_sha256, null: false
end
end
end
add_text_limit :packages_debian_group_component_files, :file, 255
end
def down
drop_table :packages_debian_group_component_files
end
end

View File

@ -0,0 +1 @@
6fcaa4184ae69fabd6f2668cad19c38a8ae7c187053d60cdf4fcbdbc0443aa42

View File

@ -0,0 +1 @@
3f422a916b50cafd46b4a7486b6c3cc0a9992831a7dbc40c51323c835d845a0a

View File

@ -14894,6 +14894,31 @@ CREATE SEQUENCE packages_debian_group_architectures_id_seq
ALTER SEQUENCE packages_debian_group_architectures_id_seq OWNED BY packages_debian_group_architectures.id;
CREATE TABLE packages_debian_group_component_files (
id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
component_id bigint NOT NULL,
architecture_id bigint,
size integer NOT NULL,
file_type smallint NOT NULL,
compression_type smallint,
file_store smallint DEFAULT 1 NOT NULL,
file text NOT NULL,
file_md5 bytea NOT NULL,
file_sha256 bytea NOT NULL,
CONSTRAINT check_839e1685bc CHECK ((char_length(file) <= 255))
);
CREATE SEQUENCE packages_debian_group_component_files_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE packages_debian_group_component_files_id_seq OWNED BY packages_debian_group_component_files.id;
CREATE TABLE packages_debian_group_components (
id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
@ -14971,6 +14996,31 @@ CREATE SEQUENCE packages_debian_project_architectures_id_seq
ALTER SEQUENCE packages_debian_project_architectures_id_seq OWNED BY packages_debian_project_architectures.id;
CREATE TABLE packages_debian_project_component_files (
id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
component_id bigint NOT NULL,
architecture_id bigint,
size integer NOT NULL,
file_type smallint NOT NULL,
compression_type smallint,
file_store smallint DEFAULT 1 NOT NULL,
file text NOT NULL,
file_md5 bytea NOT NULL,
file_sha256 bytea NOT NULL,
CONSTRAINT check_e5af03fa2d CHECK ((char_length(file) <= 255))
);
CREATE SEQUENCE packages_debian_project_component_files_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE packages_debian_project_component_files_id_seq OWNED BY packages_debian_project_component_files.id;
CREATE TABLE packages_debian_project_components (
id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
@ -19021,12 +19071,16 @@ ALTER TABLE ONLY packages_conan_metadata ALTER COLUMN id SET DEFAULT nextval('pa
ALTER TABLE ONLY packages_debian_group_architectures ALTER COLUMN id SET DEFAULT nextval('packages_debian_group_architectures_id_seq'::regclass);
ALTER TABLE ONLY packages_debian_group_component_files ALTER COLUMN id SET DEFAULT nextval('packages_debian_group_component_files_id_seq'::regclass);
ALTER TABLE ONLY packages_debian_group_components ALTER COLUMN id SET DEFAULT nextval('packages_debian_group_components_id_seq'::regclass);
ALTER TABLE ONLY packages_debian_group_distributions ALTER COLUMN id SET DEFAULT nextval('packages_debian_group_distributions_id_seq'::regclass);
ALTER TABLE ONLY packages_debian_project_architectures ALTER COLUMN id SET DEFAULT nextval('packages_debian_project_architectures_id_seq'::regclass);
ALTER TABLE ONLY packages_debian_project_component_files ALTER COLUMN id SET DEFAULT nextval('packages_debian_project_component_files_id_seq'::regclass);
ALTER TABLE ONLY packages_debian_project_components ALTER COLUMN id SET DEFAULT nextval('packages_debian_project_components_id_seq'::regclass);
ALTER TABLE ONLY packages_debian_project_distributions ALTER COLUMN id SET DEFAULT nextval('packages_debian_project_distributions_id_seq'::regclass);
@ -20401,6 +20455,9 @@ ALTER TABLE ONLY packages_debian_file_metadata
ALTER TABLE ONLY packages_debian_group_architectures
ADD CONSTRAINT packages_debian_group_architectures_pkey PRIMARY KEY (id);
ALTER TABLE ONLY packages_debian_group_component_files
ADD CONSTRAINT packages_debian_group_component_files_pkey PRIMARY KEY (id);
ALTER TABLE ONLY packages_debian_group_components
ADD CONSTRAINT packages_debian_group_components_pkey PRIMARY KEY (id);
@ -20410,6 +20467,9 @@ ALTER TABLE ONLY packages_debian_group_distributions
ALTER TABLE ONLY packages_debian_project_architectures
ADD CONSTRAINT packages_debian_project_architectures_pkey PRIMARY KEY (id);
ALTER TABLE ONLY packages_debian_project_component_files
ADD CONSTRAINT packages_debian_project_component_files_pkey PRIMARY KEY (id);
ALTER TABLE ONLY packages_debian_project_components
ADD CONSTRAINT packages_debian_project_components_pkey PRIMARY KEY (id);
@ -21134,6 +21194,10 @@ CREATE UNIQUE INDEX idx_on_compliance_management_frameworks_namespace_id_name ON
CREATE INDEX idx_packages_build_infos_on_package_id ON packages_build_infos USING btree (package_id);
CREATE INDEX idx_packages_debian_group_component_files_on_architecture_id ON packages_debian_group_component_files USING btree (architecture_id);
CREATE INDEX idx_packages_debian_project_component_files_on_architecture_id ON packages_debian_project_component_files USING btree (architecture_id);
CREATE INDEX idx_packages_packages_on_project_id_name_version_package_type ON packages_packages USING btree (project_id, name, version, package_type);
CREATE INDEX idx_pkgs_deb_grp_architectures_on_distribution_id ON packages_debian_group_architectures USING btree (distribution_id);
@ -22636,10 +22700,14 @@ CREATE UNIQUE INDEX index_packages_conan_file_metadata_on_package_file_id ON pac
CREATE UNIQUE INDEX index_packages_conan_metadata_on_package_id_username_channel ON packages_conan_metadata USING btree (package_id, package_username, package_channel);
CREATE INDEX index_packages_debian_group_component_files_on_component_id ON packages_debian_group_component_files USING btree (component_id);
CREATE INDEX index_packages_debian_group_distributions_on_creator_id ON packages_debian_group_distributions USING btree (creator_id);
CREATE INDEX index_packages_debian_group_distributions_on_group_id ON packages_debian_group_distributions USING btree (group_id);
CREATE INDEX index_packages_debian_project_component_files_on_component_id ON packages_debian_project_component_files USING btree (component_id);
CREATE INDEX index_packages_debian_project_distributions_on_creator_id ON packages_debian_project_distributions USING btree (creator_id);
CREATE INDEX index_packages_debian_project_distributions_on_project_id ON packages_debian_project_distributions USING btree (project_id);
@ -24850,6 +24918,9 @@ ALTER TABLE ONLY group_group_links
ALTER TABLE ONLY geo_repository_updated_events
ADD CONSTRAINT fk_rails_2b70854c08 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY packages_debian_group_component_files
ADD CONSTRAINT fk_rails_2b8992dd83 FOREIGN KEY (architecture_id) REFERENCES packages_debian_group_architectures(id) ON DELETE RESTRICT;
ALTER TABLE ONLY boards_epic_board_labels
ADD CONSTRAINT fk_rails_2bedeb8799 FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE;
@ -25621,6 +25692,9 @@ ALTER TABLE ONLY merge_trains
ALTER TABLE ONLY application_settings
ADD CONSTRAINT fk_rails_b53e481273 FOREIGN KEY (custom_project_templates_group_id) REFERENCES namespaces(id) ON DELETE SET NULL;
ALTER TABLE ONLY packages_debian_project_component_files
ADD CONSTRAINT fk_rails_b543a9622b FOREIGN KEY (architecture_id) REFERENCES packages_debian_project_architectures(id) ON DELETE RESTRICT;
ALTER TABLE ONLY namespace_aggregation_schedules
ADD CONSTRAINT fk_rails_b565c8d16c FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE;
@ -25645,6 +25719,9 @@ ALTER TABLE ONLY lists
ALTER TABLE ONLY security_findings
ADD CONSTRAINT fk_rails_bb63863cf1 FOREIGN KEY (scan_id) REFERENCES security_scans(id) ON DELETE CASCADE;
ALTER TABLE ONLY packages_debian_project_component_files
ADD CONSTRAINT fk_rails_bbe9ebfbd9 FOREIGN KEY (component_id) REFERENCES packages_debian_project_components(id) ON DELETE RESTRICT;
ALTER TABLE ONLY approval_merge_request_rules_users
ADD CONSTRAINT fk_rails_bc8972fa55 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
@ -25813,6 +25890,9 @@ ALTER TABLE ONLY vulnerability_occurrence_pipelines
ALTER TABLE ONLY deployment_merge_requests
ADD CONSTRAINT fk_rails_dcbce9f4df FOREIGN KEY (deployment_id) REFERENCES deployments(id) ON DELETE CASCADE;
ALTER TABLE ONLY packages_debian_group_component_files
ADD CONSTRAINT fk_rails_dd262386e9 FOREIGN KEY (component_id) REFERENCES packages_debian_group_components(id) ON DELETE RESTRICT;
ALTER TABLE ONLY user_callouts
ADD CONSTRAINT fk_rails_ddfdd80f3d FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;

View File

@ -122,7 +122,7 @@ Do not run `git prune` or `git gc` in pool repositories! This can
cause data loss in "real" repositories that depend on the pool in
question.
Forks of public projects are deduplicated by creating a third repository, the
Forks of public and internal projects are deduplicated by creating a third repository, the
object pool, containing the objects from the source project. Using
`objects/info/alternates`, the source project and forks use the object pool for
shared objects. Objects are moved from the source project to the object pool

View File

@ -2361,7 +2361,7 @@ type BoardListCreatePayload {
errors: [String!]!
"""
List of the issue board.
Issue list in the issue board.
"""
list: BoardList
}
@ -9155,6 +9155,51 @@ type EpicBoardEdge {
node: EpicBoard
}
"""
Autogenerated input type of EpicBoardListCreate
"""
input EpicBoardListCreateInput {
"""
Create the backlog list.
"""
backlog: Boolean
"""
Global ID of the issue board to mutate.
"""
boardId: BoardsEpicBoardID!
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Global ID of an existing label.
"""
labelId: LabelID
}
"""
Autogenerated return type of EpicBoardListCreate
"""
type EpicBoardListCreatePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
Epic list in the epic board.
"""
list: EpicList
}
"""
The connection type for Epic.
"""
@ -16235,6 +16280,7 @@ type Mutation {
environmentsCanaryIngressUpdate(input: EnvironmentsCanaryIngressUpdateInput!): EnvironmentsCanaryIngressUpdatePayload
epicAddIssue(input: EpicAddIssueInput!): EpicAddIssuePayload
epicBoardCreate(input: EpicBoardCreateInput!): EpicBoardCreatePayload
epicBoardListCreate(input: EpicBoardListCreateInput!): EpicBoardListCreatePayload
epicSetSubscription(input: EpicSetSubscriptionInput!): EpicSetSubscriptionPayload
epicTreeReorder(input: EpicTreeReorderInput!): EpicTreeReorderPayload
exportRequirements(input: ExportRequirementsInput!): ExportRequirementsPayload

View File

@ -6045,20 +6045,6 @@
"description": "Autogenerated input type of BoardListCreate",
"fields": null,
"inputFields": [
{
"name": "boardId",
"description": "Global ID of the issue board to mutate.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "BoardID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "backlog",
"description": "Create the backlog list.",
@ -6079,6 +6065,20 @@
},
"defaultValue": null
},
{
"name": "boardId",
"description": "Global ID of the issue board to mutate.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "BoardID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "milestoneId",
"description": "Global ID of an existing milestone.",
@ -6171,7 +6171,7 @@
},
{
"name": "list",
"description": "List of the issue board.",
"description": "Issue list in the issue board.",
"args": [
],
@ -25287,6 +25287,128 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "EpicBoardListCreateInput",
"description": "Autogenerated input type of EpicBoardListCreate",
"fields": null,
"inputFields": [
{
"name": "backlog",
"description": "Create the backlog list.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null
},
{
"name": "labelId",
"description": "Global ID of an existing label.",
"type": {
"kind": "SCALAR",
"name": "LabelID",
"ofType": null
},
"defaultValue": null
},
{
"name": "boardId",
"description": "Global ID of the issue board to mutate.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "BoardsEpicBoardID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "EpicBoardListCreatePayload",
"description": "Autogenerated return type of EpicBoardListCreate",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "list",
"description": "Epic list in the epic board.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "EpicList",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "EpicConnection",
@ -46077,6 +46199,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "epicBoardListCreate",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "EpicBoardListCreateInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "EpicBoardListCreatePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "epicSetSubscription",
"description": null,

View File

@ -357,7 +357,7 @@ Autogenerated return type of BoardListCreate.
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `list` | BoardList | List of the issue board. |
| `list` | BoardList | Issue list in the issue board. |
### BoardListUpdateLimitMetricsPayload
@ -1482,6 +1482,16 @@ Autogenerated return type of EpicBoardCreate.
| `epicBoard` | EpicBoard | The created epic board. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
### EpicBoardListCreatePayload
Autogenerated return type of EpicBoardListCreate.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `list` | EpicList | Epic list in the epic board. |
### EpicDescendantCount
Counts of descendent epics.

View File

@ -444,6 +444,7 @@ GFM recognizes the following:
| snippet | `$123` | `namespace/project$123` | `project$123` |
| epic **(ULTIMATE)** | `&123` | `group1/subgroup&123` | |
| vulnerability **(ULTIMATE)** (1)| `[vulnerability:123]` | `[vulnerability:namespace/project/123]` | `[vulnerability:project/123]` |
| feature flag | `[feature_flag:123]` | `[feature_flag:namespace/project/123]` | `[feature_flag:project/123]` |
| label by ID | `~123` | `namespace/project~123` | `project~123` |
| one-word label by name | `~bug` | `namespace/project~bug` | `project~bug` |
| multi-word label by name | `~"feature request"` | `namespace/project~"feature request"` | `project~"feature request"` |

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
module Banzai
module Filter
class FeatureFlagReferenceFilter < IssuableReferenceFilter
self.reference_type = :feature_flag
def self.object_class
Operations::FeatureFlag
end
def self.object_sym
:feature_flag
end
def parent_records(parent, ids)
return self.class.object_class.none unless Feature.enabled?(:feature_flag_contextual_issue, parent)
parent.operations_feature_flags.where(iid: ids.to_a)
end
def url_for_object(feature_flag, project)
::Gitlab::Routing.url_helpers.edit_project_feature_flag_url(
project,
feature_flag.iid,
only_path: context[:only_path]
)
end
def object_link_title(object, matches)
object.name
end
end
end
end

View File

@ -62,7 +62,8 @@ module Banzai
Filter::CommitReferenceFilter,
Filter::LabelReferenceFilter,
Filter::MilestoneReferenceFilter,
Filter::AlertReferenceFilter
Filter::AlertReferenceFilter,
Filter::FeatureFlagReferenceFilter
]
end

View File

@ -24,7 +24,8 @@ module Banzai
Filter::SnippetReferenceFilter,
Filter::CommitRangeReferenceFilter,
Filter::CommitReferenceFilter,
Filter::AlertReferenceFilter
Filter::AlertReferenceFilter,
Filter::FeatureFlagReferenceFilter
]
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
module Banzai
module ReferenceParser
class FeatureFlagParser < BaseParser
self.reference_type = :feature_flag
def references_relation
Operations::FeatureFlag
end
private
def can_read_reference?(user, feature_flag, node)
can?(user, :read_feature_flag, feature_flag)
end
end
end
end

View File

@ -3,7 +3,7 @@
module QA
module Resource
class CiVariable < Base
attr_accessor :key, :value, :masked
attr_accessor :key, :value, :masked, :protected
attribute :project do
Project.fabricate! do |resource|
@ -12,6 +12,11 @@ module QA
end
end
def initialize
@masked = false
@protected = false
end
def fabricate!
project.visit!
@ -49,7 +54,8 @@ module QA
{
key: key,
value: value,
masked: masked
masked: masked,
protected: protected
}
end
end

View File

@ -25,10 +25,6 @@ module QA
attribute :template_name
attribute :import
attribute :default_branch do
api_response[:default_branch] || Runtime::Env.default_branch
end
attribute :group do
Group.fabricate!
end
@ -224,6 +220,10 @@ module QA
parse_body(get(Runtime::API::Request.new(api_client, api_commits_path).url))
end
def default_branch
reload!.api_response[:default_branch] || Runtime::Env.default_branch
end
def import_status
response = get Runtime::API::Request.new(api_client, "/projects/#{id}/import").url

View File

@ -12,14 +12,11 @@ module QA
end
end
def branch_name
@branch_name ||= wiki.project.default_branch
end
def initialize
@file_name = 'Home.md'
@file_content = 'This line was created using git push'
@commit_message = 'Updating using git push'
@branch_name = 'master'
@new_branch = false
end

View File

@ -14,10 +14,12 @@ module QA
Git::Repository.perform do |repository|
repository.uri = project.repository_http_location.uri
repository.use_default_credentials
repository.default_branch = project.default_branch
repository.act do
clone
configure_identity('GitLab QA', 'root@gitlab.com')
checkout(default_branch, new_branch: true)
commit_file('test.rb', 'class Test; end', 'Add Test class')
commit_file('README.md', '# Test', 'Add Readme')
push_changes

View File

@ -26,6 +26,8 @@ module QA
repository.use_default_credentials
repository.clone
repository.configure_identity(username, email)
repository.default_branch = project.default_branch
repository.checkout(project.default_branch, new_branch: true)
git_protocol_reported = repository.push_with_git_protocol(
git_protocol,

View File

@ -49,6 +49,8 @@ module QA
repository.use_ssh_key(ssh_key)
repository.clone
repository.configure_identity(username, email)
repository.default_branch = project.default_branch
repository.checkout(project.default_branch, new_branch: true)
git_protocol_reported = repository.push_with_git_protocol(
git_protocol,

View File

@ -0,0 +1,148 @@
# frozen_string_literal: true
require 'faker'
module QA
RSpec.describe 'Verify', :runner do
describe 'Pipeline with protected variable' do
let(:executor) { "qa-runner-#{Faker::Alphanumeric.alphanumeric(8)}" }
let(:protected_value) { Faker::Alphanumeric.alphanumeric(8) }
let(:project) do
Resource::Project.fabricate_via_api! do |project|
project.name = 'project-with-ci-variables'
project.description = 'project with CI variables'
end
end
let!(:runner) do
Resource::Runner.fabricate! do |runner|
runner.project = project
runner.name = executor
runner.tags = [executor]
end
end
let!(:ci_file) do
Resource::Repository::Commit.fabricate_via_api! do |commit|
commit.project = project
commit.commit_message = 'Add .gitlab-ci.yml'
commit.add_files(
[
{
file_path: '.gitlab-ci.yml',
content: <<~YAML
job:
tags:
- #{executor}
script: echo $PROTECTED_VARIABLE
YAML
}
]
)
end
end
let(:developer) do
Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1)
end
let(:maintainer) do
Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_2, Runtime::Env.gitlab_qa_password_2)
end
before do
Flow::Login.sign_in
project.visit!
project.add_member(developer)
project.add_member(maintainer, Resource::Members::AccessLevel::MAINTAINER)
add_ci_variable
end
after do
runner.remove_via_api!
end
it 'exposes variable on protected branch', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/156' do
create_protected_branch
[developer, maintainer].each do |user|
user_commit_to_protected_branch(Runtime::API::Client.new(:gitlab, user: user))
go_to_pipeline_job(user)
Page::Project::Job::Show.perform do |show|
expect(show.output).to have_content(protected_value), 'Expect protected variable to be in job log.'
end
end
end
it 'does not expose variable on unprotected branch', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/156' do
[developer, maintainer].each do |user|
create_merge_request(Runtime::API::Client.new(:gitlab, user: user))
go_to_pipeline_job(user)
Page::Project::Job::Show.perform do |show|
expect(show.output).to have_no_content(protected_value), 'Expect protected variable to NOT be in job log.'
end
end
end
private
def add_ci_variable
Resource::CiVariable.fabricate_via_api! do |ci_variable|
ci_variable.project = project
ci_variable.key = 'PROTECTED_VARIABLE'
ci_variable.value = protected_value
ci_variable.protected = true
end
end
def create_protected_branch
# Using default setups, which allows access for developer and maintainer
Resource::ProtectedBranch.fabricate_via_api! do |resource|
resource.branch_name = 'protected-branch'
resource.project = project
end
end
def user_commit_to_protected_branch(api_client)
Resource::Repository::Commit.fabricate_via_api! do |commit|
commit.api_client = api_client
commit.project = project
commit.branch = 'protected-branch'
commit.commit_message = Faker::Lorem.sentence
commit.add_files(
[
{
file_path: "#{Faker::Lorem.word}.txt",
content: Faker::Lorem.sentence
}
]
)
end
end
def create_merge_request(api_client)
Resource::MergeRequest.fabricate_via_api! do |merge_request|
merge_request.api_client = api_client
merge_request.project = project
merge_request.description = Faker::Lorem.sentence
merge_request.target_new_branch = false
merge_request.file_name = "#{Faker::Lorem.word}.txt"
merge_request.file_content = Faker::Lorem.sentence
end
end
def go_to_pipeline_job(user)
Flow::Login.sign_in(as: user)
project.visit!
Flow::Pipeline.visit_latest_pipeline(pipeline_condition: 'completed')
Page::Project::Pipeline::Show.perform do |pipeline|
pipeline.click_job('job')
end
end
end
end
end

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
FactoryBot.define do
factory :debian_project_component_file, class: 'Packages::Debian::ProjectComponentFile' do
component { association(:debian_project_component) }
architecture { association(:debian_project_architecture, distribution: component.distribution) }
factory :debian_group_component_file, class: 'Packages::Debian::GroupComponentFile' do
component { association(:debian_group_component) }
architecture { association(:debian_group_architecture, distribution: component.distribution) }
end
file_type { :packages }
after(:build) do |component_file, evaluator|
component_file.file = fixture_file_upload('spec/fixtures/packages/debian/distribution/Packages')
end
file_md5 { '12345abcde' }
file_sha256 { 'be93151dc23ac34a82752444556fe79b32c7a1ad' }
trait(:packages) do
file_type { :packages }
end
trait(:source) do
file_type { :source }
architecture { nil }
end
trait(:di_packages) do
file_type { :di_packages }
end
trait(:object_storage) do
file_store { Packages::PackageFileUploader::Store::REMOTE }
end
end
end

View File

@ -14,7 +14,7 @@ FactoryBot.define do
trait(:with_file) do
after(:build) do |distribution, evaluator|
distribution.file = fixture_file_upload('spec/fixtures/packages/debian/README.md')
distribution.file = fixture_file_upload('spec/fixtures/packages/debian/distribution/Release')
end
end

View File

@ -0,0 +1,2 @@
Package: example-package
Description: This is an incomplete Packages file

View File

@ -0,0 +1 @@
Codename: fixture-distribution

View File

@ -6,7 +6,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility';
import boardsStore from '~/boards/stores/boards_store';
import { formType } from '~/boards/constants';
import BoardForm from '~/boards/components/board_form.vue';
import updateBoardMutation from '~/boards/graphql/board_update.mutation.graphql';
import createBoardMutation from '~/boards/graphql/board_create.mutation.graphql';
@ -35,6 +35,7 @@ const defaultProps = {
labelsPath: `${TEST_HOST}/labels/path`,
labelsWebUrl: `${TEST_HOST}/-/labels`,
currentBoard,
currentPage: '',
};
describe('BoardForm', () => {
@ -75,14 +76,12 @@ describe('BoardForm', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
boardsStore.state.currentPage = null;
mutate = null;
});
describe('when user can not admin the board', () => {
beforeEach(() => {
boardsStore.state.currentPage = 'new';
createComponent();
createComponent({ currentPage: formType.new });
});
it('hides modal footer when user is not a board admin', () => {
@ -100,8 +99,7 @@ describe('BoardForm', () => {
describe('when user can admin the board', () => {
beforeEach(() => {
boardsStore.state.currentPage = 'new';
createComponent({ canAdminBoard: true });
createComponent({ canAdminBoard: true, currentPage: formType.new });
});
it('shows modal footer when user is a board admin', () => {
@ -118,13 +116,9 @@ describe('BoardForm', () => {
});
describe('when creating a new board', () => {
beforeEach(() => {
boardsStore.state.currentPage = 'new';
});
describe('on non-scoped-board', () => {
beforeEach(() => {
createComponent({ canAdminBoard: true });
createComponent({ canAdminBoard: true, currentPage: formType.new });
});
it('clears the form', () => {
@ -165,7 +159,7 @@ describe('BoardForm', () => {
});
it('does not call API if board name is empty', async () => {
createComponent({ canAdminBoard: true });
createComponent({ canAdminBoard: true, currentPage: formType.new });
findInput().trigger('keyup.enter', { metaKey: true });
await waitForPromises();
@ -174,7 +168,7 @@ describe('BoardForm', () => {
});
it('calls a correct GraphQL mutation and redirects to correct page from existing board', async () => {
createComponent({ canAdminBoard: true });
createComponent({ canAdminBoard: true, currentPage: formType.new });
fillForm();
await waitForPromises();
@ -194,7 +188,7 @@ describe('BoardForm', () => {
it('shows an error flash if GraphQL mutation fails', async () => {
mutate = jest.fn().mockRejectedValue('Houston, we have a problem');
createComponent({ canAdminBoard: true });
createComponent({ canAdminBoard: true, currentPage: formType.new });
fillForm();
await waitForPromises();
@ -209,13 +203,9 @@ describe('BoardForm', () => {
});
describe('when editing a board', () => {
beforeEach(() => {
boardsStore.state.currentPage = 'edit';
});
describe('on non-scoped-board', () => {
beforeEach(() => {
createComponent({ canAdminBoard: true });
createComponent({ canAdminBoard: true, currentPage: formType.edit });
});
it('clears the form', () => {
@ -247,7 +237,7 @@ describe('BoardForm', () => {
},
});
window.location = new URL('https://test/boards/1');
createComponent({ canAdminBoard: true });
createComponent({ canAdminBoard: true, currentPage: formType.edit });
findInput().trigger('keyup.enter', { metaKey: true });
@ -273,7 +263,7 @@ describe('BoardForm', () => {
},
});
window.location = new URL('https://test/boards/1?group_by=epic');
createComponent({ canAdminBoard: true });
createComponent({ canAdminBoard: true, currentPage: formType.edit });
findInput().trigger('keyup.enter', { metaKey: true });
@ -294,7 +284,7 @@ describe('BoardForm', () => {
it('shows an error flash if GraphQL mutation fails', async () => {
mutate = jest.fn().mockRejectedValue('Houston, we have a problem');
createComponent({ canAdminBoard: true });
createComponent({ canAdminBoard: true, currentPage: formType.edit });
findInput().trigger('keyup.enter', { metaKey: true });
await waitForPromises();
@ -308,24 +298,20 @@ describe('BoardForm', () => {
});
describe('when deleting a board', () => {
beforeEach(() => {
boardsStore.state.currentPage = 'delete';
});
it('passes correct primary action text and variant', () => {
createComponent({ canAdminBoard: true });
createComponent({ canAdminBoard: true, currentPage: formType.delete });
expect(findModalActionPrimary().text).toBe('Delete');
expect(findModalActionPrimary().attributes[0].variant).toBe('danger');
});
it('renders delete confirmation message', () => {
createComponent({ canAdminBoard: true });
createComponent({ canAdminBoard: true, currentPage: formType.delete });
expect(findDeleteConfirmation().exists()).toBe(true);
});
it('calls a correct GraphQL mutation and redirects to correct page after deleting board', async () => {
mutate = jest.fn().mockResolvedValue({});
createComponent({ canAdminBoard: true });
createComponent({ canAdminBoard: true, currentPage: formType.delete });
findModal().vm.$emit('primary');
await waitForPromises();
@ -343,7 +329,7 @@ describe('BoardForm', () => {
it('shows an error flash if GraphQL mutation fails', async () => {
mutate = jest.fn().mockRejectedValue('Houston, we have a problem');
createComponent({ canAdminBoard: true });
createComponent({ canAdminBoard: true, currentPage: formType.delete });
findModal().vm.$emit('primary');
await waitForPromises();

View File

@ -0,0 +1,214 @@
import { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui';
import { TEST_HOST } from 'spec/test_constants';
import BoardsSelector from '~/boards/components/boards_selector_deprecated.vue';
import boardsStore from '~/boards/stores/boards_store';
const throttleDuration = 1;
function boardGenerator(n) {
return new Array(n).fill().map((board, index) => {
const id = `${index}`;
const name = `board${id}`;
return {
id,
name,
};
});
}
describe('BoardsSelector', () => {
let wrapper;
let allBoardsResponse;
let recentBoardsResponse;
const boards = boardGenerator(20);
const recentBoards = boardGenerator(5);
const fillSearchBox = (filterTerm) => {
const searchBox = wrapper.find({ ref: 'searchBox' });
const searchBoxInput = searchBox.find('input');
searchBoxInput.setValue(filterTerm);
searchBoxInput.trigger('input');
};
const getDropdownItems = () => wrapper.findAll('.js-dropdown-item');
const getDropdownHeaders = () => wrapper.findAll(GlDropdownSectionHeader);
const getLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findDropdown = () => wrapper.find(GlDropdown);
beforeEach(() => {
const $apollo = {
queries: {
boards: {
loading: false,
},
},
};
boardsStore.setEndpoints({
boardsEndpoint: '',
recentBoardsEndpoint: '',
listsEndpoint: '',
bulkUpdatePath: '',
boardId: '',
});
allBoardsResponse = Promise.resolve({
data: {
group: {
boards: {
edges: boards.map((board) => ({ node: board })),
},
},
},
});
recentBoardsResponse = Promise.resolve({
data: recentBoards,
});
boardsStore.allBoards = jest.fn(() => allBoardsResponse);
boardsStore.recentBoards = jest.fn(() => recentBoardsResponse);
wrapper = mount(BoardsSelector, {
propsData: {
throttleDuration,
currentBoard: {
id: 1,
name: 'Development',
milestone_id: null,
weight: null,
assignee_id: null,
labels: [],
},
boardBaseUrl: `${TEST_HOST}/board/base/url`,
hasMissingBoards: false,
canAdminBoard: true,
multipleIssueBoardsAvailable: true,
labelsPath: `${TEST_HOST}/labels/path`,
labelsWebUrl: `${TEST_HOST}/labels`,
projectId: 42,
groupId: 19,
scopedIssueBoardFeatureEnabled: true,
weights: [],
},
mocks: { $apollo },
attachTo: document.body,
});
wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => {
wrapper.setData({
[options.loadingKey]: true,
});
});
// Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
findDropdown().vm.$emit('show');
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('loading', () => {
// we are testing loading state, so don't resolve responses until after the tests
afterEach(() => {
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
});
it('shows loading spinner', () => {
expect(getDropdownHeaders()).toHaveLength(0);
expect(getDropdownItems()).toHaveLength(0);
expect(getLoadingIcon().exists()).toBe(true);
});
});
describe('loaded', () => {
beforeEach(async () => {
await wrapper.setData({
loadingBoards: false,
});
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
});
it('hides loading spinner', () => {
expect(getLoadingIcon().exists()).toBe(false);
});
describe('filtering', () => {
beforeEach(() => {
wrapper.setData({
boards,
});
return nextTick();
});
it('shows all boards without filtering', () => {
expect(getDropdownItems()).toHaveLength(boards.length + recentBoards.length);
});
it('shows only matching boards when filtering', () => {
const filterTerm = 'board1';
const expectedCount = boards.filter((board) => board.name.includes(filterTerm)).length;
fillSearchBox(filterTerm);
return nextTick().then(() => {
expect(getDropdownItems()).toHaveLength(expectedCount);
});
});
it('shows message if there are no matching boards', () => {
fillSearchBox('does not exist');
return nextTick().then(() => {
expect(getDropdownItems()).toHaveLength(0);
expect(wrapper.text().includes('No matching boards found')).toBe(true);
});
});
});
describe('recent boards section', () => {
it('shows only when boards are greater than 10', () => {
wrapper.setData({
boards,
});
return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(2);
});
});
it('does not show when boards are less than 10', () => {
wrapper.setData({
boards: boards.slice(0, 5),
});
return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(0);
});
});
it('does not show when recentBoards api returns empty array', () => {
wrapper.setData({
recentBoards: [],
});
return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(0);
});
});
it('does not show when search is active', () => {
fillSearchBox('Random string');
return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(0);
});
});
});
});
});

View File

@ -1,9 +1,10 @@
import { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'spec/test_constants';
import axios from '~/lib/utils/axios_utils';
import BoardsSelector from '~/boards/components/boards_selector.vue';
import boardsStore from '~/boards/stores/boards_store';
const throttleDuration = 1;
@ -23,6 +24,7 @@ describe('BoardsSelector', () => {
let wrapper;
let allBoardsResponse;
let recentBoardsResponse;
let mock;
const boards = boardGenerator(20);
const recentBoards = boardGenerator(5);
@ -39,6 +41,7 @@ describe('BoardsSelector', () => {
const findDropdown = () => wrapper.find(GlDropdown);
beforeEach(() => {
mock = new MockAdapter(axios);
const $apollo = {
queries: {
boards: {
@ -47,14 +50,6 @@ describe('BoardsSelector', () => {
},
};
boardsStore.setEndpoints({
boardsEndpoint: '',
recentBoardsEndpoint: '',
listsEndpoint: '',
bulkUpdatePath: '',
boardId: '',
});
allBoardsResponse = Promise.resolve({
data: {
group: {
@ -68,9 +63,6 @@ describe('BoardsSelector', () => {
data: recentBoards,
});
boardsStore.allBoards = jest.fn(() => allBoardsResponse);
boardsStore.recentBoards = jest.fn(() => recentBoardsResponse);
wrapper = mount(BoardsSelector, {
propsData: {
throttleDuration,
@ -95,6 +87,10 @@ describe('BoardsSelector', () => {
},
mocks: { $apollo },
attachTo: document.body,
provide: {
fullPath: '',
recentBoardsEndpoint: `${TEST_HOST}/recent`,
},
});
wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => {
@ -103,6 +99,8 @@ describe('BoardsSelector', () => {
});
});
mock.onGet(`${TEST_HOST}/recent`).replyOnce(200, recentBoards);
// Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
findDropdown().vm.$emit('show');
});
@ -110,6 +108,7 @@ describe('BoardsSelector', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
mock.restore();
});
describe('loading', () => {
@ -133,7 +132,8 @@ describe('BoardsSelector', () => {
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
});
it('hides loading spinner', () => {
it('hides loading spinner', async () => {
await wrapper.vm.$nextTick();
expect(getLoadingIcon().exists()).toBe(false);
});

View File

@ -3,84 +3,8 @@
require 'spec_helper'
RSpec.describe Mutations::Boards::Lists::Create do
include GraphqlHelpers
let_it_be(:group) { create(:group, :private) }
let_it_be(:board) { create(:board, group: group) }
let_it_be(:user) { create(:user) }
let_it_be(:guest) { create(:user) }
let(:current_user) { user }
let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
let(:list_create_params) { {} }
before_all do
group.add_reporter(user)
group.add_guest(guest)
end
subject { mutation.resolve(board_id: board.to_global_id.to_s, **list_create_params) }
describe '#ready?' do
it 'raises an error if required arguments are missing' do
expect { mutation.ready?(board_id: 'some id') }
.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /one and only one of/)
end
it 'raises an error if too many required arguments are specified' do
expect { mutation.ready?(board_id: 'some id', backlog: true, label_id: 'some label') }
.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /one and only one of/)
end
end
describe '#resolve' do
context 'with proper permissions' do
describe 'backlog list' do
let(:list_create_params) { { backlog: true } }
it 'creates one and only one backlog' do
expect { subject }.to change { board.lists.backlog.count }.from(0).to(1)
expect(board.lists.backlog.first.list_type).to eq 'backlog'
backlog_id = board.lists.backlog.first.id
expect { subject }.not_to change { board.lists.backlog.count }
expect(board.lists.backlog.last.id).to eq backlog_id
end
end
describe 'label list' do
let_it_be(:dev_label) do
create(:group_label, title: 'Development', color: '#FFAABB', group: group)
end
let(:list_create_params) { { label_id: dev_label.to_global_id.to_s } }
it 'creates a new issue board list for labels' do
expect { subject }.to change { board.lists.count }.from(1).to(2)
new_list = subject[:list]
expect(new_list.title).to eq dev_label.title
expect(new_list.position).to eq 0
end
context 'when label not found' do
let(:list_create_params) { { label_id: "gid://gitlab/Label/#{non_existing_record_id}" } }
it 'returns an error' do
expect(subject[:errors]).to include 'Label not found'
end
end
end
end
context 'without proper permissions' do
let(:current_user) { guest }
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
it_behaves_like 'board lists create mutation'
end

View File

@ -0,0 +1,86 @@
# frozen_string_literal: true
require 'fast_spec_helper'
RSpec.describe 'Net::HTTP patch proxy user and password encoding' do
let(:net_http) { Net::HTTP.new('hostname.example') }
describe '#proxy_user' do
subject { net_http.proxy_user }
it { is_expected.to eq(nil) }
context 'with http_proxy env' do
let(:http_proxy) { 'http://proxy.example:8000' }
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with('http_proxy').and_return(http_proxy)
end
it { is_expected.to eq(nil) }
context 'and user:password authentication' do
let(:http_proxy) { 'http://Y%5CX:R%25S%5D%20%3FX@proxy.example:8000' }
context 'when on multiuser safe platform' do
# linux, freebsd, darwin are considered multi user safe platforms
# See https://github.com/ruby/net-http/blob/v0.1.1/lib/net/http.rb#L1174-L1178
before do
allow(net_http).to receive(:environment_variable_is_multiuser_safe?).and_return(true)
end
it { is_expected.to eq 'Y\\X' }
end
context 'when not on multiuser safe platform' do
before do
allow(net_http).to receive(:environment_variable_is_multiuser_safe?).and_return(false)
end
it { is_expected.to be_nil }
end
end
end
end
describe '#proxy_pass' do
subject { net_http.proxy_pass }
it { is_expected.to eq(nil) }
context 'with http_proxy env' do
let(:http_proxy) { 'http://proxy.example:8000' }
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with('http_proxy').and_return(http_proxy)
end
it { is_expected.to eq(nil) }
context 'and user:password authentication' do
let(:http_proxy) { 'http://Y%5CX:R%25S%5D%20%3FX@proxy.example:8000' }
context 'when on multiuser safe platform' do
# linux, freebsd, darwin are considered multi user safe platforms
# See https://github.com/ruby/net-http/blob/v0.1.1/lib/net/http.rb#L1174-L1178
before do
allow(net_http).to receive(:environment_variable_is_multiuser_safe?).and_return(true)
end
it { is_expected.to eq 'R%S] ?X' }
end
context 'when not on multiuser safe platform' do
before do
allow(net_http).to receive(:environment_variable_is_multiuser_safe?).and_return(false)
end
it { is_expected.to be_nil }
end
end
end
end
end

View File

@ -0,0 +1,235 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Banzai::Filter::FeatureFlagReferenceFilter do
include FilterSpecHelper
let_it_be(:project) { create(:project, :public) }
let_it_be(:feature_flag) { create(:operations_feature_flag, project: project) }
let_it_be(:reference) { feature_flag.to_reference }
it 'requires project context' do
expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
end
%w(pre code a style).each do |elem|
it "ignores valid references contained inside '#{elem}' element" do
exp = act = "<#{elem}>Feature Flag #{reference}</#{elem}>"
expect(reference_filter(act).to_html).to eq exp
end
end
context 'with internal reference' do
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
expect(doc.css('a').first.attr('href')).to eq urls.edit_project_feature_flag_url(project, feature_flag)
end
it 'links with adjacent text' do
doc = reference_filter("Feature Flag (#{reference}.)")
expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(reference)}</a>\.\)})
end
it 'ignores invalid feature flag IIDs' do
exp = act = "Check [feature_flag:#{non_existing_record_id}]"
expect(reference_filter(act).to_html).to eq exp
end
it 'includes a title attribute' do
doc = reference_filter("Feature Flag #{reference}")
expect(doc.css('a').first.attr('title')).to eq feature_flag.name
end
it 'escapes the title attribute' do
allow(feature_flag).to receive(:name).and_return(%{"></a>whatever<a title="})
doc = reference_filter("Feature Flag #{reference}")
expect(doc.text).to eq "Feature Flag #{reference}"
end
it 'includes default classes' do
doc = reference_filter("Feature Flag #{reference}")
expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-feature_flag has-tooltip'
end
it 'includes a data-project attribute' do
doc = reference_filter("Feature Flag #{reference}")
link = doc.css('a').first
expect(link).to have_attribute('data-project')
expect(link.attr('data-project')).to eq project.id.to_s
end
it 'includes a data-feature-flag attribute' do
doc = reference_filter("See #{reference}")
link = doc.css('a').first
expect(link).to have_attribute('data-feature-flag')
expect(link.attr('data-feature-flag')).to eq feature_flag.id.to_s
end
it 'supports an :only_path context' do
doc = reference_filter("Feature Flag #{reference}", only_path: true)
link = doc.css('a').first.attr('href')
expect(link).not_to match %r(https?://)
expect(link).to eq urls.edit_project_feature_flag_url(project, feature_flag.iid, only_path: true)
end
context 'when feature_flag_contextual_issue feture flag is disabled' do
before do
stub_feature_flags(feature_flag_contextual_issue: false)
end
it 'does not link the reference' do
doc = reference_filter("See #{reference}")
expect(doc.css('a').first).to be_nil
end
end
end
context 'with cross-project / cross-namespace complete reference' do
let_it_be(:namespace) { create(:namespace) }
let_it_be(:project2) { create(:project, :public, namespace: namespace) }
let_it_be(:feature_flag) { create(:operations_feature_flag, project: project2) }
let_it_be(:reference) { "[feature_flag:#{project2.full_path}/#{feature_flag.iid}]" }
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
expect(doc.css('a').first.attr('href')).to eq urls.edit_project_feature_flag_url(project2, feature_flag)
end
it 'produces a valid text in a link' do
doc = reference_filter("See (#{reference}.)")
expect(doc.css('a').first.text).to eql(reference)
end
it 'produces a valid text' do
doc = reference_filter("See (#{reference}.)")
expect(doc.text).to eql("See (#{reference}.)")
end
it 'ignores invalid feature flag IIDs on the referenced project' do
exp = act = "Check [feature_flag:#{non_existing_record_id}]"
expect(reference_filter(act).to_html).to eq exp
end
end
context 'with cross-project / same-namespace complete reference' do
let_it_be(:namespace) { create(:namespace) }
let_it_be(:project) { create(:project, :public, namespace: namespace) }
let_it_be(:project2) { create(:project, :public, namespace: namespace) }
let_it_be(:feature_flag) { create(:operations_feature_flag, project: project2) }
let_it_be(:reference) { "[feature_flag:#{project2.full_path}/#{feature_flag.iid}]" }
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
expect(doc.css('a').first.attr('href')).to eq urls.edit_project_feature_flag_url(project2, feature_flag)
end
it 'produces a valid text in a link' do
doc = reference_filter("See ([feature_flag:#{project2.path}/#{feature_flag.iid}].)")
expect(doc.css('a').first.text).to eql("[feature_flag:#{project2.path}/#{feature_flag.iid}]")
end
it 'produces a valid text' do
doc = reference_filter("See ([feature_flag:#{project2.path}/#{feature_flag.iid}].)")
expect(doc.text).to eql("See ([feature_flag:#{project2.path}/#{feature_flag.iid}].)")
end
it 'ignores invalid feature flag IIDs on the referenced project' do
exp = act = "Check [feature_flag:#{non_existing_record_id}]"
expect(reference_filter(act).to_html).to eq exp
end
end
context 'with cross-project shorthand reference' do
let_it_be(:namespace) { create(:namespace) }
let_it_be(:project) { create(:project, :public, namespace: namespace) }
let_it_be(:project2) { create(:project, :public, namespace: namespace) }
let_it_be(:feature_flag) { create(:operations_feature_flag, project: project2) }
let_it_be(:reference) { "[feature_flag:#{project2.path}/#{feature_flag.iid}]" }
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
expect(doc.css('a').first.attr('href')).to eq urls.edit_project_feature_flag_url(project2, feature_flag)
end
it 'produces a valid text in a link' do
doc = reference_filter("See ([feature_flag:#{project2.path}/#{feature_flag.iid}].)")
expect(doc.css('a').first.text).to eql("[feature_flag:#{project2.path}/#{feature_flag.iid}]")
end
it 'produces a valid text' do
doc = reference_filter("See ([feature_flag:#{project2.path}/#{feature_flag.iid}].)")
expect(doc.text).to eql("See ([feature_flag:#{project2.path}/#{feature_flag.iid}].)")
end
it 'ignores invalid feature flag IDs on the referenced project' do
exp = act = "Check [feature_flag:#{non_existing_record_id}]"
expect(reference_filter(act).to_html).to eq exp
end
end
context 'with cross-project URL reference' do
let_it_be(:namespace) { create(:namespace, name: 'cross-reference') }
let_it_be(:project2) { create(:project, :public, namespace: namespace) }
let_it_be(:feature_flag) { create(:operations_feature_flag, project: project2) }
let_it_be(:reference) { urls.edit_project_feature_flag_url(project2, feature_flag) }
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
expect(doc.css('a').first.attr('href')).to eq urls.edit_project_feature_flag_url(project2, feature_flag)
end
it 'links with adjacent text' do
doc = reference_filter("See (#{reference}.)")
expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(feature_flag.to_reference(project))}</a>\.\)})
end
it 'ignores invalid feature flag IIDs on the referenced project' do
act = "See #{invalidate_reference(reference)}"
expect(reference_filter(act).to_html).to match(%r{<a.+>#{Regexp.escape(invalidate_reference(reference))}</a>})
end
end
context 'with group context' do
let_it_be(:group) { create(:group) }
it 'links to a valid reference' do
reference = "[feature_flag:#{project.full_path}/#{feature_flag.iid}]"
result = reference_filter("See #{reference}", { project: nil, group: group } )
expect(result.css('a').first.attr('href')).to eq(urls.edit_project_feature_flag_url(project, feature_flag))
end
it 'ignores internal references' do
exp = act = "See [feature_flag:#{feature_flag.iid}]"
expect(reference_filter(act, project: nil, group: group).to_html).to eq exp
end
end
end

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Banzai::ReferenceParser::FeatureFlagParser do
include ReferenceParserHelpers
subject { described_class.new(Banzai::RenderContext.new(project, user)) }
let(:link) { empty_html_link }
describe '#nodes_visible_to_user' do
let(:project) { create(:project, :public) }
let(:user) { create(:user) }
let(:feature_flag) { create(:operations_feature_flag, project: project) }
context 'when the link has a data-issue attribute' do
before do
link['data-feature-flag'] = feature_flag.id.to_s
end
it_behaves_like "referenced feature visibility", "issues", "merge_requests" do
before do
project.add_developer(user) if enable_user?
end
end
end
end
describe '#referenced_by' do
let_it_be(:project) { create(:project, :public) }
let_it_be(:user) { create(:user) }
let_it_be(:feature_flag) { create(:operations_feature_flag, project: project) }
describe 'when the link has a data-feature-flag attribute' do
context 'using an existing feature flag ID' do
it 'returns an Array of feature flags' do
link['data-feature-flag'] = feature_flag.id.to_s
expect(subject.referenced_by([link])).to eq([feature_flag])
end
end
context 'using a non-existing feature flag ID' do
it 'returns an empty Array' do
link['data-feature-flag'] = ''
expect(subject.referenced_by([link])).to eq([])
end
end
end
end
end

View File

@ -1778,19 +1778,6 @@ RSpec.describe Group do
describe 'with Debian Distributions' do
subject { create(:group) }
let!(:distributions) { create_list(:debian_group_distribution, 2, :with_file, container: subject) }
it 'removes distribution files on removal' do
distribution_file_paths = distributions.map do |distribution|
distribution.file.path
end
expect { subject.destroy }
.to change {
distribution_file_paths.select do |path|
File.exist? path
end.length
}.from(distribution_file_paths.length).to(0)
end
it_behaves_like 'model with Debian distributions'
end
end

View File

@ -16,6 +16,35 @@ RSpec.describe Operations::FeatureFlag do
it { is_expected.to have_many(:scopes) }
end
describe '.reference_pattern' do
subject { described_class.reference_pattern }
it { is_expected.to match('[feature_flag:123]') }
it { is_expected.to match('[feature_flag:gitlab-org/gitlab/123]') }
end
describe '.link_reference_pattern' do
subject { described_class.link_reference_pattern }
it { is_expected.to match("#{Gitlab.config.gitlab.url}/gitlab-org/gitlab/-/feature_flags/123/edit") }
it { is_expected.not_to match("#{Gitlab.config.gitlab.url}/gitlab-org/gitlab/issues/123/edit") }
it { is_expected.not_to match("gitlab-org/gitlab/-/feature_flags/123/edit") }
end
describe '#to_reference' do
let(:namespace) { build(:namespace, path: 'sample-namespace') }
let(:project) { build(:project, name: 'sample-project', namespace: namespace) }
let(:feature_flag) { build(:operations_feature_flag, iid: 1, project: project) }
it 'returns feature flag id' do
expect(feature_flag.to_reference).to eq '[feature_flag:1]'
end
it 'returns complete path to the feature flag with full: true' do
expect(feature_flag.to_reference(full: true)).to eq '[feature_flag:sample-namespace/sample-project/1]'
end
end
describe 'validations' do
it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_presence_of(:name) }

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Packages::Debian::GroupComponentFile do
it_behaves_like 'Debian Component File', :group, false
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Packages::Debian::ProjectComponentFile do
it_behaves_like 'Debian Component File', :project, true
end

View File

@ -6376,20 +6376,7 @@ RSpec.describe Project, factory_default: :keep do
describe 'with Debian Distributions' do
subject { create(:project) }
let!(:distributions) { create_list(:debian_project_distribution, 2, :with_file, container: subject) }
it 'removes distribution files on removal' do
distribution_file_paths = distributions.map do |distribution|
distribution.file.path
end
expect { subject.destroy }
.to change {
distribution_file_paths.select do |path|
File.exist? path
end.length
}.from(distribution_file_paths.length).to(0)
end
it_behaves_like 'model with Debian distributions'
end
describe '#environments_for_scope' do

View File

@ -3,52 +3,10 @@
require 'spec_helper'
RSpec.describe 'Create a label or backlog board list' do
include GraphqlHelpers
let_it_be(:group) { create(:group, :private) }
let_it_be(:board) { create(:board, group: group) }
let_it_be(:user) { create(:user) }
let_it_be(:dev_label) do
create(:group_label, title: 'Development', color: '#FFAABB', group: group)
end
let(:current_user) { user }
let(:mutation) { graphql_mutation(:board_list_create, input) }
let(:mutation_response) { graphql_mutation_response(:board_list_create) }
context 'the user is not allowed to read board lists' do
let(:input) { { board_id: board.to_global_id.to_s, backlog: true } }
it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when user has permissions to admin board lists' do
before do
group.add_reporter(current_user)
end
describe 'backlog list' do
let(:input) { { board_id: board.to_global_id.to_s, backlog: true } }
it 'creates the list' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['list'])
.to include('position' => nil, 'listType' => 'backlog')
end
end
describe 'label list' do
let(:input) { { board_id: board.to_global_id.to_s, label_id: dev_label.to_global_id.to_s } }
it 'creates the list' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['list'])
.to include('position' => 0, 'listType' => 'label', 'label' => include('title' => 'Development'))
end
end
it_behaves_like 'board lists create request' do
let(:mutation_name) { :board_list_create }
end
end

View File

@ -3,99 +3,23 @@
require 'spec_helper'
RSpec.describe Boards::Lists::CreateService do
describe '#execute' do
shared_examples 'creating board lists' do
let_it_be(:user) { create(:user) }
context 'when board parent is a project' do
let_it_be(:parent) { create(:project) }
let_it_be(:board) { create(:board, project: parent) }
let_it_be(:label) { create(:label, project: parent, name: 'in-progress') }
before_all do
parent.add_developer(user)
end
it_behaves_like 'board lists create service'
end
subject(:service) { described_class.new(parent, user, label_id: label.id) }
context 'when board parent is a group' do
let_it_be(:parent) { create(:group) }
let_it_be(:board) { create(:board, group: parent) }
let_it_be(:label) { create(:group_label, group: parent, name: 'in-progress') }
context 'when board lists is empty' do
it 'creates a new list at beginning of the list' do
response = service.execute(board)
it_behaves_like 'board lists create service'
end
expect(response.success?).to eq(true)
expect(response.payload[:list].position).to eq 0
end
end
context 'when board lists has the done list' do
it 'creates a new list at beginning of the list' do
response = service.execute(board)
expect(response.success?).to eq(true)
expect(response.payload[:list].position).to eq 0
end
end
context 'when board lists has labels lists' do
it 'creates a new list at end of the lists' do
create(:list, board: board, position: 0)
create(:list, board: board, position: 1)
response = service.execute(board)
expect(response.success?).to eq(true)
expect(response.payload[:list].position).to eq 2
end
end
context 'when board lists has label and done lists' do
it 'creates a new list at end of the label lists' do
list1 = create(:list, board: board, position: 0)
list2 = service.execute(board).payload[:list]
expect(list1.reload.position).to eq 0
expect(list2.reload.position).to eq 1
end
end
context 'when provided label does not belong to the parent' do
it 'returns an error' do
label = create(:label, name: 'in-development')
service = described_class.new(parent, user, label_id: label.id)
response = service.execute(board)
expect(response.success?).to eq(false)
expect(response.errors).to include('Label not found')
end
end
context 'when backlog param is sent' do
it 'creates one and only one backlog list' do
service = described_class.new(parent, user, 'backlog' => true)
list = service.execute(board).payload[:list]
expect(list.list_type).to eq('backlog')
expect(list.position).to be_nil
expect(list).to be_valid
another_backlog = service.execute(board).payload[:list]
expect(another_backlog).to eq list
end
end
end
context 'when board parent is a project' do
let_it_be(:parent) { create(:project) }
let_it_be(:board) { create(:board, project: parent) }
let_it_be(:label) { create(:label, project: parent, name: 'in-progress') }
it_behaves_like 'creating board lists'
end
context 'when board parent is a group' do
let_it_be(:parent) { create(:group) }
let_it_be(:board) { create(:board, group: parent) }
let_it_be(:label) { create(:group_label, group: parent, name: 'in-progress') }
it_behaves_like 'creating board lists'
end
def create_list(params)
create(:list, params.merge(board: board))
end
end

View File

@ -15,6 +15,8 @@ RSpec.describe Packages::Debian::CreateDistributionService do
.from(nil).to(expected_components.count)
.and change { container.debian_distributions.first&.architectures&.count }
.from(nil).to(expected_architectures.count)
.and not_change { Packages::Debian::ProjectComponentFile.count }
.and not_change { Packages::Debian::GroupComponentFile.count }
else
expect { response }
.to not_change { container.debian_distributions.klass.all.count }
@ -23,6 +25,8 @@ RSpec.describe Packages::Debian::CreateDistributionService do
.and not_change { Packages::Debian::GroupComponent.count }
.and not_change { Packages::Debian::ProjectArchitecture.count }
.and not_change { Packages::Debian::GroupArchitecture.count }
.and not_change { Packages::Debian::ProjectComponentFile.count }
.and not_change { Packages::Debian::GroupComponentFile.count }
end
expect(response).to be_a(ServiceResponse)

View File

@ -15,12 +15,15 @@ RSpec.describe Packages::Debian::DestroyDistributionService do
.from(2).to(0)
.and change { architecture1.class.all.count }
.from(3).to(0)
.and change { component_file1.class.all.count }
.from(4).to(0)
else
expect { response }
.to not_change { container.debian_distributions.klass.all.count }
.and not_change { container.debian_distributions.count }
.and not_change { component1.class.all.count }
.and not_change { architecture1.class.all.count }
.and not_change { component_file1.class.all.count }
end
expect(response).to be_a(ServiceResponse)
@ -45,6 +48,10 @@ RSpec.describe Packages::Debian::DestroyDistributionService do
let_it_be(:architecture0, freeze: true) { create("debian_#{container_type}_architecture", distribution: distribution, name: 'all') }
let_it_be(:architecture1, freeze: can_freeze) { create("debian_#{container_type}_architecture", distribution: distribution, name: 'architecture1') }
let_it_be(:architecture2, freeze: can_freeze) { create("debian_#{container_type}_architecture", distribution: distribution, name: 'architecture2') }
let_it_be(:component_file1, freeze: can_freeze) { create("debian_#{container_type}_component_file", :source, component: component1) }
let_it_be(:component_file2, freeze: can_freeze) { create("debian_#{container_type}_component_file", component: component1, architecture: architecture1) }
let_it_be(:component_file3, freeze: can_freeze) { create("debian_#{container_type}_component_file", :source, component: component2) }
let_it_be(:component_file4, freeze: can_freeze) { create("debian_#{container_type}_component_file", component: component2, architecture: architecture2) }
subject { described_class.new(distribution) }

View File

@ -3,15 +3,26 @@
require 'spec_helper'
RSpec.describe Packages::Debian::UpdateDistributionService do
RSpec.shared_examples 'Update Debian Distribution' do |expected_message, expected_components, expected_architectures|
RSpec.shared_examples 'Update Debian Distribution' do |expected_message, expected_components, expected_architectures, component_file_delta = 0|
it 'returns ServiceResponse', :aggregate_failures do
expect(distribution).to receive(:update).with(simple_params).and_call_original if expected_message.nil?
expect { response }
.to not_change { container.debian_distributions.klass.all.count }
.and not_change { container.debian_distributions.count }
.and not_change { component1.class.all.count }
.and not_change { architecture1.class.all.count }
if component_file_delta.zero?
expect { response }
.to not_change { container.debian_distributions.klass.all.count }
.and not_change { container.debian_distributions.count }
.and not_change { component1.class.all.count }
.and not_change { architecture1.class.all.count }
.and not_change { component_file1.class.all.count }
else
expect { response }
.to not_change { container.debian_distributions.klass.all.count }
.and not_change { container.debian_distributions.count }
.and not_change { component1.class.all.count }
.and not_change { architecture1.class.all.count }
.and change { component_file1.class.all.count }
.from(4).to(4 + component_file_delta)
end
expect(response).to be_a(ServiceResponse)
expect(response.success?).to eq(expected_message.nil?)
@ -48,6 +59,10 @@ RSpec.describe Packages::Debian::UpdateDistributionService do
let_it_be(:architecture0) { create("debian_#{container_type}_architecture", distribution: distribution, name: 'all') }
let_it_be(:architecture1) { create("debian_#{container_type}_architecture", distribution: distribution, name: 'architecture1') }
let_it_be(:architecture2) { create("debian_#{container_type}_architecture", distribution: distribution, name: 'architecture2') }
let_it_be(:component_file1) { create("debian_#{container_type}_component_file", :source, component: component1) }
let_it_be(:component_file2) { create("debian_#{container_type}_component_file", component: component1, architecture: architecture1) }
let_it_be(:component_file3) { create("debian_#{container_type}_component_file", :source, component: component2) }
let_it_be(:component_file4) { create("debian_#{container_type}_component_file", component: component2, architecture: architecture2) }
let(:original_params) do
{
@ -110,7 +125,7 @@ RSpec.describe Packages::Debian::UpdateDistributionService do
}
end
it_behaves_like 'Update Debian Distribution', nil, %w[component2 component3], %w[all architecture2 architecture3]
it_behaves_like 'Update Debian Distribution', nil, %w[component2 component3], %w[all architecture2 architecture3], -2
end
context 'with invalid components' do

View File

@ -0,0 +1,83 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.shared_examples 'board lists create mutation' do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
let(:list_create_params) { {} }
subject { mutation.resolve(board_id: board.to_global_id.to_s, **list_create_params) }
describe '#ready?' do
it 'raises an error if required arguments are missing' do
expect { mutation.ready?(board_id: 'some id') }
.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /one and only one of/)
end
it 'raises an error if too many required arguments are specified' do
expect { mutation.ready?(board_id: 'some id', backlog: true, label_id: 'some label') }
.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /one and only one of/)
end
end
describe '#resolve' do
context 'with proper permissions' do
before_all do
group.add_reporter(user)
end
describe 'backlog list' do
let(:list_create_params) { { backlog: true } }
it 'creates one and only one backlog' do
expect { subject }.to change { board.lists.backlog.count }.by(1)
expect(board.lists.backlog.first.list_type).to eq 'backlog'
backlog_id = board.lists.backlog.first.id
expect { subject }.not_to change { board.lists.backlog.count }
expect(board.lists.backlog.last.id).to eq backlog_id
end
end
describe 'label list' do
let_it_be(:dev_label) do
create(:group_label, title: 'Development', color: '#FFAABB', group: group)
end
let(:list_create_params) { { label_id: dev_label.to_global_id.to_s } }
it 'creates a new label board list' do
expect { subject }.to change { board.lists.count }.by(1)
new_list = subject[:list]
expect(new_list.title).to eq dev_label.title
expect(new_list.position).to eq 0
end
context 'when label not found' do
let(:list_create_params) { { label_id: "gid://gitlab/Label/#{non_existing_record_id}" } }
it 'returns an error' do
expect(subject[:errors]).to include 'Label not found'
end
end
end
end
context 'without proper permissions' do
before_all do
group.add_guest(user)
end
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
end

View File

@ -11,6 +11,7 @@ RSpec.shared_examples 'Debian Distribution Architecture' do |factory, container,
describe 'relationships' do
it { is_expected.to belong_to(:distribution).class_name("Packages::Debian::#{container.capitalize}Distribution").inverse_of(:architectures) }
it { is_expected.to have_many(:files).class_name("Packages::Debian::#{container.capitalize}ComponentFile").inverse_of(:architecture) }
end
describe 'validations' do

View File

@ -0,0 +1,245 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.shared_examples 'Debian Component File' do |container_type, can_freeze|
let_it_be(:container1, freeze: can_freeze) { create(container_type) } # rubocop:disable Rails/SaveBang
let_it_be(:container2, freeze: can_freeze) { create(container_type) } # rubocop:disable Rails/SaveBang
let_it_be(:distribution1, freeze: can_freeze) { create("debian_#{container_type}_distribution", container: container1) }
let_it_be(:distribution2, freeze: can_freeze) { create("debian_#{container_type}_distribution", container: container2) }
let_it_be(:architecture1_1, freeze: can_freeze) { create("debian_#{container_type}_architecture", distribution: distribution1) }
let_it_be(:architecture1_2, freeze: can_freeze) { create("debian_#{container_type}_architecture", distribution: distribution1) }
let_it_be(:architecture2_1, freeze: can_freeze) { create("debian_#{container_type}_architecture", distribution: distribution2) }
let_it_be(:architecture2_2, freeze: can_freeze) { create("debian_#{container_type}_architecture", distribution: distribution2) }
let_it_be(:component1_1, freeze: can_freeze) { create("debian_#{container_type}_component", distribution: distribution1) }
let_it_be(:component1_2, freeze: can_freeze) { create("debian_#{container_type}_component", distribution: distribution1) }
let_it_be(:component2_1, freeze: can_freeze) { create("debian_#{container_type}_component", distribution: distribution2) }
let_it_be(:component2_2, freeze: can_freeze) { create("debian_#{container_type}_component", distribution: distribution2) }
let_it_be_with_refind(:component_file_with_architecture) { create("debian_#{container_type}_component_file", component: component1_1, architecture: architecture1_1) }
let_it_be(:component_file_other_architecture, freeze: can_freeze) { create("debian_#{container_type}_component_file", component: component1_1, architecture: architecture1_2) }
let_it_be(:component_file_other_component, freeze: can_freeze) { create("debian_#{container_type}_component_file", component: component1_2, architecture: architecture1_1) }
let_it_be(:component_file_other_compression_type, freeze: can_freeze) { create("debian_#{container_type}_component_file", component: component1_1, architecture: architecture1_1, compression_type: :xz) }
let_it_be(:component_file_other_file_md5, freeze: can_freeze) { create("debian_#{container_type}_component_file", component: component1_1, architecture: architecture1_1, file_md5: 'other_md5') }
let_it_be(:component_file_other_file_sha256, freeze: can_freeze) { create("debian_#{container_type}_component_file", component: component1_1, architecture: architecture1_1, file_sha256: 'other_sha256') }
let_it_be(:component_file_other_container, freeze: can_freeze) { create("debian_#{container_type}_component_file", component: component2_1, architecture: architecture2_1) }
let_it_be_with_refind(:component_file_with_file_type_source) { create("debian_#{container_type}_component_file", :source, component: component1_1) }
let_it_be(:component_file_with_file_type_di_packages, freeze: can_freeze) { create("debian_#{container_type}_component_file", :di_packages, component: component1_1, architecture: architecture1_1) }
subject { component_file_with_architecture }
describe 'relationships' do
context 'with stubbed uploader' do
before do
allow_next_instance_of(Packages::Debian::ComponentFileUploader) do |uploader|
allow(uploader).to receive(:dynamic_segment).and_return('stubbed')
end
end
it { is_expected.to belong_to(:component).class_name("Packages::Debian::#{container_type.capitalize}Component").inverse_of(:files) }
end
context 'with packages file_type' do
it { is_expected.to belong_to(:architecture).class_name("Packages::Debian::#{container_type.capitalize}Architecture").inverse_of(:files) }
end
context 'with :source file_type' do
subject { component_file_with_file_type_source }
it { is_expected.to belong_to(:architecture).class_name("Packages::Debian::#{container_type.capitalize}Architecture").inverse_of(:files).optional }
end
end
describe 'validations' do
describe "#component" do
before do
allow_next_instance_of(Packages::Debian::ComponentFileUploader) do |uploader|
allow(uploader).to receive(:dynamic_segment).and_return('stubbed')
end
end
it { is_expected.to validate_presence_of(:component) }
end
describe "#architecture" do
context 'with packages file_type' do
it { is_expected.to validate_presence_of(:architecture) }
end
context 'with :source file_type' do
subject { component_file_with_file_type_source }
it { is_expected.to validate_absence_of(:architecture) }
end
end
describe '#file_type' do
it { is_expected.to validate_presence_of(:file_type) }
it { is_expected.to allow_value(:packages).for(:file_type) }
end
describe '#compression_type' do
it { is_expected.not_to validate_presence_of(:compression_type) }
it { is_expected.to allow_value(nil).for(:compression_type) }
it { is_expected.to allow_value(:gz).for(:compression_type) }
end
describe '#file' do
subject { component_file_with_architecture.file }
context 'the uploader api' do
it { is_expected.to respond_to(:store_dir) }
it { is_expected.to respond_to(:cache_dir) }
it { is_expected.to respond_to(:work_dir) }
end
end
describe '#file_store' do
it { is_expected.to validate_presence_of(:file_store) }
end
describe '#file_md5' do
it { is_expected.to validate_presence_of(:file_md5) }
end
describe '#file_sha256' do
it { is_expected.to validate_presence_of(:file_sha256) }
end
end
describe 'scopes' do
describe '.with_container' do
subject { described_class.with_container(container2) }
it do
queries = ActiveRecord::QueryRecorder.new do
expect(subject.to_a).to contain_exactly(component_file_other_container)
end
expect(queries.count).to eq(1)
end
end
describe '.with_codename_or_suite' do
subject { described_class.with_codename_or_suite(distribution2.codename) }
it do
queries = ActiveRecord::QueryRecorder.new do
expect(subject.to_a).to contain_exactly(component_file_other_container)
end
expect(queries.count).to eq(1)
end
end
describe '.with_component_name' do
subject { described_class.with_component_name(component1_2.name) }
it do
queries = ActiveRecord::QueryRecorder.new do
expect(subject.to_a).to contain_exactly(component_file_other_component)
end
expect(queries.count).to eq(1)
end
end
describe '.with_file_type' do
subject { described_class.with_file_type(:source) }
it do
# let_it_be_with_refind triggers a query
component_file_with_file_type_source
queries = ActiveRecord::QueryRecorder.new do
expect(subject.to_a).to contain_exactly(component_file_with_file_type_source)
end
expect(queries.count).to eq(1)
end
end
describe '.with_architecture_name' do
subject { described_class.with_architecture_name(architecture1_2.name) }
it do
queries = ActiveRecord::QueryRecorder.new do
expect(subject.to_a).to contain_exactly(component_file_other_architecture)
end
expect(queries.count).to eq(1)
end
end
describe '.with_compression_type' do
subject { described_class.with_compression_type(:xz) }
it do
queries = ActiveRecord::QueryRecorder.new do
expect(subject.to_a).to contain_exactly(component_file_other_compression_type)
end
expect(queries.count).to eq(1)
end
end
describe '.with_file_sha256' do
subject { described_class.with_file_sha256('other_sha256') }
it do
queries = ActiveRecord::QueryRecorder.new do
expect(subject.to_a).to contain_exactly(component_file_other_file_sha256)
end
expect(queries.count).to eq(1)
end
end
end
describe 'callbacks' do
let(:component_file) { build("debian_#{container_type}_component_file", component: component1_1, architecture: architecture1_1, size: nil) }
subject { component_file.save! }
it 'updates metadata columns' do
expect(component_file)
.to receive(:update_file_store)
.and_call_original
expect(component_file)
.to receive(:update_column)
.with(:file_store, ::Packages::PackageFileUploader::Store::LOCAL)
.and_call_original
expect { subject }.to change { component_file.size }.from(nil).to(74)
end
end
describe '#relative_path' do
context 'with a Packages file_type' do
subject { component_file_with_architecture.relative_path }
it { is_expected.to eq("#{component1_1.name}/binary-#{architecture1_1.name}/Packages") }
end
context 'with a Source file_type' do
subject { component_file_with_file_type_source.relative_path }
it { is_expected.to eq("#{component1_1.name}/source/Source") }
end
context 'with a DI Packages file_type' do
subject { component_file_with_file_type_di_packages.relative_path }
it { is_expected.to eq("#{component1_1.name}/debian-installer/binary-#{architecture1_1.name}/Packages") }
end
context 'with an xz compression_type' do
subject { component_file_other_compression_type.relative_path }
it { is_expected.to eq("#{component1_1.name}/binary-#{architecture1_1.name}/Packages.xz") }
end
end
end

View File

@ -11,6 +11,7 @@ RSpec.shared_examples 'Debian Distribution Component' do |factory, container, ca
describe 'relationships' do
it { is_expected.to belong_to(:distribution).class_name("Packages::Debian::#{container.capitalize}Distribution").inverse_of(:components) }
it { is_expected.to have_many(:files).class_name("Packages::Debian::#{container.capitalize}ComponentFile").inverse_of(:component) }
end
describe 'validations' do

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
RSpec.shared_examples 'model with Debian distributions' do
let(:container_type) { subject.class.name.downcase }
let!(:distributions) { create_list("debian_#{container_type}_distribution", 2, :with_file, container: subject) }
let!(:components) { create_list("debian_#{container_type}_component", 5, distribution: distributions[0]) }
let!(:component_files) { create_list("debian_#{container_type}_component_file", 3, component: components[0]) }
it 'removes distribution files on removal' do
distribution_file_paths = distributions.map do |distribution|
[distribution.file.path] +
distribution.component_files.map do |component_file|
component_file.file.path
end
end.flatten
expect { subject.destroy! }
.to change {
distribution_file_paths.select do |path|
File.exist? path
end.length
}.from(distribution_file_paths.length).to(0)
end
end

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.shared_examples 'board lists create request' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:dev_label) do
create(:group_label, title: 'Development', color: '#FFAABB', group: group)
end
let(:mutation) { graphql_mutation(mutation_name, input) }
let(:mutation_response) { graphql_mutation_response(mutation_name) }
context 'the user is not allowed to read board lists' do
let(:input) { { board_id: board.to_global_id.to_s, backlog: true } }
it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when user has permissions to admin board lists' do
before do
group.add_reporter(current_user)
end
describe 'backlog list' do
let(:input) { { board_id: board.to_global_id.to_s, backlog: true } }
it 'creates the list' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['list'])
.to include('position' => nil, 'listType' => 'backlog')
end
end
describe 'label list' do
let(:input) { { board_id: board.to_global_id.to_s, label_id: dev_label.to_global_id.to_s } }
it 'creates the list' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['list'])
.to include('position' => 0, 'listType' => 'label', 'label' => include('title' => 'Development'))
end
end
end
end

View File

@ -0,0 +1,81 @@
# frozen_string_literal: true
RSpec.shared_examples 'board lists create service' do
describe '#execute' do
let_it_be(:user) { create(:user) }
before_all do
parent.add_developer(user)
end
subject(:service) { described_class.new(parent, user, label_id: label.id) }
context 'when board lists is empty' do
it 'creates a new list at beginning of the list' do
response = service.execute(board)
expect(response.success?).to eq(true)
expect(response.payload[:list].position).to eq 0
end
end
context 'when board lists has the done list' do
it 'creates a new list at beginning of the list' do
response = service.execute(board)
expect(response.success?).to eq(true)
expect(response.payload[:list].position).to eq 0
end
end
context 'when board lists has labels lists' do
it 'creates a new list at end of the lists' do
create_list(position: 0)
create_list(position: 1)
response = service.execute(board)
expect(response.success?).to eq(true)
expect(response.payload[:list].position).to eq 2
end
end
context 'when board lists has label and done lists' do
it 'creates a new list at end of the label lists' do
list1 = create_list(position: 0)
list2 = service.execute(board).payload[:list]
expect(list1.reload.position).to eq 0
expect(list2.reload.position).to eq 1
end
end
context 'when provided label does not belong to the parent' do
it 'returns an error' do
label = create(:label, name: 'in-development')
service = described_class.new(parent, user, label_id: label.id)
response = service.execute(board)
expect(response.success?).to eq(false)
expect(response.errors).to include('Label not found')
end
end
context 'when backlog param is sent' do
it 'creates one and only one backlog list' do
service = described_class.new(parent, user, 'backlog' => true)
list = service.execute(board).payload[:list]
expect(list.list_type).to eq('backlog')
expect(list.position).to be_nil
expect(list).to be_valid
another_backlog = service.execute(board).payload[:list]
expect(another_backlog).to eq list
end
end
end
end

View File

@ -0,0 +1,52 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Packages::Debian::ComponentFileUploader do
[:project, :group].each do |container_type|
context "Packages::Debian::#{container_type.capitalize}ComponentFile" do
let(:factory) { "debian_#{container_type}_component_file" }
let(:component_file) { create(factory) } # rubocop:disable Rails/SaveBang
let(:uploader) { described_class.new(component_file, :file) }
let(:path) { Gitlab.config.packages.storage_path }
subject { uploader }
it_behaves_like "builds correct paths",
store_dir: %r[^\h{2}/\h{2}/\h{64}/debian_#{container_type}_component_file/\d+$],
cache_dir: %r[/packages/tmp/cache$],
work_dir: %r[/packages/tmp/work$]
context 'object store is remote' do
before do
stub_package_file_object_storage
end
include_context 'with storage', described_class::Store::REMOTE
it_behaves_like "builds correct paths",
store_dir: %r[^\h{2}/\h{2}/\h{64}/debian_#{container_type}_component_file/\d+$],
cache_dir: %r[/packages/tmp/cache$],
work_dir: %r[/packages/tmp/work$]
end
describe 'remote file' do
let(:component_file) { create(factory, :object_storage) }
context 'with object storage enabled' do
before do
stub_package_file_object_storage
end
it 'can store file remotely' do
allow(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async)
component_file
expect(component_file.file_store).to eq(described_class::Store::REMOTE)
expect(component_file.file.path).not_to be_blank
end
end
end
end
end
end