Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-11-11 00:08:58 +00:00
parent 13bcb82213
commit 5427433c6d
123 changed files with 3624 additions and 1350 deletions

View File

@ -1 +1 @@
0.0.8
13.6.1

View File

@ -20,6 +20,8 @@ import HiddenFilesWarning from './hidden_files_warning.vue';
import MergeConflictWarning from './merge_conflict_warning.vue';
import CollapsedFilesWarning from './collapsed_files_warning.vue';
import { diffsApp } from '../utils/performance';
import {
TREE_LIST_WIDTH_STORAGE_KEY,
INITIAL_TREE_WIDTH,
@ -272,8 +274,12 @@ export default {
);
}
},
beforeCreate() {
diffsApp.instrument();
},
created() {
this.adjustView();
eventHub.$once('fetchDiffData', this.fetchData);
eventHub.$on('refetchDiffData', this.refetchDiffData);
this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES;
@ -294,6 +300,8 @@ export default {
);
},
beforeDestroy() {
diffsApp.deinstrument();
eventHub.$off('fetchDiffData', this.fetchData);
eventHub.$off('refetchDiffData', this.refetchDiffData);
this.removeEventListeners();
@ -487,9 +495,11 @@ export default {
<div v-if="isBatchLoading" class="loading"><gl-loading-icon size="lg" /></div>
<template v-else-if="renderDiffFiles">
<diff-file
v-for="file in diffs"
v-for="(file, index) in diffs"
:key="file.newPath"
:file="file"
:is-first-file="index === 0"
:is-last-file="index === diffs.length - 1"
:help-page-path="helpPagePath"
:can-current-user-fork="canCurrentUserFork"
:view-diffs-file-by-file="viewDiffsFileByFile"

View File

@ -15,6 +15,8 @@ import {
DIFF_FILE_AUTOMATIC_COLLAPSE,
DIFF_FILE_MANUAL_COLLAPSE,
EVT_EXPAND_ALL_FILES,
EVT_PERF_MARK_DIFF_FILES_END,
EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN,
} from '../constants';
import { DIFF_FILE, GENERIC_ERROR } from '../i18n';
import eventHub from '../event_hub';
@ -35,6 +37,16 @@ export default {
type: Object,
required: true,
},
isFirstFile: {
type: Boolean,
required: false,
default: false,
},
isLastFile: {
type: Boolean,
required: false,
default: false,
},
canCurrentUserFork: {
type: Boolean,
required: true,
@ -160,6 +172,11 @@ export default {
notesEventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.requestDiff);
eventHub.$on(EVT_EXPAND_ALL_FILES, this.expandAllListener);
},
async mounted() {
if (this.hasDiff) {
await this.postRender();
}
},
beforeDestroy() {
eventHub.$off(EVT_EXPAND_ALL_FILES, this.expandAllListener);
},
@ -175,6 +192,23 @@ export default {
this.handleToggle();
}
},
async postRender() {
const eventsForThisFile = [];
if (this.isFirstFile) {
eventsForThisFile.push(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN);
}
if (this.isLastFile) {
eventsForThisFile.push(EVT_PERF_MARK_DIFF_FILES_END);
}
await this.$nextTick();
eventsForThisFile.forEach(event => {
eventHub.$emit(event);
});
},
handleToggle() {
const currentCollapsedFlag = this.isCollapsed;
@ -197,7 +231,8 @@ export default {
})
.then(() => {
requestIdleCallback(
() => {
async () => {
await this.postRender();
this.assignDiscussionsToDiff(this.getDiffFileDiscussions(this.file));
},
{ timeout: 1000 },

View File

@ -98,3 +98,8 @@ export const RENAMED_DIFF_TRANSITIONS = {
// MR Diffs known events
export const EVT_EXPAND_ALL_FILES = 'mr:diffs:expandAllFiles';
export const EVT_PERF_MARK_FILE_TREE_START = 'mr:diffs:perf:fileTreeStart';
export const EVT_PERF_MARK_FILE_TREE_END = 'mr:diffs:perf:fileTreeEnd';
export const EVT_PERF_MARK_DIFF_FILES_START = 'mr:diffs:perf:filesStart';
export const EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN = 'mr:diffs:perf:firstFileShown';
export const EVT_PERF_MARK_DIFF_FILES_END = 'mr:diffs:perf:filesEnd';

View File

@ -8,7 +8,8 @@ import { __, s__ } from '~/locale';
import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils';
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
import TreeWorker from '../workers/tree_worker';
import eventHub from '../../notes/event_hub';
import notesEventHub from '../../notes/event_hub';
import eventHub from '../event_hub';
import {
getDiffPositionByLineCode,
getNoteFormData,
@ -42,6 +43,9 @@ import {
NO_SHOW_WHITESPACE,
DIFF_FILE_MANUAL_COLLAPSE,
DIFF_FILE_AUTOMATIC_COLLAPSE,
EVT_PERF_MARK_FILE_TREE_START,
EVT_PERF_MARK_FILE_TREE_END,
EVT_PERF_MARK_DIFF_FILES_START,
} from '../constants';
import { diffViewerModes } from '~/ide/constants';
import { isCollapsed } from '../diff_file';
@ -78,6 +82,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
commit(types.SET_BATCH_LOADING, true);
commit(types.SET_RETRIEVING_BATCHES, true);
eventHub.$emit(EVT_PERF_MARK_DIFF_FILES_START);
const getBatch = (page = 1) =>
axios
@ -139,9 +144,11 @@ export const fetchDiffFilesMeta = ({ commit, state }) => {
};
commit(types.SET_LOADING, true);
eventHub.$emit(EVT_PERF_MARK_FILE_TREE_START);
worker.addEventListener('message', ({ data }) => {
commit(types.SET_TREE_DATA, data);
eventHub.$emit(EVT_PERF_MARK_FILE_TREE_END);
worker.terminate();
});
@ -215,7 +222,7 @@ export const assignDiscussionsToDiff = (
}
Vue.nextTick(() => {
eventHub.$emit('scrollToDiscussion');
notesEventHub.$emit('scrollToDiscussion');
});
};
@ -240,7 +247,7 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi
}
if (file.viewer.automaticallyCollapsed) {
eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`);
notesEventHub.$emit(`loadCollapsedDiff/${file.file_hash}`);
scrollToElement(document.getElementById(file.file_hash));
} else if (file.viewer.manuallyCollapsed) {
commit(types.SET_FILE_COLLAPSED, {
@ -248,9 +255,9 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi
collapsed: false,
trigger: DIFF_FILE_AUTOMATIC_COLLAPSE,
});
eventHub.$emit('scrollToDiscussion');
notesEventHub.$emit('scrollToDiscussion');
} else {
eventHub.$emit('scrollToDiscussion');
notesEventHub.$emit('scrollToDiscussion');
}
}
}
@ -485,7 +492,7 @@ export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = fals
historyPushState(mergeUrlParams({ w }, window.location.href));
}
eventHub.$emit('refetchDiffData');
notesEventHub.$emit('refetchDiffData');
};
export const toggleFileFinder = ({ commit }, visible) => {

View File

@ -0,0 +1,80 @@
import { performanceMarkAndMeasure } from '~/performance/utils';
import {
MR_DIFFS_MARK_FILE_TREE_START,
MR_DIFFS_MARK_FILE_TREE_END,
MR_DIFFS_MARK_DIFF_FILES_START,
MR_DIFFS_MARK_FIRST_DIFF_FILE_SHOWN,
MR_DIFFS_MARK_DIFF_FILES_END,
MR_DIFFS_MEASURE_FILE_TREE_DONE,
MR_DIFFS_MEASURE_DIFF_FILES_DONE,
} from '../../performance/constants';
import eventHub from '../event_hub';
import {
EVT_PERF_MARK_FILE_TREE_START,
EVT_PERF_MARK_FILE_TREE_END,
EVT_PERF_MARK_DIFF_FILES_START,
EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN,
EVT_PERF_MARK_DIFF_FILES_END,
} from '../constants';
function treeStart() {
performanceMarkAndMeasure({
mark: MR_DIFFS_MARK_FILE_TREE_START,
});
}
function treeEnd() {
performanceMarkAndMeasure({
mark: MR_DIFFS_MARK_FILE_TREE_END,
measures: [
{
name: MR_DIFFS_MEASURE_FILE_TREE_DONE,
start: MR_DIFFS_MARK_FILE_TREE_START,
end: MR_DIFFS_MARK_FILE_TREE_END,
},
],
});
}
function filesStart() {
performanceMarkAndMeasure({
mark: MR_DIFFS_MARK_DIFF_FILES_START,
});
}
function filesEnd() {
performanceMarkAndMeasure({
mark: MR_DIFFS_MARK_DIFF_FILES_END,
measures: [
{
name: MR_DIFFS_MEASURE_DIFF_FILES_DONE,
start: MR_DIFFS_MARK_DIFF_FILES_START,
end: MR_DIFFS_MARK_DIFF_FILES_END,
},
],
});
}
function firstFile() {
performanceMarkAndMeasure({
mark: MR_DIFFS_MARK_FIRST_DIFF_FILE_SHOWN,
});
}
export const diffsApp = {
instrument() {
eventHub.$on(EVT_PERF_MARK_FILE_TREE_START, treeStart);
eventHub.$on(EVT_PERF_MARK_FILE_TREE_END, treeEnd);
eventHub.$on(EVT_PERF_MARK_DIFF_FILES_START, filesStart);
eventHub.$on(EVT_PERF_MARK_DIFF_FILES_END, filesEnd);
eventHub.$on(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN, firstFile);
},
deinstrument() {
eventHub.$off(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN, firstFile);
eventHub.$off(EVT_PERF_MARK_DIFF_FILES_END, filesEnd);
eventHub.$off(EVT_PERF_MARK_DIFF_FILES_START, filesStart);
eventHub.$off(EVT_PERF_MARK_FILE_TREE_END, treeEnd);
eventHub.$off(EVT_PERF_MARK_FILE_TREE_START, treeStart);
},
};

View File

@ -29,3 +29,17 @@ export const WEBIDE_MARK_FILE_FINISH = 'webide-file-finished';
export const WEBIDE_MEASURE_TREE_FROM_REQUEST = 'webide-tree-loading-from-request';
export const WEBIDE_MEASURE_FILE_FROM_REQUEST = 'webide-file-loading-from-request';
export const WEBIDE_MEASURE_FILE_AFTER_INTERACTION = 'webide-file-loading-after-interaction';
//
// MR Diffs namespace
// Marks
export const MR_DIFFS_MARK_FILE_TREE_START = 'mr-diffs-mark-file-tree-start';
export const MR_DIFFS_MARK_FILE_TREE_END = 'mr-diffs-mark-file-tree-end';
export const MR_DIFFS_MARK_DIFF_FILES_START = 'mr-diffs-mark-diff-files-start';
export const MR_DIFFS_MARK_FIRST_DIFF_FILE_SHOWN = 'mr-diffs-mark-first-diff-file-shown';
export const MR_DIFFS_MARK_DIFF_FILES_END = 'mr-diffs-mark-diff-files-end';
// Measures
export const MR_DIFFS_MEASURE_FILE_TREE_DONE = 'mr-diffs-measure-file-tree-done';
export const MR_DIFFS_MEASURE_DIFF_FILES_DONE = 'mr-diffs-measure-diff-files-done';

View File

@ -0,0 +1,108 @@
<script>
import { mapState } from 'vuex';
import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { setUrlParams, visitUrl } from '~/lib/utils/url_utility';
import { sprintf, s__ } from '~/locale';
export default {
name: 'DropdownFilter',
components: {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
},
props: {
filterData: {
type: Object,
required: true,
},
},
computed: {
...mapState(['query']),
scope() {
return this.query.scope;
},
supportedScopes() {
return Object.values(this.filterData.scopes);
},
initialFilter() {
return this.query[this.filterData.filterParam];
},
filter() {
return this.initialFilter || this.filterData.filters.ANY.value;
},
filtersArray() {
return this.filterData.filterByScope[this.scope];
},
selectedFilter: {
get() {
if (this.filtersArray.some(({ value }) => value === this.filter)) {
return this.filter;
}
return this.filterData.filters.ANY.value;
},
set(filter) {
// we need to remove the pagination cursor to ensure the
// relevancy of the new results
visitUrl(
setUrlParams({
page: null,
[this.filterData.filterParam]: filter,
}),
);
},
},
selectedFilterText() {
const f = this.filtersArray.find(({ value }) => value === this.selectedFilter);
if (!f || f === this.filterData.filters.ANY) {
return sprintf(s__('Any %{header}'), { header: this.filterData.header });
}
return f.label;
},
showDropdown() {
return this.supportedScopes.includes(this.scope);
},
},
methods: {
dropDownItemClass(filter) {
return {
'gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2':
filter === this.filterData.filters.ANY,
};
},
isFilterSelected(filter) {
return filter === this.selectedFilter;
},
handleFilterChange(filter) {
this.selectedFilter = filter;
},
},
};
</script>
<template>
<gl-dropdown
v-if="showDropdown"
:text="selectedFilterText"
class="col-3 gl-pt-4 gl-pl-0 gl-pr-0 gl-mr-4"
menu-class="gl-w-full! gl-pl-0"
>
<header class="gl-text-center gl-font-weight-bold gl-font-lg">
{{ filterData.header }}
</header>
<gl-dropdown-divider />
<gl-dropdown-item
v-for="f in filtersArray"
:key="f.value"
:is-check-item="true"
:is-checked="isFilterSelected(f.value)"
:class="dropDownItemClass(f)"
@click="handleFilterChange(f.value)"
>
{{ f.label }}
</gl-dropdown-item>
</gl-dropdown>
</template>

View File

@ -0,0 +1,36 @@
import { __ } from '~/locale';
const header = __('Confidentiality');
const filters = {
ANY: {
label: __('Any'),
value: null,
},
CONFIDENTIAL: {
label: __('Confidential'),
value: 'yes',
},
NOT_CONFIDENTIAL: {
label: __('Not confidential'),
value: 'no',
},
};
const scopes = {
ISSUES: 'issues',
};
const filterByScope = {
[scopes.ISSUES]: [filters.ANY, filters.CONFIDENTIAL, filters.NOT_CONFIDENTIAL],
};
const filterParam = 'confidential';
export default {
header,
filters,
scopes,
filterByScope,
filterParam,
};

View File

@ -0,0 +1,42 @@
import { __ } from '~/locale';
const header = __('Status');
const filters = {
ANY: {
label: __('Any'),
value: 'all',
},
OPEN: {
label: __('Open'),
value: 'opened',
},
CLOSED: {
label: __('Closed'),
value: 'closed',
},
MERGED: {
label: __('Merged'),
value: 'merged',
},
};
const scopes = {
ISSUES: 'issues',
MERGE_REQUESTS: 'merge_requests',
};
const filterByScope = {
[scopes.ISSUES]: [filters.ANY, filters.OPEN, filters.CLOSED],
[scopes.MERGE_REQUESTS]: [filters.ANY, filters.OPEN, filters.MERGED, filters.CLOSED],
};
const filterParam = 'state';
export default {
header,
filters,
scopes,
filterByScope,
filterParam,
};

View File

@ -0,0 +1,38 @@
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import DropdownFilter from './components/dropdown_filter.vue';
import stateFilterData from './constants/state_filter_data';
import confidentialFilterData from './constants/confidential_filter_data';
Vue.use(Translate);
const mountDropdownFilter = (store, { id, filterData }) => {
const el = document.getElementById(id);
if (!el) return false;
return new Vue({
el,
store,
render(createElement) {
return createElement(DropdownFilter, {
props: {
filterData,
},
});
},
});
};
const dropdownFilters = [
{
id: 'js-search-filter-by-state',
filterData: stateFilterData,
},
{
id: 'js-search-filter-by-confidential',
filterData: confidentialFilterData,
},
];
export default store => [...dropdownFilters].map(filter => mountDropdownFilter(store, filter));

View File

@ -1,11 +1,17 @@
import { queryToObject } from '~/lib/utils/url_utility';
import createStore from './store';
import initDropdownFilters from './dropdown_filter';
import { initSidebar } from './sidebar';
import initGroupFilter from './group_filter';
export default () => {
const store = createStore({ query: queryToObject(window.location.search) });
initSidebar(store);
if (gon.features.searchFacets) {
initSidebar(store);
} else {
initDropdownFilters(store);
}
initGroupFilter(store);
};

View File

@ -1,41 +0,0 @@
<script>
import { mapActions, mapState } from 'vuex';
import { GlButton, GlLink } from '@gitlab/ui';
import StatusFilter from './status_filter.vue';
import ConfidentialityFilter from './confidentiality_filter.vue';
export default {
name: 'GlobalSearchSidebar',
components: {
GlButton,
GlLink,
StatusFilter,
ConfidentialityFilter,
},
computed: {
...mapState(['query']),
showReset() {
return this.query.state || this.query.confidential;
},
},
methods: {
...mapActions(['applyQuery', 'resetQuery']),
},
};
</script>
<template>
<form
class="gl-display-flex gl-flex-direction-column col-md-3 gl-mr-4 gl-mb-6 gl-mb gl-mt-5"
@submit.prevent="applyQuery"
>
<status-filter />
<confidentiality-filter />
<div class="gl-display-flex gl-align-items-center gl-mt-3">
<gl-button variant="success" type="submit">{{ __('Apply') }}</gl-button>
<gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{
__('Reset filters')
}}</gl-link>
</div>
</form>
</template>

View File

@ -21,6 +21,5 @@ export default {
<template>
<div v-if="showDropdown">
<radio-filter :filter-data="$options.confidentialFilterData" />
<hr class="gl-my-5 gl-border-gray-100" />
</div>
</template>

View File

@ -21,6 +21,5 @@ export default {
<template>
<div v-if="showDropdown">
<radio-filter :filter-data="$options.stateFilterData" />
<hr class="gl-my-5 gl-border-gray-100" />
</div>
</template>

View File

@ -1,11 +1,12 @@
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import GlobalSearchSidebar from './components/app.vue';
import StatusFilter from './components/status_filter.vue';
import ConfidentialityFilter from './components/confidentiality_filter.vue';
Vue.use(Translate);
export const initSidebar = store => {
const el = document.getElementById('js-search-sidebar');
const mountRadioFilters = (store, { id, component }) => {
const el = document.getElementById(id);
if (!el) return false;
@ -13,7 +14,21 @@ export const initSidebar = store => {
el,
store,
render(createElement) {
return createElement(GlobalSearchSidebar);
return createElement(component);
},
});
};
const radioFilters = [
{
id: 'js-search-filter-by-state',
component: StatusFilter,
},
{
id: 'js-search-filter-by-confidential',
component: ConfidentialityFilter,
},
];
export const initSidebar = store =>
[...radioFilters].map(filter => mountRadioFilters(store, filter));

View File

@ -1,7 +1,6 @@
import Api from '~/api';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import * as types from './mutation_types';
export const fetchGroups = ({ commit }, search) => {
@ -19,11 +18,3 @@ export const fetchGroups = ({ commit }, search) => {
export const setQuery = ({ commit }, { key, value }) => {
commit(types.SET_QUERY, { key, value });
};
export const applyQuery = ({ state }) => {
visitUrl(setUrlParams({ ...state.query, page: null }));
};
export const resetQuery = ({ state }) => {
visitUrl(setUrlParams({ ...state.query, page: null, state: null, confidential: null }));
};

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true
# This concern assumes:
# - a `#container` accessor
# - a `#project` accessor
# - a `#user` accessor
# - a `#authentication_result` accessor
@ -11,6 +12,7 @@
# - a `#has_authentication_ability?(ability)` method
module LfsRequest
extend ActiveSupport::Concern
include Gitlab::Utils::StrongMemoize
CONTENT_TYPE = 'application/vnd.git-lfs+json'
@ -29,16 +31,19 @@ module LfsRequest
message: _('Git LFS is not enabled on this GitLab server, contact your admin.'),
documentation_url: help_url
},
content_type: CONTENT_TYPE,
status: :not_implemented
)
end
def lfs_check_access!
return render_lfs_not_found unless project
return render_lfs_not_found unless container&.lfs_enabled?
return if download_request? && lfs_download_access?
return if upload_request? && lfs_upload_access?
if project.public? || can?(user, :read_project, project)
# Only return a 403 response if the user has download access permission,
# otherwise return a 404 to avoid exposing the existence of the container.
if lfs_download_access?
lfs_forbidden!
else
render_lfs_not_found
@ -72,9 +77,9 @@ module LfsRequest
end
def lfs_download_access?
return false unless project.lfs_enabled?
ci? || lfs_deploy_token? || user_can_download_code? || build_can_download_code? || deploy_token_can_download_code?
strong_memoize(:lfs_download_access) do
ci? || lfs_deploy_token? || user_can_download_code? || build_can_download_code? || deploy_token_can_download_code?
end
end
def deploy_token_can_download_code?
@ -93,11 +98,12 @@ module LfsRequest
end
def lfs_upload_access?
return false unless project.lfs_enabled?
return false unless has_authentication_ability?(:push_code)
return false if limit_exceeded?
strong_memoize(:lfs_upload_access) do
next false unless has_authentication_ability?(:push_code)
next false if limit_exceeded?
lfs_deploy_token? || can?(user, :push_code, project)
lfs_deploy_token? || can?(user, :push_code, project)
end
end
def lfs_deploy_token?

View File

@ -8,7 +8,7 @@ class Groups::BoardsController < Groups::ApplicationController
before_action :assign_endpoint_vars
before_action do
push_frontend_feature_flag(:graphql_board_lists, group, default_enabled: false)
push_frontend_feature_flag(:boards_with_swimlanes, group, default_enabled: false)
push_frontend_feature_flag(:boards_with_swimlanes, group, default_enabled: true)
end
feature_category :boards

View File

@ -8,7 +8,7 @@ class Projects::BoardsController < Projects::ApplicationController
before_action :authorize_read_board!, only: [:index, :show]
before_action :assign_endpoint_vars
before_action do
push_frontend_feature_flag(:boards_with_swimlanes, project, default_enabled: false)
push_frontend_feature_flag(:boards_with_swimlanes, project, default_enabled: true)
end
feature_category :boards

View File

@ -5,6 +5,8 @@ module Projects
class CiCdController < Projects::ApplicationController
include RunnerSetupScripts
NUMBER_OF_RUNNERS_PER_PAGE = 20
before_action :authorize_admin_pipeline!
before_action :define_variables
before_action do
@ -108,13 +110,13 @@ module Projects
end
def define_runners_variables
@project_runners = @project.runners.ordered
@project_runners = @project.runners.ordered.page(params[:project_page]).per(NUMBER_OF_RUNNERS_PER_PAGE).with_tags
@assignable_runners = current_user
.ci_owned_runners
.assignable_for(project)
.ordered
.page(params[:page]).per(20)
.page(params[:specific_page]).per(NUMBER_OF_RUNNERS_PER_PAGE)
@shared_runners = ::Ci::Runner.instance_type.active

View File

@ -6,7 +6,7 @@ module Repositories
include KerberosSpnegoHelper
include Gitlab::Utils::StrongMemoize
attr_reader :authentication_result, :redirected_path, :container
attr_reader :authentication_result, :redirected_path
delegate :actor, :authentication_abilities, to: :authentication_result, allow_nil: true
delegate :type, to: :authentication_result, allow_nil: true, prefix: :auth_result
@ -75,6 +75,12 @@ module Repositories
headers['Www-Authenticate'] = challenges.join("\n") if challenges.any?
end
def container
parse_repo_path unless defined?(@container)
@container
end
def project
parse_repo_path unless defined?(@project)

View File

@ -17,9 +17,9 @@ module Repositories
end
if download_request?
render json: { objects: download_objects! }
render json: { objects: download_objects! }, content_type: LfsRequest::CONTENT_TYPE
elsif upload_request?
render json: { objects: upload_objects! }
render json: { objects: upload_objects! }, content_type: LfsRequest::CONTENT_TYPE
else
raise "Never reached"
end
@ -31,6 +31,7 @@ module Repositories
message: _('Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.'),
documentation_url: "#{Gitlab.config.gitlab.url}/help"
},
content_type: LfsRequest::CONTENT_TYPE,
status: :not_implemented
)
end

View File

@ -29,7 +29,7 @@ module Repositories
def upload_finalize
if store_file!(oid, size)
head 200
head 200, content_type: LfsRequest::CONTENT_TYPE
else
render plain: 'Unprocessable entity', status: :unprocessable_entity
end

View File

@ -24,6 +24,10 @@ class SearchController < ApplicationController
search_term_present && !params[:project_id].present?
end
before_action do
push_frontend_feature_flag(:search_facets)
end
layout 'search'
feature_category :global_search

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
module Mutations
module Releases
class Base < BaseMutation
include ResolvesProject
argument :project_path, GraphQL::ID_TYPE,
required: true,
description: 'Full path of the project the release is associated with'
private
def find_object(full_path:)
resolve_project(full_path: full_path)
end
end
end
end

View File

@ -0,0 +1,68 @@
# frozen_string_literal: true
module Mutations
module Releases
class Create < Base
graphql_name 'ReleaseCreate'
field :release,
Types::ReleaseType,
null: true,
description: 'The release after mutation'
argument :tag_name, GraphQL::STRING_TYPE,
required: true, as: :tag,
description: 'Name of the tag to associate with the release'
argument :ref, GraphQL::STRING_TYPE,
required: false,
description: 'The commit SHA or branch name to use if creating a new tag'
argument :name, GraphQL::STRING_TYPE,
required: false,
description: 'Name of the release'
argument :description, GraphQL::STRING_TYPE,
required: false,
description: 'Description (also known as "release notes") of the release'
argument :released_at, Types::TimeType,
required: false,
description: 'The date when the release will be/was ready. Defaults to the current time.'
argument :milestones, [GraphQL::STRING_TYPE],
required: false,
description: 'The title of each milestone the release is associated with. GitLab Premium customers can specify group milestones.'
argument :assets, Types::ReleaseAssetsInputType,
required: false,
description: 'Assets associated to the release'
authorize :create_release
def resolve(project_path:, milestones: nil, assets: nil, **scalars)
project = authorized_find!(full_path: project_path)
params = {
**scalars,
milestones: milestones.presence || [],
assets: assets.to_h
}.with_indifferent_access
result = ::Releases::CreateService.new(project, current_user, params).execute
if result[:status] == :success
{
release: result[:release],
errors: []
}
else
{
release: nil,
errors: [result[:message]]
}
end
end
end
end
end

View File

@ -5,7 +5,7 @@ module Resolvers
type Types::MetadataType, null: false
def resolve(**args)
{ version: Gitlab::VERSION, revision: Gitlab.revision }
::InstanceMetadata.new
end
end
end

View File

@ -63,6 +63,7 @@ module Types
'destroyed during the update, and no Note will be returned'
mount_mutation Mutations::Notes::RepositionImageDiffNote
mount_mutation Mutations::Notes::Destroy
mount_mutation Mutations::Releases::Create
mount_mutation Mutations::Terraform::State::Delete
mount_mutation Mutations::Terraform::State::Lock
mount_mutation Mutations::Terraform::State::Unlock

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class ReleaseAssetLinkInputType < BaseInputObject
graphql_name 'ReleaseAssetLinkInput'
description 'Fields that are available when modifying a release asset link'
argument :name, GraphQL::STRING_TYPE,
required: true,
description: 'Name of the asset link'
argument :url, GraphQL::STRING_TYPE,
required: true,
description: 'URL of the asset link'
argument :direct_asset_path, GraphQL::STRING_TYPE,
required: false, as: :filepath,
description: 'Relative path for a direct asset link'
argument :link_type, Types::ReleaseAssetLinkTypeEnum,
required: false, default_value: 'other',
description: 'The type of the asset link'
end
end

View File

@ -3,7 +3,7 @@
module Types
class ReleaseAssetLinkTypeEnum < BaseEnum
graphql_name 'ReleaseAssetLinkType'
description 'Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`'
description 'Type of the link: `other`, `runbook`, `image`, `package`'
::Releases::Link.link_types.keys.each do |link_type|
value link_type.upcase, value: link_type, description: "#{link_type.titleize} link type"

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class ReleaseAssetsInputType < BaseInputObject
graphql_name 'ReleaseAssetsInput'
description 'Fields that are available when modifying release assets'
argument :links, [Types::ReleaseAssetLinkInputType],
required: false,
description: 'A list of asset links to associate to the release'
end
end

View File

@ -34,7 +34,7 @@ module AlertManagement
has_many :ordered_notes, -> { fresh }, as: :noteable, class_name: 'Note'
has_many :user_mentions, class_name: 'AlertManagement::AlertUserMention', foreign_key: :alert_management_alert_id
has_internal_id :iid, scope: :project, init: ->(s) { s.project.alert_management_alerts.maximum(:iid) }
has_internal_id :iid, scope: :project
sha_attribute :fingerprint

View File

@ -42,9 +42,16 @@ module Ci
belongs_to :external_pull_request
belongs_to :ci_ref, class_name: 'Ci::Ref', foreign_key: :ci_ref_id, inverse_of: :pipelines
has_internal_id :iid, scope: :project, presence: false, track_if: -> { !importing? }, ensure_if: -> { !importing? }, init: ->(s) do
s&.project&.all_pipelines&.maximum(:iid) || s&.project&.all_pipelines&.count
end
has_internal_id :iid, scope: :project, presence: false,
track_if: -> { !importing? },
ensure_if: -> { !importing? },
init: ->(pipeline, scope) do
if pipeline
pipeline.project&.all_pipelines&.maximum(:iid) || pipeline.project&.all_pipelines&.count
elsif scope
::Ci::Pipeline.where(**scope).maximum(:iid)
end
end
has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline

View File

@ -27,16 +27,42 @@ module AtomicInternalId
extend ActiveSupport::Concern
class_methods do
def has_internal_id(column, scope:, init:, ensure_if: nil, track_if: nil, presence: true, backfill: false) # rubocop:disable Naming/PredicateName
# We require init here to retain the ability to recalculate in the absence of a
# InternalId record (we may delete records in `internal_ids` for example).
raise "has_internal_id requires a init block, none given." unless init
def has_internal_id( # rubocop:disable Naming/PredicateName
column, scope:, init: :not_given, ensure_if: nil, track_if: nil,
presence: true, backfill: false, hook_names: :create)
raise "has_internal_id init must not be nil if given." if init.nil?
raise "has_internal_id needs to be defined on association." unless self.reflect_on_association(scope)
before_validation :"track_#{scope}_#{column}!", on: :create, if: track_if
before_validation :"ensure_#{scope}_#{column}!", on: :create, if: ensure_if
init = infer_init(scope) if init == :not_given
before_validation :"track_#{scope}_#{column}!", on: hook_names, if: track_if
before_validation :"ensure_#{scope}_#{column}!", on: hook_names, if: ensure_if
validates column, presence: presence
define_singleton_internal_id_methods(scope, column, init)
define_instance_internal_id_methods(scope, column, init, backfill)
end
private
def infer_init(scope)
case scope
when :project
AtomicInternalId.project_init(self)
when :group
AtomicInternalId.group_init(self)
else
# We require init here to retain the ability to recalculate in the absence of a
# InternalId record (we may delete records in `internal_ids` for example).
raise "has_internal_id - cannot infer init for scope: #{scope}"
end
end
# Defines instance methods:
# - ensure_{scope}_{column}!
# - track_{scope}_{column}!
# - reset_{scope}_{column}
# - {column}=
def define_instance_internal_id_methods(scope, column, init, backfill)
define_method("ensure_#{scope}_#{column}!") do
return if backfill && self.class.where(column => nil).exists?
@ -103,19 +129,95 @@ module AtomicInternalId
read_attribute(column)
end
end
# Defines class methods:
#
# - with_{scope}_{column}_supply
# This method can be used to allocate a block of IID values during
# bulk operations (importing/copying, etc). This can be more efficient
# than creating instances one-by-one.
#
# Pass in a block that receives a `Supply` instance. To allocate a new
# IID value, call `Supply#next_value`.
#
# Example:
#
# MyClass.with_project_iid_supply(project) do |supply|
# attributes = MyClass.where(project: project).find_each do |record|
# record.attributes.merge(iid: supply.next_value)
# end
#
# bulk_insert(attributes)
# end
def define_singleton_internal_id_methods(scope, column, init)
define_singleton_method("with_#{scope}_#{column}_supply") do |scope_value, &block|
subject = find_by(scope => scope_value) || self
scope_attrs = ::AtomicInternalId.scope_attrs(scope_value)
usage = ::AtomicInternalId.scope_usage(self)
generator = InternalId::InternalIdGenerator.new(subject, scope_attrs, usage, init)
generator.with_lock do
supply = Supply.new(generator.record.last_value)
block.call(supply)
ensure
generator.track_greatest(supply.current_value) if supply
end
end
end
end
def self.scope_attrs(scope_value)
{ scope_value.class.table_name.singularize.to_sym => scope_value } if scope_value
end
def internal_id_scope_attrs(scope)
scope_value = internal_id_read_scope(scope)
{ scope_value.class.table_name.singularize.to_sym => scope_value } if scope_value
::AtomicInternalId.scope_attrs(scope_value)
end
def internal_id_scope_usage
self.class.table_name.to_sym
::AtomicInternalId.scope_usage(self.class)
end
def self.scope_usage(including_class)
including_class.table_name.to_sym
end
def self.project_init(klass, column_name = :iid)
->(instance, scope) do
if instance
klass.where(project_id: instance.project_id).maximum(column_name)
elsif scope.present?
klass.where(**scope).maximum(column_name)
end
end
end
def self.group_init(klass, column_name = :iid)
->(instance, scope) do
if instance
klass.where(group_id: instance.group_id).maximum(column_name)
elsif scope.present?
klass.where(group: scope[:namespace]).maximum(column_name)
end
end
end
def internal_id_read_scope(scope)
association(scope).reader
end
class Supply
attr_reader :current_value
def initialize(start_value)
@current_value = start_value
end
def next_value
@current_value += 1
end
end
end

View File

@ -14,7 +14,8 @@ module Enums
operations_feature_flags: 6,
operations_user_lists: 7,
alert_management_alerts: 8,
sprints: 9 # iterations
sprints: 9, # iterations
design_management_designs: 10
}
end
end

View File

@ -21,9 +21,7 @@ class Deployment < ApplicationRecord
has_one :deployment_cluster
has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) do
Deployment.where(project: s.project).maximum(:iid) if s&.project
end
has_internal_id :iid, scope: :project, track_if: -> { !importing? }
validates :sha, presence: true
validates :ref, presence: true

View File

@ -2,6 +2,7 @@
module DesignManagement
class Design < ApplicationRecord
include AtomicInternalId
include Importable
include Noteable
include Gitlab::FileTypeDetection
@ -26,6 +27,10 @@ module DesignManagement
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_internal_id :iid, scope: :project, presence: true,
hook_names: %i[create update], # Deal with old records
track_if: -> { !importing? }
validates :project, :filename, presence: true
validates :issue, presence: true, unless: :importing?
validates :filename, uniqueness: { scope: :issue_id }, length: { maximum: 255 }

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
class InstanceMetadata
attr_reader :version, :revision
def initialize(version: Gitlab::VERSION, revision: Gitlab.revision)
@version = version
@revision = revision
end
end

View File

@ -61,13 +61,13 @@ class InternalId < ApplicationRecord
class << self
def track_greatest(subject, scope, usage, new_value, init)
InternalIdGenerator.new(subject, scope, usage)
.track_greatest(init, new_value)
InternalIdGenerator.new(subject, scope, usage, init)
.track_greatest(new_value)
end
def generate_next(subject, scope, usage, init)
InternalIdGenerator.new(subject, scope, usage)
.generate(init)
InternalIdGenerator.new(subject, scope, usage, init)
.generate
end
def reset(subject, scope, usage, value)
@ -99,15 +99,18 @@ class InternalId < ApplicationRecord
# 4) In the absence of a record in the internal_ids table, one will be created
# and last_value will be calculated on the fly.
#
# subject: The instance we're generating an internal id for. Gets passed to init if called.
# subject: The instance or class we're generating an internal id for.
# scope: Attributes that define the scope for id generation.
# Valid keys are `project/project_id` and `namespace/namespace_id`.
# usage: Symbol to define the usage of the internal id, see InternalId.usages
attr_reader :subject, :scope, :scope_attrs, :usage
# init: Proc that accepts the subject and the scope and returns Integer|NilClass
attr_reader :subject, :scope, :scope_attrs, :usage, :init
def initialize(subject, scope, usage)
def initialize(subject, scope, usage, init = nil)
@subject = subject
@scope = scope
@usage = usage
@init = init
raise ArgumentError, 'Scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty?
@ -119,13 +122,13 @@ class InternalId < ApplicationRecord
# Generates next internal id and returns it
# init: Block that gets called to initialize InternalId record if not present
# Make sure to not throw exceptions in the absence of records (if this is expected).
def generate(init)
def generate
subject.transaction do
# Create a record in internal_ids if one does not yet exist
# and increment its last value
#
# Note this will acquire a ROW SHARE lock on the InternalId record
(lookup || create_record(init)).increment_and_save!
record.increment_and_save!
end
end
@ -148,12 +151,20 @@ class InternalId < ApplicationRecord
# and set its new_value if it is higher than the current last_value
#
# Note this will acquire a ROW SHARE lock on the InternalId record
def track_greatest(init, new_value)
def track_greatest(new_value)
subject.transaction do
(lookup || create_record(init)).track_greatest_and_save!(new_value)
record.track_greatest_and_save!(new_value)
end
end
def record
@record ||= (lookup || create_record)
end
def with_lock(&block)
record.with_lock(&block)
end
private
# Retrieve InternalId record for (project, usage) combination, if it exists
@ -171,12 +182,16 @@ class InternalId < ApplicationRecord
# was faster in doing this, we'll realize once we hit the unique key constraint
# violation. We can safely roll-back the nested transaction and perform
# a lookup instead to retrieve the record.
def create_record(init)
def create_record
raise ArgumentError, 'Cannot initialize without init!' unless init
instance = subject.is_a?(::Class) ? nil : subject
subject.transaction(requires_new: true) do
InternalId.create!(
**scope,
usage: usage_value,
last_value: init.call(subject) || 0
last_value: init.call(instance, scope) || 0
)
end
rescue ActiveRecord::RecordNotUnique

View File

@ -48,7 +48,7 @@ class Issue < ApplicationRecord
belongs_to :moved_to, class_name: 'Issue'
has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id
has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.issues&.maximum(:iid) }
has_internal_id :iid, scope: :project, track_if: -> { !importing? }
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent

View File

@ -17,8 +17,8 @@ class Iteration < ApplicationRecord
belongs_to :project
belongs_to :group
has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.iterations&.maximum(:iid) }
has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.iterations&.maximum(:iid) }
has_internal_id :iid, scope: :project
has_internal_id :iid, scope: :group
validates :start_date, presence: true
validates :due_date, presence: true

View File

@ -41,7 +41,14 @@ class MergeRequest < ApplicationRecord
belongs_to :merge_user, class_name: "User"
belongs_to :iteration, foreign_key: 'sprint_id'
has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) }
has_internal_id :iid, scope: :target_project, track_if: -> { !importing? },
init: ->(mr, scope) do
if mr
mr.target_project&.merge_requests&.maximum(:iid)
elsif scope[:project]
where(target_project: scope[:project]).maximum(:iid)
end
end
has_many :merge_request_diffs
has_many :merge_request_context_commits, inverse_of: :merge_request

View File

@ -12,8 +12,8 @@ class Milestone < ApplicationRecord
has_many :milestone_releases
has_many :releases, through: :milestone_releases
has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.milestones&.maximum(:iid) }
has_internal_id :iid, scope: :group, track_if: -> { !importing? }, init: ->(s) { s&.group&.milestones&.maximum(:iid) }
has_internal_id :iid, scope: :project, track_if: -> { !importing? }
has_internal_id :iid, scope: :group, track_if: -> { !importing? }
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent

View File

@ -13,7 +13,7 @@ module Operations
belongs_to :project
has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.operations_feature_flags&.maximum(:iid) }
has_internal_id :iid, scope: :project
default_value_for :active, true

View File

@ -13,7 +13,7 @@ module Operations
has_many :strategy_user_lists
has_many :strategies, through: :strategy_user_lists
has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.operations_feature_flags_user_lists&.maximum(:iid) }, presence: true
has_internal_id :iid, scope: :project, presence: true
validates :project, presence: true
validates :name,

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class InstanceMetadataPolicy < BasePolicy
delegate { :global }
end

View File

@ -172,20 +172,26 @@ module DesignManagement
def copy_designs!
design_attributes = attributes_config[:design_attributes]
new_rows = designs.map do |design|
design.attributes.slice(*design_attributes).merge(
issue_id: target_issue.id,
project_id: target_project.id
::DesignManagement::Design.with_project_iid_supply(target_project) do |supply|
new_rows = designs.each_with_index.map do |design, i|
design.attributes.slice(*design_attributes).merge(
issue_id: target_issue.id,
project_id: target_project.id,
iid: supply.next_value
)
end
# TODO Replace `Gitlab::Database.bulk_insert` with `BulkInsertSafe`
# once https://gitlab.com/gitlab-org/gitlab/-/issues/247718 is fixed.
# When this is fixed, we can remove the call to
# `with_project_iid_supply` above, since the objects will be instantiated
# and callbacks (including `ensure_project_iid!`) will fire.
::Gitlab::Database.bulk_insert( # rubocop:disable Gitlab/BulkInsert
DesignManagement::Design.table_name,
new_rows,
return_ids: true
)
end
# TODO Replace `Gitlab::Database.bulk_insert` with `BulkInsertSafe`
# once https://gitlab.com/gitlab-org/gitlab/-/issues/247718 is fixed.
::Gitlab::Database.bulk_insert( # rubocop:disable Gitlab/BulkInsert
DesignManagement::Design.table_name,
new_rows,
return_ids: true
)
end
def copy_versions!

View File

@ -37,8 +37,8 @@
- if runner.description.present?
%p.runner-description
= runner.description
- if runner.tag_list.present?
- if runner.tags.present?
%p
- runner.tag_list.sort.each do |tag|
- runner.tags.map(&:name).sort.each do |tag|
%span.badge.badge-primary
= tag

View File

@ -17,9 +17,10 @@
%h4.underlined-title= _('Runners activated for this project')
%ul.bordered-list.activated-specific-runners
= render partial: 'projects/runners/runner', collection: @project_runners, as: :runner
= paginate @project_runners, theme: "gitlab", param_name: "project_page", params: { expand_runners: true, anchor: 'js-runners-settings' }
- if @assignable_runners.any?
%h4.underlined-title= _('Available specific runners')
%ul.bordered-list.available-specific-runners
= render partial: 'projects/runners/runner', collection: @assignable_runners, as: :runner
= paginate @assignable_runners, theme: "gitlab", :params => { :anchor => '#js-runners-settings' }
= paginate @assignable_runners, theme: "gitlab", param_name: "specific_page", :params => { :anchor => 'js-runners-settings'}

View File

@ -33,7 +33,7 @@
= render_if_exists 'projects/settings/ci_cd/protected_environments', expanded: expanded
%section.settings.no-animate#js-runners-settings{ class: ('expanded' if expanded), data: { qa_selector: 'runners_settings_content' } }
%section.settings.no-animate#js-runners-settings{ class: ('expanded' if expanded || params[:expand_runners]), data: { qa_selector: 'runners_settings_content' } }
.settings-header
%h4
= _("Runners")

View File

@ -1,10 +1,7 @@
- if @search_objects.to_a.empty?
.gl-display-md-flex
- if %w(issues merge_requests).include?(@scope)
#js-search-sidebar.gl-display-flex.gl-flex-direction-column.col-md-3.gl-mr-4{ }
.gl-w-full
= render partial: "search/results/empty"
= render_if_exists 'shared/promotions/promote_advanced_search'
= render partial: "search/results/filters"
= render partial: "search/results/empty"
= render_if_exists 'shared/promotions/promote_advanced_search'
- else
.search-results-status
.row-content-block.gl-display-flex
@ -27,21 +24,19 @@
.gl-display-md-flex.gl-flex-direction-column
= render partial: 'search/sort_dropdown'
= render_if_exists 'shared/promotions/promote_advanced_search'
= render partial: "search/results/filters"
.results.gl-display-md-flex.gl-mt-3
- if %w(issues merge_requests).include?(@scope)
#js-search-sidebar{ }
.gl-w-full
- if @scope == 'commits'
%ul.content-list.commit-list
= render partial: "search/results/commit", collection: @search_objects
- else
.search-results
- if @scope == 'projects'
.term
= render 'shared/projects/list', projects: @search_objects, pipeline_status: false
- else
= render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects
.results.gl-mt-3
- if @scope == 'commits'
%ul.content-list.commit-list
= render partial: "search/results/commit", collection: @search_objects
- else
.search-results
- if @scope == 'projects'
.term
= render 'shared/projects/list', projects: @search_objects, pipeline_status: false
- else
= render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects
- if @scope != 'projects'
= paginate_collection(@search_objects)
- if @scope != 'projects'
= paginate_collection(@search_objects)

View File

@ -0,0 +1,6 @@
.d-lg-flex.align-items-end
#js-search-filter-by-state{ 'v-cloak': true }
#js-search-filter-by-confidential{ 'v-cloak': true }
- if %w(issues merge_requests).include?(@scope)
%hr.gl-mt-4.gl-mb-4

View File

@ -17,7 +17,7 @@
= render 'shared/issuable/search_bar', type: :boards, board: board
#board-app.boards-app.position-relative{ "v-cloak" => "true", data: board_data, ":class" => "{ 'is-compact': detailIssueVisible }" }
- if Feature.enabled?(:boards_with_swimlanes, current_board_parent) || Feature.enabled?(:graphql_board_lists, current_board_parent)
- if Feature.enabled?(:boards_with_swimlanes, current_board_parent, default_enabled: true) || Feature.enabled?(:graphql_board_lists, current_board_parent)
%board-content{ "v-cloak" => "true",
"ref" => "board_content",
":lists" => "state.lists",

View File

@ -182,7 +182,7 @@
= render 'shared/issuable/board_create_list_dropdown', board: board
- if @project
#js-add-issues-btn.gl-ml-3{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
- if current_user && Feature.enabled?(:boards_with_swimlanes, @group)
- if current_user && Feature.enabled?(:boards_with_swimlanes, @group, default_enabled: true)
#js-board-epics-swimlanes-toggle
#js-toggle-focus-btn
- elsif is_not_boards_modal_or_productivity_analytics && show_sorting_dropdown

View File

@ -0,0 +1,5 @@
---
title: Block LFS requests on snippets
merge_request: 45874
author:
type: fixed

View File

@ -1,5 +0,0 @@
---
title: Global Search - Left Sidebar
merge_request: 46595
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Expand postgres_indexes view
merge_request: 47304
author:
type: other

View File

@ -0,0 +1,5 @@
---
title: Add iid column to design_management_designs
merge_request: 46596
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Add performance marks and measures to the MR Diffs app at critical moments
merge_request: 46434
author:
type: other

View File

@ -0,0 +1,5 @@
---
title: Paginate project_runners in ci_cd settings
merge_request: 45830
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Add releaseCreate mutation to GraphQL endpoint
merge_request: 46263
author:
type: added

View File

@ -1,8 +1,8 @@
---
name: boards_with_swimlanes
introduced_by_url:
rollout_issue_url:
milestone:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/218040
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/238222
milestone: 13.6
group: group::product planning
type: development
group: group::project management
default_enabled: false
default_enabled: true

View File

@ -0,0 +1,7 @@
---
name: search_facets
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46809
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46595
group: group::global search
type: development
default_enabled: false

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddIidToDesignManagementDesign < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :design_management_designs, :iid, :integer
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class AddIndexOnDesignManagementDesignsIidProjectId < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
INDEX_NAME = 'index_design_management_designs_on_iid_and_project_id'
def up
add_concurrent_index :design_management_designs, [:project_id, :iid],
name: INDEX_NAME,
unique: true
end
def down
remove_concurrent_index_by_name :design_management_designs, INDEX_NAME
end
end

View File

@ -0,0 +1,55 @@
# frozen_string_literal: true
class ExtendPostgresIndexesView < ActiveRecord::Migration[6.0]
DOWNTIME = false
def up
execute(<<~SQL)
DROP VIEW postgres_indexes;
CREATE VIEW postgres_indexes AS
SELECT (pg_namespace.nspname::text || '.'::text) || pg_class.relname::text AS identifier,
pg_index.indexrelid,
pg_namespace.nspname AS schema,
pg_class.relname AS name,
pg_index.indisunique AS "unique",
pg_index.indisvalid AS valid_index,
pg_class.relispartition AS partitioned,
pg_index.indisexclusion AS exclusion,
pg_index.indexprs IS NOT NULL as expression,
pg_index.indpred IS NOT NULL as partial,
pg_indexes.indexdef AS definition,
pg_relation_size(pg_class.oid::regclass) AS ondisk_size_bytes
FROM pg_index
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
JOIN pg_namespace ON pg_class.relnamespace = pg_namespace.oid
JOIN pg_indexes ON pg_class.relname = pg_indexes.indexname
WHERE pg_namespace.nspname <> 'pg_catalog'::name
AND (pg_namespace.nspname = ANY (ARRAY["current_schema"(), 'gitlab_partitions_dynamic'::name, 'gitlab_partitions_static'::name]));
SQL
end
def down
execute(<<~SQL)
DROP VIEW postgres_indexes;
CREATE VIEW postgres_indexes AS
SELECT (pg_namespace.nspname::text || '.'::text) || pg_class.relname::text AS identifier,
pg_index.indexrelid,
pg_namespace.nspname AS schema,
pg_class.relname AS name,
pg_index.indisunique AS "unique",
pg_index.indisvalid AS valid_index,
pg_class.relispartition AS partitioned,
pg_index.indisexclusion AS exclusion,
pg_indexes.indexdef AS definition,
pg_relation_size(pg_class.oid::regclass) AS ondisk_size_bytes
FROM pg_index
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
JOIN pg_namespace ON pg_class.relnamespace = pg_namespace.oid
JOIN pg_indexes ON pg_class.relname = pg_indexes.indexname
WHERE pg_namespace.nspname <> 'pg_catalog'::name
AND (pg_namespace.nspname = ANY (ARRAY["current_schema"(), 'gitlab_partitions_dynamic'::name, 'gitlab_partitions_static'::name]));
SQL
end
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
class BackfillDesignIids < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
class Designs < ActiveRecord::Base
include EachBatch
self.table_name = 'design_management_designs'
end
def up
backfill = ::Gitlab::BackgroundMigration::BackfillDesignInternalIds.new(Designs)
Designs.select(:project_id).distinct.each_batch(of: 100, column: :project_id) do |relation|
backfill.perform(relation)
end
end
def down
# NOOP
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class AddNotNullCheckOnIidOnDesignManangementDesigns < ActiveRecord::Migration[6.0]
include ::Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_not_null_constraint(:design_management_designs, :iid)
end
def down
remove_not_null_constraint(:design_management_designs, :iid)
end
end

View File

@ -0,0 +1 @@
bef50f2417b9676c89aea838f7b9c85fb88af9f52c197d8eb4613a9c91bc7741

View File

@ -0,0 +1 @@
2f6c7efc1716d02dd40adb08bd09b9f1e63e4248619678c0562f4b8d581e6065

View File

@ -0,0 +1 @@
3937235469c8fb1f2b0af9cdf38933db5ae61552d1a9050755cec5f7c16ebb66

View File

@ -0,0 +1 @@
7ec73c06ccc4c9f618e0455d0a7aae3b591bf52b5ddb1b3f1678d2fd50b9fd5e

View File

@ -0,0 +1 @@
f008d77d2a0aef463a924923d5a338030758d6b9c194756a0490b51a95681127

View File

@ -11650,7 +11650,9 @@ CREATE TABLE design_management_designs (
issue_id integer,
filename character varying NOT NULL,
relative_position integer,
CONSTRAINT check_07155e2715 CHECK ((char_length((filename)::text) <= 255))
iid integer,
CONSTRAINT check_07155e2715 CHECK ((char_length((filename)::text) <= 255)),
CONSTRAINT check_cfb92df01a CHECK ((iid IS NOT NULL))
);
CREATE SEQUENCE design_management_designs_id_seq
@ -14722,6 +14724,8 @@ CREATE VIEW postgres_indexes AS
pg_index.indisvalid AS valid_index,
pg_class.relispartition AS partitioned,
pg_index.indisexclusion AS exclusion,
(pg_index.indexprs IS NOT NULL) AS expression,
(pg_index.indpred IS NOT NULL) AS partial,
pg_indexes.indexdef AS definition,
pg_relation_size((pg_class.oid)::regclass) AS ondisk_size_bytes
FROM (((pg_index
@ -20592,6 +20596,8 @@ CREATE INDEX index_description_versions_on_merge_request_id ON description_versi
CREATE INDEX index_design_management_designs_issue_id_relative_position_id ON design_management_designs USING btree (issue_id, relative_position, id);
CREATE UNIQUE INDEX index_design_management_designs_on_iid_and_project_id ON design_management_designs USING btree (project_id, iid);
CREATE UNIQUE INDEX index_design_management_designs_on_issue_id_and_filename ON design_management_designs USING btree (issue_id, filename);
CREATE INDEX index_design_management_designs_on_project_id ON design_management_designs USING btree (project_id);

View File

@ -12,6 +12,9 @@ info: To determine the technical writer assigned to the Stage/Group associated w
Deletes the cached blobs for a group. This endpoint requires group admin access.
CAUTION: **Warning:**
[A bug exists](https://gitlab.com/gitlab-org/gitlab/-/issues/277161) for this API.
```plaintext
DELETE /groups/:id/dependency_proxy/cache
```

View File

@ -13476,6 +13476,7 @@ type Mutation {
prometheusIntegrationResetToken(input: PrometheusIntegrationResetTokenInput!): PrometheusIntegrationResetTokenPayload
prometheusIntegrationUpdate(input: PrometheusIntegrationUpdateInput!): PrometheusIntegrationUpdatePayload
promoteToEpic(input: PromoteToEpicInput!): PromoteToEpicPayload
releaseCreate(input: ReleaseCreateInput!): ReleaseCreatePayload
removeAwardEmoji(input: RemoveAwardEmojiInput!): RemoveAwardEmojiPayload @deprecated(reason: "Use awardEmojiRemove. Deprecated in 13.2")
removeProjectFromSecurityDashboard(input: RemoveProjectFromSecurityDashboardInput!): RemoveProjectFromSecurityDashboardPayload
@ -17734,7 +17735,32 @@ type ReleaseAssetLinkEdge {
}
"""
Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`
Fields that are available when modifying a release asset link
"""
input ReleaseAssetLinkInput {
"""
Relative path for a direct asset link
"""
directAssetPath: String
"""
The type of the asset link
"""
linkType: ReleaseAssetLinkType = OTHER
"""
Name of the asset link
"""
name: String!
"""
URL of the asset link
"""
url: String!
}
"""
Type of the link: `other`, `runbook`, `image`, `package`
"""
enum ReleaseAssetLinkType {
"""
@ -17818,6 +17844,16 @@ type ReleaseAssets {
): ReleaseSourceConnection
}
"""
Fields that are available when modifying release assets
"""
input ReleaseAssetsInput {
"""
A list of asset links to associate to the release
"""
links: [ReleaseAssetLinkInput!]
}
"""
The connection type for Release.
"""
@ -17843,6 +17879,76 @@ type ReleaseConnection {
pageInfo: PageInfo!
}
"""
Autogenerated input type of ReleaseCreate
"""
input ReleaseCreateInput {
"""
Assets associated to the release
"""
assets: ReleaseAssetsInput
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Description (also known as "release notes") of the release
"""
description: String
"""
The title of each milestone the release is associated with. GitLab Premium customers can specify group milestones.
"""
milestones: [String!]
"""
Name of the release
"""
name: String
"""
Full path of the project the release is associated with
"""
projectPath: ID!
"""
The commit SHA or branch name to use if creating a new tag
"""
ref: String
"""
The date when the release will be/was ready. Defaults to the current time.
"""
releasedAt: Time
"""
Name of the tag to associate with the release
"""
tagName: String!
}
"""
Autogenerated return type of ReleaseCreate
"""
type ReleaseCreatePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The release after mutation
"""
release: Release
}
"""
An edge in a connection.
"""

View File

@ -39200,6 +39200,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "releaseCreate",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "ReleaseCreateInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "ReleaseCreatePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "removeAwardEmoji",
"description": null,
@ -51265,10 +51292,69 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "ReleaseAssetLinkInput",
"description": "Fields that are available when modifying a release asset link",
"fields": null,
"inputFields": [
{
"name": "name",
"description": "Name of the asset link",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "url",
"description": "URL of the asset link",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "directAssetPath",
"description": "Relative path for a direct asset link",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "linkType",
"description": "The type of the asset link",
"type": {
"kind": "ENUM",
"name": "ReleaseAssetLinkType",
"ofType": null
},
"defaultValue": "OTHER"
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "ReleaseAssetLinkType",
"description": "Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`",
"description": "Type of the link: `other`, `runbook`, `image`, `package`",
"fields": null,
"inputFields": null,
"interfaces": null,
@ -51433,6 +51519,35 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "ReleaseAssetsInput",
"description": "Fields that are available when modifying release assets",
"fields": null,
"inputFields": [
{
"name": "links",
"description": "A list of asset links to associate to the release",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "ReleaseAssetLinkInput",
"ofType": null
}
}
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ReleaseConnection",
@ -51518,6 +51633,190 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "ReleaseCreateInput",
"description": "Autogenerated input type of ReleaseCreate",
"fields": null,
"inputFields": [
{
"name": "projectPath",
"description": "Full path of the project the release is associated with",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "tagName",
"description": "Name of the tag to associate with the release",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "ref",
"description": "The commit SHA or branch name to use if creating a new tag",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "name",
"description": "Name of the release",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "description",
"description": "Description (also known as \"release notes\") of the release",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "releasedAt",
"description": "The date when the release will be/was ready. Defaults to the current time.",
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"defaultValue": null
},
{
"name": "milestones",
"description": "The title of each milestone the release is associated with. GitLab Premium customers can specify group milestones.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "assets",
"description": "Assets associated to the release",
"type": {
"kind": "INPUT_OBJECT",
"name": "ReleaseAssetsInput",
"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": "ReleaseCreatePayload",
"description": "Autogenerated return type of ReleaseCreate",
"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": "release",
"description": "The release after mutation",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Release",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ReleaseEdge",

View File

@ -2496,6 +2496,16 @@ A container for all assets associated with a release.
| `links` | ReleaseAssetLinkConnection | Asset links of the release |
| `sources` | ReleaseSourceConnection | Sources of the release |
### ReleaseCreatePayload
Autogenerated return type of ReleaseCreate.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `release` | Release | The release after mutation |
### ReleaseEvidence
Evidence for a release.
@ -4097,7 +4107,7 @@ State of a Geo registry.
### ReleaseAssetLinkType
Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`.
Type of the link: `other`, `runbook`, `image`, `package`.
| Value | Description |
| ----- | ----------- |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

View File

@ -5,50 +5,29 @@ info: To determine the technical writer assigned to the Stage/Group associated w
type: reference, concepts
---
# Instance-level merge request approval rules **(PREMIUM ONLY)**
# Merge request approval rules **(PREMIUM ONLY)**
> Introduced in [GitLab Premium](https://gitlab.com/gitlab-org/gitlab/-/issues/39060) 12.8.
Merge request approvals rules prevent users overriding certain settings on a project
level. When configured, only administrators can change these settings on a project level
if they are enabled at an instance level.
Merge request approval rules prevent users from overriding certain settings on the project
level. When enabled at the instance level, these settings are no longer editable on the
project level.
To enable merge request approval rules for an instance:
1. Navigate to **Admin Area >** **{push-rules}** **Push Rules** and expand **Merge
requests approvals**.
requests approvals**.
1. Set the required rule.
1. Click **Save changes**.
GitLab administrators can later override these settings in a projects settings.
## Available rules
Merge request approval rules that can be set at an instance level are:
- **Prevent approval of merge requests by merge request author**. Prevents project
maintainers from allowing request authors to merge their own merge requests.
maintainers from allowing request authors to merge their own merge requests.
- **Prevent approval of merge requests by merge request committers**. Prevents project
maintainers from allowing users to approve merge requests if they have submitted
any commits to the source branch.
- **Can override approvers and approvals required per merge request**. Allows project
maintainers to modify the approvers list in individual merge requests.
## Scope rules to compliance-labeled projects
> Introduced in [GitLab Premium](https://gitlab.com/groups/gitlab-org/-/epics/3432) 13.2.
Merge request approval rules can be further scoped to specific compliance frameworks.
When the compliance framework label is selected and the project is assigned the compliance
label, the instance-level MR approval settings will take effect and the
[project-level settings](../project/merge_requests/merge_request_approvals.md#adding--editing-a-default-approval-rule)
is locked for modification.
When the compliance framework label is not selected or the project is not assigned the
compliance label, the project-level MR approval settings will take effect and the users with
Maintainer role and above can modify these.
| Instance-level | Project-level |
| -------------- | ------------- |
| ![Scope MR approval settings to compliance frameworks](img/scope_mr_approval_settings_v13_5.png) | ![MR approval settings on compliance projects](img/mr_approval_settings_compliance_project_v13_5.png) |
maintainers from allowing users to approve merge requests if they have submitted
any commits to the source branch.
- **Prevent users from modifying merge request approvers list**. Prevents users from
modifying the approvers list in project settings or in individual merge requests.

View File

@ -11,10 +11,10 @@ info: To determine the technical writer assigned to the Stage/Group associated w
Each security vulnerability in a project's [Security Dashboard](../security_dashboard/index.md#project-security-dashboard) has an individual page which includes:
- Details of the vulnerability.
- Details for the vulnerability.
- The status of the vulnerability within the project.
- Available actions for the vulnerability.
- Issues related to the vulnerability.
- Any issues related to the vulnerability.
On the vulnerability page, you can interact with the vulnerability in
several different ways:
@ -26,21 +26,21 @@ several different ways:
By default, such issues are [confidential](../../project/issues/confidential_issues.md).
- [Link issues](#link-issues-to-the-vulnerability) - Link existing issues to vulnerability.
- [Automatic remediation](#automatic-remediation-for-vulnerabilities) - For some vulnerabilities,
a solution is provided for how to fix the vulnerability.
a solution is provided for how to fix the vulnerability automatically.
## Changing vulnerability status
You can switch the status of a vulnerability using the **Status** dropdown to one of
the following values:
| Status | Description |
|-----------|-------------------------------------------------------------------|
| Detected | The default state for a newly discovered vulnerability |
| Confirmed | A user has seen this vulnerability and confirmed it to be real |
| Dismissed | A user has seen this vulnerability and dismissed it |
| Resolved | The vulnerability has been fixed and is no longer in the codebase |
| Status | Description |
|-----------|------------------------------------------------------------------------------------------------------------------|
| Detected | The default state for a newly discovered vulnerability |
| Confirmed | A user has seen this vulnerability and confirmed it to be accurate |
| Dismissed | A user has seen this vulnerability and dismissed it because it is not accurate or otherwise will not be resolved |
| Resolved | The vulnerability has been fixed and is no longer valid |
A timeline shows you when the vulnerability status has changed,
A timeline shows you when the vulnerability status has changed
and allows you to comment on a change.
## Creating an issue for a vulnerability
@ -48,7 +48,7 @@ and allows you to comment on a change.
You can create an issue for a vulnerability by selecting the **Create issue** button.
This creates a [confidential issue](../../project/issues/confidential_issues.md) in the
project the vulnerability came from, and pre-populates it with useful information from
project the vulnerability came from and pre-populates it with useful information from
the vulnerability report. After the issue is created, GitLab redirects you to the
issue page so you can edit, assign, or comment on the issue.

View File

@ -50,7 +50,7 @@ To create an iteration:
## Edit an iteration
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218277) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.2.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218277) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.2.
NOTE: **Note:**
You need Developer [permissions](../../permissions.md) or higher to edit an iteration.
@ -73,7 +73,7 @@ An iteration report displays a list of all the issues assigned to an iteration a
To view an iteration report, go to the iterations list page and click an iteration's title.
## Disable Iterations **(CORE ONLY)**
## Disable Iterations **(STARTER ONLY)**
GitLab Iterations feature is deployed with a feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)

View File

@ -105,7 +105,7 @@ When [configuring your identify provider](#configuring-your-identity-provider),
### Azure setup notes
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
For a demo of the Azure SAML setup including SCIM, see [SCIM Provisioning on Azure Using SAML SSO for Groups Demo](https://youtu.be/24-ZxmTeEBU).
For a demo of the Azure SAML setup including SCIM, see [SCIM Provisioning on Azure Using SAML SSO for Groups Demo](https://youtu.be/24-ZxmTeEBU). Please note that the video is outdated in regards to objectID mapping and the [SCIM documentation should be followed](scim_setup.md#azure-configuration-steps).
| GitLab Setting | Azure Field |
|--------------|----------------|

View File

@ -0,0 +1,130 @@
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
# Backfill design.iid for a range of projects
class BackfillDesignInternalIds
# See app/models/internal_id
# This is a direct copy of the application code with the following changes:
# - usage enum is hard-coded to the value for design_management_designs
# - init is not passed around, but ignored
class InternalId < ActiveRecord::Base
def self.track_greatest(subject, scope, new_value)
InternalIdGenerator.new(subject, scope).track_greatest(new_value)
end
# Increments #last_value with new_value if it is greater than the current,
# and saves the record
#
# The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL).
# As such, the increment is atomic and safe to be called concurrently.
def track_greatest_and_save!(new_value)
update_and_save { self.last_value = [last_value || 0, new_value].max }
end
private
def update_and_save(&block)
lock!
yield
# update_and_save_counter.increment(usage: usage, changed: last_value_changed?)
save!
last_value
end
end
# See app/models/internal_id
class InternalIdGenerator
attr_reader :subject, :scope, :scope_attrs
def initialize(subject, scope)
@subject = subject
@scope = scope
raise ArgumentError, 'Scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty?
end
# Create a record in internal_ids if one does not yet exist
# and set its new_value if it is higher than the current last_value
#
# Note this will acquire a ROW SHARE lock on the InternalId record
def track_greatest(new_value)
subject.transaction do
record.track_greatest_and_save!(new_value)
end
end
def record
@record ||= (lookup || create_record)
end
def lookup
InternalId.find_by(**scope, usage: usage_value)
end
def usage_value
10 # see Enums::InternalId - this is the value for design_management_designs
end
# Create InternalId record for (scope, usage) combination, if it doesn't exist
#
# We blindly insert without synchronization. If another process
# was faster in doing this, we'll realize once we hit the unique key constraint
# violation. We can safely roll-back the nested transaction and perform
# a lookup instead to retrieve the record.
def create_record
subject.transaction(requires_new: true) do
InternalId.create!(
**scope,
usage: usage_value,
last_value: 0
)
end
rescue ActiveRecord::RecordNotUnique
lookup
end
end
attr_reader :design_class
def initialize(design_class)
@design_class = design_class
end
def perform(relation)
start_id, end_id = relation.pluck("min(project_id), max(project_id)").flatten
table = 'design_management_designs'
ActiveRecord::Base.connection.execute <<~SQL
WITH
starting_iids(project_id, iid) as (
SELECT project_id, MAX(COALESCE(iid, 0))
FROM #{table}
WHERE project_id BETWEEN #{start_id} AND #{end_id}
GROUP BY project_id
),
with_calculated_iid(id, iid) as (
SELECT design.id,
init.iid + ROW_NUMBER() OVER (PARTITION BY design.project_id ORDER BY design.id ASC)
FROM #{table} as design, starting_iids as init
WHERE design.project_id BETWEEN #{start_id} AND #{end_id}
AND design.iid IS NULL
AND init.project_id = design.project_id
)
UPDATE #{table}
SET iid = with_calculated_iid.iid
FROM with_calculated_iid
WHERE #{table}.id = with_calculated_iid.id
SQL
# track the new greatest IID value
relation.each do |design|
current_max = design_class.where(project_id: design.project_id).maximum(:iid)
scope = { project_id: design.project_id }
InternalId.track_greatest(design, scope, current_max)
end
end
end
end
end

View File

@ -10,6 +10,7 @@ module Gitlab
def self.candidate_indexes
Gitlab::Database::PostgresIndex
.regular
.where('NOT expression')
.not_match("^#{ConcurrentReindex::TEMPORARY_INDEX_PREFIX}")
.not_match("^#{ConcurrentReindex::REPLACED_INDEX_PREFIX}")
end

View File

@ -29,6 +29,7 @@ ignore_design_attributes:
- id
- issue_id
- project_id
- iid
ignore_version_attributes:
- id

View File

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'zlib'
# == Experimentation
#
# Utility module for A/B testing experimental features. Define your experiments in the `EXPERIMENTS` constant.
@ -87,126 +85,6 @@ module Gitlab
}
}.freeze
# Controller concern that checks if an `experimentation_subject_id cookie` is present and sets it if absent.
# Used for A/B testing of experimental features. Exposes the `experiment_enabled?(experiment_name)` method
# to controllers and views. It returns true when the experiment is enabled and the user is selected as part
# of the experimental group.
#
module ControllerConcern
include ::Gitlab::Experimentation::GroupTypes
extend ActiveSupport::Concern
included do
before_action :set_experimentation_subject_id_cookie, unless: :dnt_enabled?
helper_method :experiment_enabled?, :experiment_tracking_category_and_group
end
def set_experimentation_subject_id_cookie
return if cookies[:experimentation_subject_id].present?
cookies.permanent.signed[:experimentation_subject_id] = {
value: SecureRandom.uuid,
secure: ::Gitlab.config.gitlab.https,
httponly: true
}
end
def push_frontend_experiment(experiment_key)
var_name = experiment_key.to_s.camelize(:lower)
enabled = experiment_enabled?(experiment_key)
gon.push({ experiments: { var_name => enabled } }, true)
end
def experiment_enabled?(experiment_key)
return false if dnt_enabled?
return true if Experimentation.enabled_for_value?(experiment_key, experimentation_subject_index(experiment_key))
return true if forced_enabled?(experiment_key)
false
end
def track_experiment_event(experiment_key, action, value = nil)
return if dnt_enabled?
track_experiment_event_for(experiment_key, action, value) do |tracking_data|
::Gitlab::Tracking.event(tracking_data.delete(:category), tracking_data.delete(:action), **tracking_data)
end
end
def frontend_experimentation_tracking_data(experiment_key, action, value = nil)
return if dnt_enabled?
track_experiment_event_for(experiment_key, action, value) do |tracking_data|
gon.push(tracking_data: tracking_data)
end
end
def record_experiment_user(experiment_key)
return if dnt_enabled?
return unless Experimentation.enabled?(experiment_key) && current_user
::Experiment.add_user(experiment_key, tracking_group(experiment_key), current_user)
end
def experiment_tracking_category_and_group(experiment_key)
"#{tracking_category(experiment_key)}:#{tracking_group(experiment_key, '_group')}"
end
private
def dnt_enabled?
Gitlab::Utils.to_boolean(request.headers['DNT'])
end
def experimentation_subject_id
cookies.signed[:experimentation_subject_id]
end
def experimentation_subject_index(experiment_key)
return if experimentation_subject_id.blank?
if Experimentation.experiment(experiment_key).use_backwards_compatible_subject_index
experimentation_subject_id.delete('-').hex % 100
else
Zlib.crc32("#{experiment_key}#{experimentation_subject_id}") % 100
end
end
def track_experiment_event_for(experiment_key, action, value)
return unless Experimentation.enabled?(experiment_key)
yield experimentation_tracking_data(experiment_key, action, value)
end
def experimentation_tracking_data(experiment_key, action, value)
{
category: tracking_category(experiment_key),
action: action,
property: tracking_group(experiment_key, "_group"),
label: experimentation_subject_id,
value: value
}.compact
end
def tracking_category(experiment_key)
Experimentation.experiment(experiment_key).tracking_category
end
def tracking_group(experiment_key, suffix = nil)
return unless Experimentation.enabled?(experiment_key)
group = experiment_enabled?(experiment_key) ? GROUP_EXPERIMENTAL : GROUP_CONTROL
suffix ? "#{group}#{suffix}" : group
end
def forced_enabled?(experiment_key)
params.has_key?(:force_experiment) && params[:force_experiment] == experiment_key.to_s
end
end
class << self
def experiment(key)
Experiment.new(EXPERIMENTS[key].merge(key: key))

View File

@ -0,0 +1,127 @@
# frozen_string_literal: true
require 'zlib'
# Controller concern that checks if an `experimentation_subject_id cookie` is present and sets it if absent.
# Used for A/B testing of experimental features. Exposes the `experiment_enabled?(experiment_name)` method
# to controllers and views. It returns true when the experiment is enabled and the user is selected as part
# of the experimental group.
#
module Gitlab
module Experimentation
module ControllerConcern
include ::Gitlab::Experimentation::GroupTypes
extend ActiveSupport::Concern
included do
before_action :set_experimentation_subject_id_cookie, unless: :dnt_enabled?
helper_method :experiment_enabled?, :experiment_tracking_category_and_group
end
def set_experimentation_subject_id_cookie
return if cookies[:experimentation_subject_id].present?
cookies.permanent.signed[:experimentation_subject_id] = {
value: SecureRandom.uuid,
secure: ::Gitlab.config.gitlab.https,
httponly: true
}
end
def push_frontend_experiment(experiment_key)
var_name = experiment_key.to_s.camelize(:lower)
enabled = experiment_enabled?(experiment_key)
gon.push({ experiments: { var_name => enabled } }, true)
end
def experiment_enabled?(experiment_key)
return false if dnt_enabled?
return true if Experimentation.enabled_for_value?(experiment_key, experimentation_subject_index(experiment_key))
return true if forced_enabled?(experiment_key)
false
end
def track_experiment_event(experiment_key, action, value = nil)
return if dnt_enabled?
track_experiment_event_for(experiment_key, action, value) do |tracking_data|
::Gitlab::Tracking.event(tracking_data.delete(:category), tracking_data.delete(:action), **tracking_data)
end
end
def frontend_experimentation_tracking_data(experiment_key, action, value = nil)
return if dnt_enabled?
track_experiment_event_for(experiment_key, action, value) do |tracking_data|
gon.push(tracking_data: tracking_data)
end
end
def record_experiment_user(experiment_key)
return if dnt_enabled?
return unless Experimentation.enabled?(experiment_key) && current_user
::Experiment.add_user(experiment_key, tracking_group(experiment_key), current_user)
end
def experiment_tracking_category_and_group(experiment_key)
"#{tracking_category(experiment_key)}:#{tracking_group(experiment_key, '_group')}"
end
private
def dnt_enabled?
Gitlab::Utils.to_boolean(request.headers['DNT'])
end
def experimentation_subject_id
cookies.signed[:experimentation_subject_id]
end
def experimentation_subject_index(experiment_key)
return if experimentation_subject_id.blank?
if Experimentation.experiment(experiment_key).use_backwards_compatible_subject_index
experimentation_subject_id.delete('-').hex % 100
else
Zlib.crc32("#{experiment_key}#{experimentation_subject_id}") % 100
end
end
def track_experiment_event_for(experiment_key, action, value)
return unless Experimentation.enabled?(experiment_key)
yield experimentation_tracking_data(experiment_key, action, value)
end
def experimentation_tracking_data(experiment_key, action, value)
{
category: tracking_category(experiment_key),
action: action,
property: tracking_group(experiment_key, "_group"),
label: experimentation_subject_id,
value: value
}.compact
end
def tracking_category(experiment_key)
Experimentation.experiment(experiment_key).tracking_category
end
def tracking_group(experiment_key, suffix = nil)
return unless Experimentation.enabled?(experiment_key)
group = experiment_enabled?(experiment_key) ? GROUP_EXPERIMENTAL : GROUP_CONTROL
suffix ? "#{group}#{suffix}" : group
end
def forced_enabled?(experiment_key)
params.has_key?(:force_experiment) && params[:force_experiment] == experiment_key.to_s
end
end
end
end

View File

@ -6958,9 +6958,6 @@ msgstr ""
msgid "Compliance framework (optional)"
msgstr ""
msgid "Compliance frameworks"
msgstr ""
msgid "ComplianceDashboard|created by:"
msgstr ""
@ -19103,6 +19100,9 @@ msgstr ""
msgid "Outdent"
msgstr ""
msgid "Overall Activity"
msgstr ""
msgid "Overridden"
msgstr ""
@ -20339,6 +20339,9 @@ msgstr ""
msgid "Prevent users from changing their profile name"
msgstr ""
msgid "Prevent users from modifying merge request approvers list"
msgstr ""
msgid "Prevent users from performing write operations on GitLab while performing maintenance."
msgstr ""
@ -22252,9 +22255,6 @@ msgstr ""
msgid "Registry setup"
msgstr ""
msgid "Regulate approvals by authors/committers, based on compliance frameworks. Can be changed only at the instance level."
msgstr ""
msgid "Reindexing status"
msgstr ""
@ -22751,6 +22751,9 @@ msgstr ""
msgid "Repositories Analytics"
msgstr ""
msgid "RepositoriesAnalytics|Average Coverage by Job"
msgstr ""
msgid "RepositoriesAnalytics|Coverage"
msgstr ""
@ -22781,12 +22784,18 @@ msgstr ""
msgid "RepositoriesAnalytics|Please select projects to display."
msgstr ""
msgid "RepositoriesAnalytics|Projects with Tests"
msgstr ""
msgid "RepositoriesAnalytics|Test Code Coverage"
msgstr ""
msgid "RepositoriesAnalytics|There was an error fetching the projects."
msgstr ""
msgid "RepositoriesAnalytics|Total Number of Coverages"
msgstr ""
msgid "Repository"
msgstr ""
@ -22974,9 +22983,6 @@ msgstr ""
msgid "Reset authorization key?"
msgstr ""
msgid "Reset filters"
msgstr ""
msgid "Reset health check access token"
msgstr ""
@ -26653,9 +26659,6 @@ msgstr ""
msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
msgstr ""
msgid "The above settings apply to all projects with the selected compliance framework(s)."
msgstr ""
msgid "The application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential."
msgstr ""

View File

@ -1,75 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe LfsRequest do
include ProjectForksHelper
controller(Repositories::GitHttpClientController) do
# `described_class` is not available in this context
include LfsRequest
def show
head :ok
end
def project
@project ||= Project.find_by(id: params[:id])
end
def download_request?
true
end
def upload_request?
false
end
def ci?
false
end
end
let(:project) { create(:project, :public) }
before do
stub_lfs_setting(enabled: true)
end
context 'user is authenticated without access to lfs' do
before do
allow(controller).to receive(:authenticate_user)
allow(controller).to receive(:authentication_result) do
Gitlab::Auth::Result.new
end
end
context 'with access to the project' do
it 'returns 403' do
get :show, params: { id: project.id }
expect(response).to have_gitlab_http_status(:forbidden)
end
end
context 'without access to the project' do
context 'project does not exist' do
it 'returns 404' do
get :show, params: { id: 'does not exist' }
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'project is private' do
let(:project) { create(:project, :private) }
it 'returns 404' do
get :show, params: { id: project.id }
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
FactoryBot.define do
factory :design, class: 'DesignManagement::Design' do
factory :design, traits: [:has_internal_id], class: 'DesignManagement::Design' do
issue { association(:issue) }
project { issue&.project || association(:project) }
sequence(:filename) { |n| "homescreen-#{n}.jpg" }

View File

@ -17,5 +17,9 @@ FactoryBot.define do
container { project }
end
trait :empty_repo do
after(:create, &:create_wiki_repository)
end
end
end

View File

@ -122,6 +122,19 @@ RSpec.describe 'Runners' do
end
end
context 'when multiple runners are configured' do
let!(:specific_runner) { create(:ci_runner, :project, projects: [project]) }
let!(:specific_runner_2) { create(:ci_runner, :project, projects: [project]) }
it 'adds pagination to the runner list' do
stub_const('Projects::Settings::CiCdController::NUMBER_OF_RUNNERS_PER_PAGE', 1)
visit project_runners_path(project)
expect(find('.pagination')).not_to be_nil
end
end
context 'when a specific runner exists in another project' do
let(:another_project) { create(:project) }
let!(:specific_runner) { create(:ci_runner, :project, projects: [another_project]) }

View File

@ -98,6 +98,7 @@
"designs":[
{
"id":38,
"iid": 1,
"project_id":30,
"issue_id":469,
"filename":"chirrido3.jpg",
@ -107,6 +108,7 @@
},
{
"id":39,
"iid": 2,
"project_id":30,
"issue_id":469,
"filename":"jonathan_richman.jpg",
@ -116,6 +118,7 @@
},
{
"id":40,
"iid": 3,
"project_id":30,
"issue_id":469,
"filename":"mariavontrap.jpeg",
@ -137,6 +140,7 @@
"event":0,
"design":{
"id":38,
"iid": 1,
"project_id":30,
"issue_id":469,
"filename":"chirrido3.jpg"
@ -156,6 +160,7 @@
"event":1,
"design":{
"id":38,
"iid": 1,
"project_id":30,
"issue_id":469,
"filename":"chirrido3.jpg"
@ -167,6 +172,7 @@
"event":0,
"design":{
"id":39,
"iid": 2,
"project_id":30,
"issue_id":469,
"filename":"jonathan_richman.jpg"
@ -186,6 +192,7 @@
"event":1,
"design":{
"id":38,
"iid": 1,
"project_id":30,
"issue_id":469,
"filename":"chirrido3.jpg"
@ -197,6 +204,7 @@
"event":2,
"design":{
"id":39,
"iid": 2,
"project_id":30,
"issue_id":469,
"filename":"jonathan_richman.jpg"
@ -208,6 +216,7 @@
"event":0,
"design":{
"id":40,
"iid": 3,
"project_id":30,
"issue_id":469,
"filename":"mariavontrap.jpeg"

View File

@ -2,6 +2,7 @@ import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import createDiffsStore from '~/diffs/store/modules';
import createNotesStore from '~/notes/stores/modules';
import diffFileMockDataReadable from '../mock_data/diff_file';
import diffFileMockDataUnreadable from '../mock_data/diff_file_unreadable';
@ -10,9 +11,13 @@ import DiffFileHeaderComponent from '~/diffs/components/diff_file_header.vue';
import DiffContentComponent from '~/diffs/components/diff_content.vue';
import eventHub from '~/diffs/event_hub';
import {
EVT_EXPAND_ALL_FILES,
EVT_PERF_MARK_DIFF_FILES_END,
EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN,
} from '~/diffs/constants';
import { diffViewerModes, diffViewerErrors } from '~/ide/constants';
import { EVT_EXPAND_ALL_FILES } from '~/diffs/constants';
function changeViewer(store, index, { automaticallyCollapsed, manuallyCollapsed, name }) {
const file = store.state.diffs.diffFiles[index];
@ -58,12 +63,13 @@ function markFileToBeRendered(store, index = 0) {
});
}
function createComponent({ file }) {
function createComponent({ file, first = false, last = false }) {
const localVue = createLocalVue();
localVue.use(Vuex);
const store = new Vuex.Store({
...createNotesStore(),
modules: {
diffs: createDiffsStore(),
},
@ -78,6 +84,8 @@ function createComponent({ file }) {
file,
canCurrentUserFork: false,
viewDiffsFileByFile: false,
isFirstFile: first,
isLastFile: last,
},
});
@ -117,6 +125,72 @@ describe('DiffFile', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('bus events', () => {
beforeEach(() => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
});
describe('during mount', () => {
it.each`
first | last | events | file
${false} | ${false} | ${[]} | ${{ inlineLines: [], parallelLines: [], readableText: true }}
${true} | ${true} | ${[]} | ${{ inlineLines: [], parallelLines: [], readableText: true }}
${true} | ${false} | ${[EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN]} | ${false}
${false} | ${true} | ${[EVT_PERF_MARK_DIFF_FILES_END]} | ${false}
${true} | ${true} | ${[EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN, EVT_PERF_MARK_DIFF_FILES_END]} | ${false}
`(
'emits the events $events based on the file and its position ({ first: $first, last: $last }) among all files',
async ({ file, first, last, events }) => {
if (file) {
forceHasDiff({ store, ...file });
}
({ wrapper, store } = createComponent({
file: store.state.diffs.diffFiles[0],
first,
last,
}));
await wrapper.vm.$nextTick();
expect(eventHub.$emit).toHaveBeenCalledTimes(events.length);
events.forEach(event => {
expect(eventHub.$emit).toHaveBeenCalledWith(event);
});
},
);
});
describe('after loading the diff', () => {
it('indicates that it loaded the file', async () => {
forceHasDiff({ store, inlineLines: [], parallelLines: [], readableText: true });
({ wrapper, store } = createComponent({
file: store.state.diffs.diffFiles[0],
first: true,
last: true,
}));
jest.spyOn(wrapper.vm, 'loadCollapsedDiff').mockResolvedValue(getReadableFile());
jest.spyOn(window, 'requestIdleCallback').mockImplementation(fn => fn());
makeFileAutomaticallyCollapsed(store);
await wrapper.vm.$nextTick(); // Wait for store updates to flow into the component
toggleFile(wrapper);
await wrapper.vm.$nextTick(); // Wait for the load to resolve
await wrapper.vm.$nextTick(); // Wait for the idleCallback
await wrapper.vm.$nextTick(); // Wait for nextTick inside postRender
expect(eventHub.$emit).toHaveBeenCalledTimes(2);
expect(eventHub.$emit).toHaveBeenCalledWith(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN);
expect(eventHub.$emit).toHaveBeenCalledWith(EVT_PERF_MARK_DIFF_FILES_END);
});
});
});
describe('template', () => {

View File

@ -0,0 +1,198 @@
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { MOCK_QUERY } from 'jest/search/mock_data';
import * as urlUtils from '~/lib/utils/url_utility';
import initStore from '~/search/store';
import DropdownFilter from '~/search/dropdown_filter/components/dropdown_filter.vue';
import stateFilterData from '~/search/dropdown_filter/constants/state_filter_data';
import confidentialFilterData from '~/search/dropdown_filter/constants/confidential_filter_data';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
setUrlParams: jest.fn(),
}));
const localVue = createLocalVue();
localVue.use(Vuex);
describe('DropdownFilter', () => {
let wrapper;
let store;
const createStore = options => {
store = initStore({ query: MOCK_QUERY, ...options });
};
const createComponent = (props = { filterData: stateFilterData }) => {
wrapper = shallowMount(DropdownFilter, {
localVue,
store,
propsData: {
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
store = null;
});
const findGlDropdown = () => wrapper.find(GlDropdown);
const findGlDropdownItems = () => findGlDropdown().findAll(GlDropdownItem);
const findDropdownItemsText = () => findGlDropdownItems().wrappers.map(w => w.text());
const firstDropDownItem = () => findGlDropdownItems().at(0);
describe('StatusFilter', () => {
describe('template', () => {
describe.each`
scope | showDropdown
${'issues'} | ${true}
${'merge_requests'} | ${true}
${'projects'} | ${false}
${'milestones'} | ${false}
${'users'} | ${false}
${'notes'} | ${false}
${'wiki_blobs'} | ${false}
${'blobs'} | ${false}
`(`dropdown`, ({ scope, showDropdown }) => {
beforeEach(() => {
createStore({ query: { ...MOCK_QUERY, scope } });
createComponent();
});
it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => {
expect(findGlDropdown().exists()).toBe(showDropdown);
});
});
describe.each`
initialFilter | label
${stateFilterData.filters.ANY.value} | ${`Any ${stateFilterData.header}`}
${stateFilterData.filters.OPEN.value} | ${stateFilterData.filters.OPEN.label}
${stateFilterData.filters.CLOSED.value} | ${stateFilterData.filters.CLOSED.label}
`(`filter text`, ({ initialFilter, label }) => {
describe(`when initialFilter is ${initialFilter}`, () => {
beforeEach(() => {
createStore({ query: { ...MOCK_QUERY, [stateFilterData.filterParam]: initialFilter } });
createComponent();
});
it(`sets dropdown label to ${label}`, () => {
expect(findGlDropdown().attributes('text')).toBe(label);
});
});
});
});
describe('Filter options', () => {
beforeEach(() => {
createStore();
createComponent();
});
it('renders a dropdown item for each filterOption', () => {
expect(findDropdownItemsText()).toStrictEqual(
stateFilterData.filterByScope[stateFilterData.scopes.ISSUES].map(v => {
return v.label;
}),
);
});
it('clicking a dropdown item calls setUrlParams', () => {
const filter = stateFilterData.filters[Object.keys(stateFilterData.filters)[0]].value;
firstDropDownItem().vm.$emit('click');
expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
page: null,
[stateFilterData.filterParam]: filter,
});
});
it('clicking a dropdown item calls visitUrl', () => {
firstDropDownItem().vm.$emit('click');
expect(urlUtils.visitUrl).toHaveBeenCalled();
});
});
});
describe('ConfidentialFilter', () => {
describe('template', () => {
describe.each`
scope | showDropdown
${'issues'} | ${true}
${'merge_requests'} | ${false}
${'projects'} | ${false}
${'milestones'} | ${false}
${'users'} | ${false}
${'notes'} | ${false}
${'wiki_blobs'} | ${false}
${'blobs'} | ${false}
`(`dropdown`, ({ scope, showDropdown }) => {
beforeEach(() => {
createStore({ query: { ...MOCK_QUERY, scope } });
createComponent({ filterData: confidentialFilterData });
});
it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => {
expect(findGlDropdown().exists()).toBe(showDropdown);
});
});
describe.each`
initialFilter | label
${confidentialFilterData.filters.ANY.value} | ${`Any ${confidentialFilterData.header}`}
${confidentialFilterData.filters.CONFIDENTIAL.value} | ${confidentialFilterData.filters.CONFIDENTIAL.label}
${confidentialFilterData.filters.NOT_CONFIDENTIAL.value} | ${confidentialFilterData.filters.NOT_CONFIDENTIAL.label}
`(`filter text`, ({ initialFilter, label }) => {
describe(`when initialFilter is ${initialFilter}`, () => {
beforeEach(() => {
createStore({
query: { ...MOCK_QUERY, [confidentialFilterData.filterParam]: initialFilter },
});
createComponent({ filterData: confidentialFilterData });
});
it(`sets dropdown label to ${label}`, () => {
expect(findGlDropdown().attributes('text')).toBe(label);
});
});
});
});
});
describe('Filter options', () => {
beforeEach(() => {
createStore();
createComponent({ filterData: confidentialFilterData });
});
it('renders a dropdown item for each filterOption', () => {
expect(findDropdownItemsText()).toStrictEqual(
confidentialFilterData.filterByScope[confidentialFilterData.scopes.ISSUES].map(v => {
return v.label;
}),
);
});
it('clicking a dropdown item calls setUrlParams', () => {
const filter =
confidentialFilterData.filters[Object.keys(confidentialFilterData.filters)[0]].value;
firstDropDownItem().vm.$emit('click');
expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
page: null,
[confidentialFilterData.filterParam]: filter,
});
});
it('clicking a dropdown item calls visitUrl', () => {
firstDropDownItem().vm.$emit('click');
expect(urlUtils.visitUrl).toHaveBeenCalled();
});
});
});

View File

@ -1,99 +0,0 @@
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlButton, GlLink } from '@gitlab/ui';
import { MOCK_QUERY } from 'jest/search/mock_data';
import GlobalSearchSidebar from '~/search/sidebar/components/app.vue';
import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter.vue';
import StatusFilter from '~/search/sidebar/components/status_filter.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('GlobalSearchSidebar', () => {
let wrapper;
const actionSpies = {
applyQuery: jest.fn(),
resetQuery: jest.fn(),
};
const createComponent = initialState => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
...initialState,
},
actions: actionSpies,
});
wrapper = shallowMount(GlobalSearchSidebar, {
localVue,
store,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findSidebarForm = () => wrapper.find('form');
const findStatusFilter = () => wrapper.find(StatusFilter);
const findConfidentialityFilter = () => wrapper.find(ConfidentialityFilter);
const findApplyButton = () => wrapper.find(GlButton);
const findResetLinkButton = () => wrapper.find(GlLink);
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders StatusFilter always', () => {
expect(findStatusFilter().exists()).toBe(true);
});
it('renders ConfidentialityFilter always', () => {
expect(findConfidentialityFilter().exists()).toBe(true);
});
it('renders ApplyButton always', () => {
expect(findApplyButton().exists()).toBe(true);
});
describe('ResetLinkButton', () => {
describe('with no filter selected', () => {
beforeEach(() => {
createComponent({ query: {} });
});
it('does not render', () => {
expect(findResetLinkButton().exists()).toBe(false);
});
});
describe('with filter selected', () => {
it('does render when a filter selected', () => {
expect(findResetLinkButton().exists()).toBe(true);
});
});
});
});
describe('actions', () => {
beforeEach(() => {
createComponent();
});
it('clicking ApplyButton calls applyQuery', () => {
findSidebarForm().trigger('submit');
expect(actionSpies.applyQuery).toHaveBeenCalled();
});
it('clicking ResetLinkButton calls resetQuery', () => {
findResetLinkButton().vm.$emit('click');
expect(actionSpies.resetQuery).toHaveBeenCalled();
});
});
});

View File

@ -2,7 +2,6 @@ import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import * as actions from '~/search/store/actions';
import * as types from '~/search/store/mutation_types';
import * as urlUtils from '~/lib/utils/url_utility';
import state from '~/search/store/state';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
@ -43,47 +42,6 @@ describe('Global Search Store Actions', () => {
});
});
});
describe('setQuery', () => {
const payload = { key: 'key1', value: 'value1' };
it('calls the SET_QUERY mutation', done => {
testAction(actions.setQuery, payload, state, [{ type: types.SET_QUERY, payload }], [], done);
});
});
describe('applyQuery', () => {
beforeEach(() => {
urlUtils.setUrlParams = jest.fn();
urlUtils.visitUrl = jest.fn();
});
it('calls visitUrl and setParams with the state.query', () => {
testAction(actions.applyQuery, null, state, [], [], () => {
expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ ...state.query, page: null });
expect(urlUtils.visitUrl).toHaveBeenCalled();
});
});
});
describe('resetQuery', () => {
beforeEach(() => {
urlUtils.setUrlParams = jest.fn();
urlUtils.visitUrl = jest.fn();
});
it('calls visitUrl and setParams with empty values', () => {
testAction(actions.resetQuery, null, state, [], [], () => {
expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
...state.query,
page: null,
state: null,
confidential: null,
});
expect(urlUtils.visitUrl).toHaveBeenCalled();
});
});
});
});
describe('setQuery', () => {

Some files were not shown because too many files have changed in this diff Show More