Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
e7462f7b49
commit
3c53fbc50b
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { debounce } from 'lodash';
|
||||
import { mapActions, mapState } from 'vuex';
|
||||
import { GlIcon } from '@gitlab/ui';
|
||||
import { GlSearchBoxByType } from '@gitlab/ui';
|
||||
import Tracking from '~/tracking';
|
||||
import frequentItemsMixin from './frequent_items_mixin';
|
||||
|
||||
|
@ -9,7 +9,7 @@ const trackingMixin = Tracking.mixin();
|
|||
|
||||
export default {
|
||||
components: {
|
||||
GlIcon,
|
||||
GlSearchBoxByType,
|
||||
},
|
||||
mixins: [frequentItemsMixin, trackingMixin],
|
||||
data() {
|
||||
|
@ -33,22 +33,15 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
...mapActions(['setSearchQuery']),
|
||||
setFocus() {
|
||||
this.$refs.search.focus();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-input-container d-none d-sm-block">
|
||||
<input
|
||||
ref="search"
|
||||
<gl-search-box-by-type
|
||||
v-model="searchQuery"
|
||||
:placeholder="translations.searchInputPlaceholder"
|
||||
type="search"
|
||||
class="form-control"
|
||||
/>
|
||||
<gl-icon v-if="!searchQuery" name="search" class="search-icon" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import initSearchSettings from '~/search_settings';
|
||||
|
||||
initSearchSettings();
|
|
@ -1,6 +1,7 @@
|
|||
import $ from 'jquery';
|
||||
import '~/profile/gl_crop';
|
||||
import Profile from '~/profile/profile';
|
||||
import initSearchSettings from '~/search_settings';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// eslint-disable-next-line func-names
|
||||
|
@ -17,4 +18,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
});
|
||||
|
||||
new Profile(); // eslint-disable-line no-new
|
||||
|
||||
initSearchSettings();
|
||||
});
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
import axios from '~/lib/utils/axios_utils';
|
||||
|
||||
function showCount(el, count) {
|
||||
el.textContent = count;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function refreshCount(el) {
|
||||
const { url } = el.dataset;
|
||||
|
||||
return axios
|
||||
.get(url)
|
||||
.then(({ data }) => showCount(el, data.count))
|
||||
.catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to fetch search count from '${url}'.`, e);
|
||||
});
|
||||
}
|
||||
|
||||
export default function refreshCounts() {
|
||||
const elements = Array.from(document.querySelectorAll('.js-search-count'));
|
||||
|
||||
return Promise.all(elements.map(refreshCount));
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result';
|
||||
import Project from '~/pages/projects/project';
|
||||
import refreshCounts from '~/pages/search/show/refresh_counts';
|
||||
import { queryToObject } from '~/lib/utils/url_utility';
|
||||
import createStore from './store';
|
||||
import { initTopbar } from './topbar';
|
||||
|
@ -20,6 +19,5 @@ export const initSearchApp = () => {
|
|||
initSearchSort(store);
|
||||
|
||||
setHighlightClass(query.search); // Code Highlighting
|
||||
refreshCounts(); // Other Scope Tab Counts
|
||||
Project.initRefSwitcher(); // Code Search Branch Picker
|
||||
};
|
||||
|
|
|
@ -1,9 +1,30 @@
|
|||
import axios from '~/lib/utils/axios_utils';
|
||||
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';
|
||||
|
||||
/* private */
|
||||
const getCount = ({ params, state, activeCount }) => {
|
||||
const globalSearchCountsPath = '/search/count';
|
||||
const url = Api.buildUrl(globalSearchCountsPath);
|
||||
|
||||
// count is known for active tab, so return it and skip the Api call
|
||||
if (params.scope === state.query?.scope) {
|
||||
return { scope: params.scope, count: activeCount };
|
||||
}
|
||||
|
||||
return axios
|
||||
.get(url, { params })
|
||||
.then(({ data }) => {
|
||||
return { scope: params.scope, count: data.count };
|
||||
})
|
||||
.catch((e) => {
|
||||
throw e;
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchGroups = ({ commit }, search) => {
|
||||
commit(types.REQUEST_GROUPS);
|
||||
Api.groups(search)
|
||||
|
@ -38,6 +59,21 @@ export const fetchProjects = ({ commit, state }, search) => {
|
|||
}
|
||||
};
|
||||
|
||||
export const fetchSearchCounts = ({ commit, state }, { scopeTabs, activeCount }) => {
|
||||
commit(types.REQUEST_SEARCH_COUNTS, { scopeTabs, activeCount });
|
||||
const promises = scopeTabs.map((scope) =>
|
||||
getCount({ params: { ...state.query, scope }, state, activeCount }),
|
||||
);
|
||||
|
||||
Promise.all(promises)
|
||||
.then((data) => {
|
||||
commit(types.RECEIVE_SEARCH_COUNTS_SUCCESS, data);
|
||||
})
|
||||
.catch(() => {
|
||||
createFlash({ message: __('There was an error fetching the Search Counts') });
|
||||
});
|
||||
};
|
||||
|
||||
export const setQuery = ({ commit }, { key, value }) => {
|
||||
commit(types.SET_QUERY, { key, value });
|
||||
};
|
||||
|
@ -46,6 +82,22 @@ export const applyQuery = ({ state }) => {
|
|||
visitUrl(setUrlParams({ ...state.query, page: null }));
|
||||
};
|
||||
|
||||
export const resetQuery = ({ state }) => {
|
||||
visitUrl(setUrlParams({ ...state.query, page: null, state: null, confidential: null }));
|
||||
export const resetQuery = ({ state }, snippets = false) => {
|
||||
let defaultQuery = {
|
||||
page: null,
|
||||
state: null,
|
||||
confidential: null,
|
||||
nav_source: null,
|
||||
};
|
||||
|
||||
if (snippets) {
|
||||
defaultQuery = {
|
||||
snippets: true,
|
||||
group_id: null,
|
||||
project_id: null,
|
||||
...defaultQuery,
|
||||
};
|
||||
}
|
||||
|
||||
visitUrl(setUrlParams({ ...state.query, ...defaultQuery }));
|
||||
};
|
||||
|
|
|
@ -6,4 +6,7 @@ export const REQUEST_PROJECTS = 'REQUEST_PROJECTS';
|
|||
export const RECEIVE_PROJECTS_SUCCESS = 'RECEIVE_PROJECTS_SUCCESS';
|
||||
export const RECEIVE_PROJECTS_ERROR = 'RECEIVE_PROJECTS_ERROR';
|
||||
|
||||
export const REQUEST_SEARCH_COUNTS = 'REQUEST_SEARCH_COUNTS';
|
||||
export const RECEIVE_SEARCH_COUNTS_SUCCESS = 'RECEIVE_SEARCH_COUNTS_SUCCESS';
|
||||
|
||||
export const SET_QUERY = 'SET_QUERY';
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { ALL_SCOPE_TABS } from '~/search/topbar/constants';
|
||||
import * as types from './mutation_types';
|
||||
|
||||
export default {
|
||||
|
@ -23,6 +24,16 @@ export default {
|
|||
state.fetchingProjects = false;
|
||||
state.projects = [];
|
||||
},
|
||||
[types.REQUEST_SEARCH_COUNTS](state, { scopeTabs, activeCount }) {
|
||||
state.inflatedScopeTabs = scopeTabs.map((tab) => {
|
||||
return { ...ALL_SCOPE_TABS[tab], count: tab === state.query?.scope ? activeCount : '' };
|
||||
});
|
||||
},
|
||||
[types.RECEIVE_SEARCH_COUNTS_SUCCESS](state, data) {
|
||||
state.inflatedScopeTabs = data.map((tab) => {
|
||||
return { ...ALL_SCOPE_TABS[tab.scope], count: tab.count };
|
||||
});
|
||||
},
|
||||
[types.SET_QUERY](state, { key, value }) {
|
||||
state.query[key] = value;
|
||||
},
|
||||
|
|
|
@ -4,5 +4,6 @@ const createState = ({ query }) => ({
|
|||
fetchingGroups: false,
|
||||
projects: [],
|
||||
fetchingProjects: false,
|
||||
inflatedScopeTabs: [],
|
||||
});
|
||||
export default createState;
|
||||
|
|
|
@ -3,6 +3,7 @@ import { mapState, mapActions } from 'vuex';
|
|||
import { GlForm, GlSearchBoxByType, GlButton } from '@gitlab/ui';
|
||||
import GroupFilter from './group_filter.vue';
|
||||
import ProjectFilter from './project_filter.vue';
|
||||
import ScopeTabs from './scope_tabs.vue';
|
||||
|
||||
export default {
|
||||
name: 'GlobalSearchTopbar',
|
||||
|
@ -12,6 +13,7 @@ export default {
|
|||
GroupFilter,
|
||||
ProjectFilter,
|
||||
GlButton,
|
||||
ScopeTabs,
|
||||
},
|
||||
props: {
|
||||
groupInitialData: {
|
||||
|
@ -24,6 +26,16 @@ export default {
|
|||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
scopeTabs: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
count: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState(['query']),
|
||||
|
@ -38,6 +50,9 @@ export default {
|
|||
showFilters() {
|
||||
return !this.query.snippets || this.query.snippets === 'false';
|
||||
},
|
||||
showScopeTabs() {
|
||||
return this.query.search;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['applyQuery', 'setQuery']),
|
||||
|
@ -46,28 +61,31 @@ export default {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<gl-form class="search-page-form" @submit.prevent="applyQuery">
|
||||
<section class="gl-lg-display-flex gl-align-items-flex-end">
|
||||
<div class="gl-flex-fill-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2">
|
||||
<label>{{ __('What are you searching for?') }}</label>
|
||||
<gl-search-box-by-type
|
||||
id="dashboard_search"
|
||||
v-model="search"
|
||||
name="search"
|
||||
:placeholder="__(`Search for projects, issues, etc.`)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2">
|
||||
<label class="gl-display-block">{{ __('Group') }}</label>
|
||||
<group-filter :initial-data="groupInitialData" />
|
||||
</div>
|
||||
<div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2">
|
||||
<label class="gl-display-block">{{ __('Project') }}</label>
|
||||
<project-filter :initial-data="projectInitialData" />
|
||||
</div>
|
||||
<gl-button class="btn-search gl-lg-ml-2" variant="success" type="submit">{{
|
||||
__('Search')
|
||||
}}</gl-button>
|
||||
</section>
|
||||
</gl-form>
|
||||
<section>
|
||||
<gl-form class="search-page-form" @submit.prevent="applyQuery">
|
||||
<section class="gl-lg-display-flex gl-align-items-flex-end">
|
||||
<div class="gl-flex-fill-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2">
|
||||
<label>{{ __('What are you searching for?') }}</label>
|
||||
<gl-search-box-by-type
|
||||
id="dashboard_search"
|
||||
v-model="search"
|
||||
name="search"
|
||||
:placeholder="__(`Search for projects, issues, etc.`)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2">
|
||||
<label class="gl-display-block">{{ __('Group') }}</label>
|
||||
<group-filter :initial-data="groupInitialData" />
|
||||
</div>
|
||||
<div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2">
|
||||
<label class="gl-display-block">{{ __('Project') }}</label>
|
||||
<project-filter :initial-data="projectInitialData" />
|
||||
</div>
|
||||
<gl-button class="btn-search gl-lg-ml-2" variant="success" type="submit">{{
|
||||
__('Search')
|
||||
}}</gl-button>
|
||||
</section>
|
||||
</gl-form>
|
||||
<scope-tabs v-if="showScopeTabs" :scope-tabs="scopeTabs" :count="count" />
|
||||
</section>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
<script>
|
||||
import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
|
||||
import { mapState, mapActions } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ScopeTabs',
|
||||
components: {
|
||||
GlTabs,
|
||||
GlTab,
|
||||
GlBadge,
|
||||
},
|
||||
props: {
|
||||
scopeTabs: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
count: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState(['query', 'inflatedScopeTabs']),
|
||||
},
|
||||
created() {
|
||||
this.fetchSearchCounts({ scopeTabs: this.scopeTabs, activeCount: this.count });
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchSearchCounts', 'setQuery', 'resetQuery']),
|
||||
handleTabChange(scope) {
|
||||
this.setQuery({ key: 'scope', value: scope });
|
||||
this.resetQuery(scope === 'snippet_titles');
|
||||
},
|
||||
isTabActive(scope) {
|
||||
return scope === this.query.scope;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<gl-tabs
|
||||
content-class="gl-p-0"
|
||||
nav-class="search-filter search-nav-tabs gl-display-flex gl-overflow-x-auto"
|
||||
>
|
||||
<gl-tab
|
||||
v-for="tab in inflatedScopeTabs"
|
||||
:key="tab.scope"
|
||||
class="gl-display-flex"
|
||||
:active="isTabActive(tab.scope)"
|
||||
:data-testid="`tab-${tab.scope}`"
|
||||
:title-link-attributes="{ 'data-qa-selector': tab.qaSelector }"
|
||||
title-link-class="gl-white-space-nowrap"
|
||||
@click="handleTabChange(tab.scope)"
|
||||
>
|
||||
<template #title>
|
||||
<span data-testid="tab-title"> {{ tab.title }} </span>
|
||||
<gl-badge
|
||||
v-show="tab.count"
|
||||
:data-scope="tab.scope"
|
||||
:data-testid="`badge-${tab.scope}`"
|
||||
:variant="isTabActive(tab.scope) ? 'neutral' : 'muted'"
|
||||
size="sm"
|
||||
>
|
||||
{{ tab.count }}
|
||||
</gl-badge>
|
||||
</template>
|
||||
</gl-tab>
|
||||
</gl-tabs>
|
||||
</div>
|
||||
</template>
|
|
@ -19,3 +19,17 @@ export const PROJECT_DATA = {
|
|||
selectedDisplayValue: 'name_with_namespace',
|
||||
itemsDisplayValue: 'name_with_namespace',
|
||||
};
|
||||
|
||||
export const ALL_SCOPE_TABS = {
|
||||
blobs: { scope: 'blobs', title: __('Code'), qaSelector: 'code_tab' },
|
||||
issues: { scope: 'issues', title: __('Issues') },
|
||||
merge_requests: { scope: 'merge_requests', title: __('Merge requests') },
|
||||
milestones: { scope: 'milestones', title: __('Milestones') },
|
||||
notes: { scope: 'notes', title: __('Comments') },
|
||||
wiki_blobs: { scope: 'wiki_blobs', title: __('Wiki') },
|
||||
commits: { scope: 'commits', title: __('Commits') },
|
||||
epics: { scope: 'epics', title: __('Epics') },
|
||||
users: { scope: 'users', title: __('Users') },
|
||||
snippet_titles: { scope: 'snippet_titles', title: __('Titles and Descriptions') },
|
||||
projects: { scope: 'projects', title: __('Projects'), qaSelector: 'projects_tab' },
|
||||
};
|
||||
|
|
|
@ -11,10 +11,12 @@ export const initTopbar = (store) => {
|
|||
return false;
|
||||
}
|
||||
|
||||
let { groupInitialData, projectInitialData } = el.dataset;
|
||||
let { groupInitialData, projectInitialData, scopeTabs } = el.dataset;
|
||||
const { count } = el.dataset;
|
||||
|
||||
groupInitialData = JSON.parse(groupInitialData);
|
||||
projectInitialData = JSON.parse(projectInitialData);
|
||||
scopeTabs = JSON.parse(scopeTabs);
|
||||
|
||||
return new Vue({
|
||||
el,
|
||||
|
@ -24,6 +26,8 @@ export const initTopbar = (store) => {
|
|||
props: {
|
||||
groupInitialData,
|
||||
projectInitialData,
|
||||
scopeTabs,
|
||||
count,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
|
@ -11,7 +11,7 @@ const mountSearch = ({ el }) =>
|
|||
ref: 'searchSettings',
|
||||
props: {
|
||||
searchRoot: document.querySelector('#content-body'),
|
||||
sectionSelector: 'section.settings',
|
||||
sectionSelector: '.js-search-settings-section, section.settings',
|
||||
},
|
||||
on: {
|
||||
collapse: (section) => closeSection($(section)),
|
||||
|
|
|
@ -2,6 +2,7 @@ $search-dropdown-max-height: 400px;
|
|||
$search-avatar-size: 16px;
|
||||
$search-sidebar-min-width: 240px;
|
||||
$search-sidebar-max-width: 300px;
|
||||
$search-topbar-min-height: 111px;
|
||||
|
||||
.search-results {
|
||||
.search-result-row {
|
||||
|
@ -19,6 +20,12 @@ $search-sidebar-max-width: 300px;
|
|||
}
|
||||
}
|
||||
|
||||
.search-topbar {
|
||||
@include media-breakpoint-up(md) {
|
||||
min-height: $search-topbar-min-height;
|
||||
}
|
||||
}
|
||||
|
||||
.search-sidebar {
|
||||
@include media-breakpoint-up(md) {
|
||||
min-width: $search-sidebar-min-width;
|
||||
|
@ -26,6 +33,11 @@ $search-sidebar-max-width: 300px;
|
|||
}
|
||||
}
|
||||
|
||||
.search-nav-tabs {
|
||||
overflow-y: hidden;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.search form:hover,
|
||||
.file-finder-input:hover,
|
||||
.issuable-search-form:hover,
|
||||
|
|
|
@ -502,13 +502,15 @@ class ProjectsController < Projects::ApplicationController
|
|||
render_404 unless Gitlab::CurrentSettings.project_export_enabled?
|
||||
end
|
||||
|
||||
# Redirect from localhost/group/project.git to localhost/group/project
|
||||
def redirect_git_extension
|
||||
# Redirect from
|
||||
# localhost/group/project.git
|
||||
# to
|
||||
# localhost/group/project
|
||||
#
|
||||
redirect_to request.original_url.sub(%r{\.git/?\Z}, '') if params[:format] == 'git'
|
||||
return unless params[:format] == 'git'
|
||||
|
||||
# `project` calls `find_routable!`, so this will trigger the usual not-found
|
||||
# behaviour when the user isn't authorized to see the project
|
||||
return unless project
|
||||
|
||||
redirect_to(request.original_url.sub(%r{\.git/?\Z}, ''))
|
||||
end
|
||||
|
||||
def whitelist_query_limiting
|
||||
|
|
|
@ -166,7 +166,7 @@ module CommitsHelper
|
|||
path = project_blob_path(project, tree_join(commit_sha, diff_new_path))
|
||||
title = replaced ? _('View replaced file @ ') : _('View file @ ')
|
||||
|
||||
link_to(path, class: 'btn') do
|
||||
link_to(path, class: 'btn gl-button btn-default') do
|
||||
raw(title) + content_tag(:span, truncate_sha(commit_sha), class: 'commit-sha')
|
||||
end
|
||||
end
|
||||
|
|
|
@ -133,7 +133,7 @@ module DiffHelper
|
|||
].join('').html_safe
|
||||
|
||||
tooltip = _('Compare submodule commit revisions')
|
||||
link = content_tag(:span, link_to(link_text, compare_url, class: 'btn has-tooltip', title: tooltip), class: 'submodule-compare')
|
||||
link = content_tag(:span, link_to(link_text, compare_url, class: 'btn gl-button has-tooltip', title: tooltip), class: 'submodule-compare')
|
||||
end
|
||||
|
||||
link
|
||||
|
@ -223,7 +223,7 @@ module DiffHelper
|
|||
# Always use HTML to handle case where JSON diff rendered this button
|
||||
params_copy.delete(:format)
|
||||
|
||||
link_to url_for(params_copy), id: "#{name}-diff-btn", class: (selected ? 'btn active' : 'btn'), data: { view_type: name } do
|
||||
link_to url_for(params_copy), id: "#{name}-diff-btn", class: (selected ? 'btn gl-button active' : 'btn gl-button'), data: { view_type: name } do
|
||||
title
|
||||
end
|
||||
end
|
||||
|
@ -252,7 +252,7 @@ module DiffHelper
|
|||
end
|
||||
|
||||
def toggle_whitespace_link(url, options)
|
||||
options[:class] = [*options[:class], 'btn btn-default'].join(' ')
|
||||
options[:class] = [*options[:class], 'btn gl-button btn-default'].join(' ')
|
||||
link_to "#{hide_whitespace? ? 'Show' : 'Hide'} whitespace changes", url, class: options[:class]
|
||||
end
|
||||
|
||||
|
|
|
@ -511,7 +511,8 @@ module ProjectsHelper
|
|||
commits: :download_code,
|
||||
merge_requests: :read_merge_request,
|
||||
notes: [:read_merge_request, :download_code, :read_issue, :read_snippet],
|
||||
members: :read_project_member
|
||||
members: :read_project_member,
|
||||
wiki_blobs: :read_wiki
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
@ -1,16 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module SearchHelper
|
||||
SEARCH_GENERIC_PARAMS = [
|
||||
:search,
|
||||
:scope,
|
||||
:project_id,
|
||||
:group_id,
|
||||
:repository_ref,
|
||||
:snippets,
|
||||
:sort,
|
||||
:force_search_results
|
||||
].freeze
|
||||
PROJECT_SEARCH_TABS = %i{blobs issues merge_requests milestones notes wiki_blobs commits}.freeze
|
||||
BASIC_SEARCH_TABS = %i{projects issues merge_requests milestones}.freeze
|
||||
|
||||
def search_autocomplete_opts(term)
|
||||
return unless current_user
|
||||
|
@ -283,27 +275,19 @@ module SearchHelper
|
|||
Sanitize.clean(str)
|
||||
end
|
||||
|
||||
def search_filter_link(scope, label, data: {}, search: {})
|
||||
search_params = params
|
||||
.merge(search)
|
||||
.merge({ scope: scope })
|
||||
.permit(SEARCH_GENERIC_PARAMS)
|
||||
def search_nav_tabs
|
||||
return [:snippet_titles] if !@project && @show_snippets
|
||||
|
||||
if @scope == scope
|
||||
li_class = 'active'
|
||||
count = @search_results.formatted_count(scope)
|
||||
else
|
||||
badge_class = 'js-search-count hidden'
|
||||
badge_data = { url: search_count_path(search_params) }
|
||||
end
|
||||
|
||||
content_tag :li, class: li_class, data: data do
|
||||
link_to search_path(search_params) do
|
||||
concat label
|
||||
concat ' '
|
||||
concat content_tag(:span, count, class: ['badge badge-pill', badge_class], data: badge_data)
|
||||
tabs =
|
||||
if @project
|
||||
PROJECT_SEARCH_TABS.select { |tab| project_search_tabs?(tab) }
|
||||
else
|
||||
BASIC_SEARCH_TABS.dup
|
||||
end
|
||||
end
|
||||
|
||||
tabs << :users if show_user_search_tab?
|
||||
|
||||
tabs
|
||||
end
|
||||
|
||||
def search_filter_input_options(type, placeholder = _('Search or filter results...'))
|
||||
|
|
|
@ -92,7 +92,7 @@ module AlertManagement
|
|||
|
||||
def incoming_payload
|
||||
strong_memoize(:incoming_payload) do
|
||||
Gitlab::AlertManagement::Payload.parse(project, payload.to_h)
|
||||
Gitlab::AlertManagement::Payload.parse(project, payload.to_h, integration: integration)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -4,4 +4,5 @@
|
|||
- nav "profile"
|
||||
- @left_sidebar = true
|
||||
|
||||
- enable_search_settings locals: { container_class: 'gl-my-5' }
|
||||
= render template: "layouts/application"
|
||||
|
|
|
@ -22,13 +22,13 @@
|
|||
|
||||
.header-action-buttons
|
||||
- if defined?(@notes_count) && @notes_count > 0
|
||||
%span.btn.disabled.gl-button.btn-icon.d-none.d-sm-inline.gl-mr-3.has-tooltip{ title: n_("%d comment on this commit", "%d comments on this commit", @notes_count) % @notes_count }
|
||||
%span.btn.gl-button.btn-default.disabled.gl-button.btn-icon.d-none.d-sm-inline.gl-mr-3.has-tooltip{ title: n_("%d comment on this commit", "%d comments on this commit", @notes_count) % @notes_count }
|
||||
= sprite_icon('comment')
|
||||
= @notes_count
|
||||
= link_to project_tree_path(@project, @commit), class: "btn gl-button gl-mr-3 d-none d-md-inline" do
|
||||
= link_to project_tree_path(@project, @commit), class: "btn gl-button btn-default gl-mr-3 d-none d-md-inline" do
|
||||
#{ _('Browse files') }
|
||||
.dropdown.inline
|
||||
%a.btn.gl-button.dropdown-toggle.qa-options-button.d-md-inline{ data: { toggle: "dropdown" } }
|
||||
%a.btn.gl-button.btn-default.dropdown-toggle.qa-options-button.d-md-inline{ data: { toggle: "dropdown" } }
|
||||
%span= _('Options')
|
||||
= sprite_icon('chevron-down', css_class: 'gl-text-gray-500')
|
||||
%ul.dropdown-menu.dropdown-menu-right
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
- unless diff_file.submodule?
|
||||
.file-actions.d-none.d-sm-block
|
||||
- if diff_file.blob&.readable_text?
|
||||
= link_to '#', class: 'js-toggle-diff-comments gl-button btn active has-tooltip', title: _("Toggle comments for this file"), disabled: @diff_notes_disabled do
|
||||
= link_to '#', class: 'js-toggle-diff-comments btn gl-button active has-tooltip', title: _("Toggle comments for this file"), disabled: @diff_notes_disabled do
|
||||
= sprite_icon('comment')
|
||||
\
|
||||
- if editable_diff?(diff_file)
|
||||
|
|
|
@ -1,35 +0,0 @@
|
|||
- users = capture_haml do
|
||||
- if show_user_search_tab?
|
||||
= search_filter_link 'users', _("Users")
|
||||
|
||||
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
|
||||
.fade-left= sprite_icon('chevron-lg-left', size: 12)
|
||||
.fade-right= sprite_icon('chevron-lg-right', size: 12)
|
||||
%ul.nav-links.search-filter.scrolling-tabs.nav.nav-tabs
|
||||
- if @project
|
||||
- if project_search_tabs?(:blobs)
|
||||
= search_filter_link 'blobs', _("Code"), data: { qa_selector: 'code_tab' }
|
||||
- if project_search_tabs?(:issues)
|
||||
= search_filter_link 'issues', _("Issues")
|
||||
- if project_search_tabs?(:merge_requests)
|
||||
= search_filter_link 'merge_requests', _("Merge requests")
|
||||
- if project_search_tabs?(:milestones)
|
||||
= search_filter_link 'milestones', _("Milestones")
|
||||
- if project_search_tabs?(:notes)
|
||||
= search_filter_link 'notes', _("Comments")
|
||||
- if project_search_tabs?(:wiki)
|
||||
= search_filter_link 'wiki_blobs', _("Wiki")
|
||||
- if project_search_tabs?(:commits)
|
||||
= search_filter_link 'commits', _("Commits")
|
||||
= users
|
||||
|
||||
- elsif @show_snippets
|
||||
= search_filter_link 'snippet_titles', _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil }
|
||||
- else
|
||||
= search_filter_link 'projects', _("Projects"), data: { qa_selector: 'projects_tab' }
|
||||
= search_filter_link 'issues', _("Issues")
|
||||
= search_filter_link 'merge_requests', _("Merge requests")
|
||||
= search_filter_link 'milestones', _("Milestones")
|
||||
= render_if_exists 'search/epics_filter_link'
|
||||
= render_if_exists 'search/category_elasticsearch'
|
||||
= users
|
|
@ -16,7 +16,6 @@
|
|||
= render_if_exists 'search/form_elasticsearch', attrs: { class: 'mb-2 mb-sm-0 align-self-center' }
|
||||
|
||||
.gl-mt-3
|
||||
#js-search-topbar{ data: { "group-initial-data": @group.to_json, "project-initial-data": project_attributes.to_json } }
|
||||
#js-search-topbar.search-topbar{ data: { "group-initial-data": @group.to_json, "project-initial-data": project_attributes.to_json, "scope-tabs": search_nav_tabs.to_json, count: @search_results&.formatted_count(@scope) } }
|
||||
- if @search_term
|
||||
= render 'search/category'
|
||||
= render 'search/results'
|
||||
|
|
|
@ -13,7 +13,7 @@ class NewNoteWorker # rubocop:disable Scalability/IdempotentWorker
|
|||
# rubocop: disable CodeReuse/ActiveRecord
|
||||
def perform(note_id, _params = {})
|
||||
if note = Note.find_by(id: note_id)
|
||||
NotificationService.new.new_note(note) unless note.skip_notification?
|
||||
NotificationService.new.new_note(note) unless note.skip_notification? || note.author.ghost?
|
||||
Notes::PostProcessService.new(note).execute
|
||||
else
|
||||
Gitlab::AppLogger.error("NewNoteWorker: couldn't find note with ID=#{note_id}, skipping job")
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Don't expose project existence by redirecting from its .git URL
|
||||
merge_request: 52818
|
||||
author:
|
||||
type: fixed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Change search tab to Vue component
|
||||
merge_request: 52018
|
||||
author:
|
||||
type: changed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Apply new GitLab UI for buttons in commit page
|
||||
merge_request: 53555
|
||||
author: Yogi (@yo)
|
||||
type: other
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Apply new GitLab UI for new trial page
|
||||
merge_request: 53447
|
||||
author: Yogi (@yo)
|
||||
type: other
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Skip new note notifications when author is deleted
|
||||
merge_request: 53699
|
||||
author:
|
||||
type: changed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Apply new GitLab UI for search in frequent items search
|
||||
merge_request: 53368
|
||||
author: Yogi (@yo)
|
||||
type: other
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: ci_custom_yaml_tags
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52104
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/300155
|
||||
milestone: '13.9'
|
||||
type: development
|
||||
group: group::pipeline authoring
|
||||
default_enabled: false
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: database_sourced_aggregated_metrics
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52784
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/300411
|
||||
milestone: '13.9'
|
||||
type: development
|
||||
group: group::product intelligence
|
||||
default_enabled: false
|
|
@ -90,6 +90,7 @@ def instrument_classes(instrumentation)
|
|||
|
||||
instrumentation.instrument_methods(Gitlab::Highlight)
|
||||
instrumentation.instrument_instance_methods(Gitlab::Highlight)
|
||||
instrumentation.instrument_instance_method(Gitlab::Ci::Config::Yaml::Tags::Resolver, :to_hash)
|
||||
|
||||
Gitlab.ee do
|
||||
instrumentation.instrument_instance_methods(Elastic::Latest::GitInstanceProxy)
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
key_path: counts_monthly.deployments
|
||||
description: Total deployments count for recent 28 days
|
||||
value_type: integer
|
||||
stage: release
|
||||
product_stage: release
|
||||
status: data_available
|
||||
milestone: 13.2
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35493
|
||||
group: 'group::ops release'
|
||||
product_group: 'group::ops release'
|
||||
time_frame: 28d
|
||||
data_source: database
|
||||
distribution: [ee, ce]
|
||||
|
|
|
@ -2,11 +2,11 @@ key_path: redis_hll_counters.issues_edit.g_project_management_issue_title_change
|
|||
description: Distinct users count that changed issue title in a group for last recent week
|
||||
value_type: integer
|
||||
product_category: issue_tracking
|
||||
stage: plan
|
||||
product_stage: plan
|
||||
status: data_available
|
||||
milestone: 13.6
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
|
||||
group: 'group::project management'
|
||||
product_group: 'group::project management'
|
||||
time_frame: 7d
|
||||
data_source: redis_hll
|
||||
distribution: [ee, ce]
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
key_path: counts.deployments
|
||||
description: Total deployments count
|
||||
value_type: integer
|
||||
stage: release
|
||||
product_stage: release
|
||||
status: data_available
|
||||
milestone: 8.12
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/735
|
||||
group: 'group::ops release'
|
||||
product_group: 'group::ops release'
|
||||
time_frame: all
|
||||
data_source: database
|
||||
distribution: [ee, ce]
|
||||
|
|
|
@ -2,11 +2,11 @@ key_path: recorded_at
|
|||
description: When the Usage Ping computation was started
|
||||
value_type: string
|
||||
product_category: collection
|
||||
stage: growth
|
||||
product_stage: growth
|
||||
status: data_available
|
||||
milestone: 8.10
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/557
|
||||
group: group::product analytics
|
||||
product_group: group::product intelligence
|
||||
time_frame: none
|
||||
data_source: ruby
|
||||
distribution: [ee, ce]
|
||||
|
|
|
@ -2,11 +2,11 @@ key_path: uuid
|
|||
description: GitLab instance unique identifier
|
||||
value_type: string
|
||||
product_category: collection
|
||||
stage: growth
|
||||
product_stage: growth
|
||||
status: data_available
|
||||
milestone: 9.1
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1521
|
||||
group: group::product analytics
|
||||
product_group: group::product intelligence
|
||||
time_frame: none
|
||||
data_source: database
|
||||
distribution: [ee, ce]
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"type": "object",
|
||||
"required": ["key_path", "description", "value_type", "status", "group", "time_frame", "data_source", "distribution", "tier"],
|
||||
"required": ["key_path", "description", "value_type", "status", "product_group", "time_frame", "data_source", "distribution", "tier"],
|
||||
"properties": {
|
||||
"key_path": {
|
||||
"type": "string"
|
||||
|
@ -8,19 +8,25 @@
|
|||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"value_type": {
|
||||
"type": "string",
|
||||
"enum": ["integer", "string", "number", "boolean"]
|
||||
"product_section": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"product_stage": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"product_group": {
|
||||
"type": "string"
|
||||
},
|
||||
"product_category": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"stage": {
|
||||
"type": ["string", "null"]
|
||||
"value_type": {
|
||||
"type": "string",
|
||||
"enum": ["integer", "string", "number", "boolean"]
|
||||
},
|
||||
"status": {
|
||||
"type": ["string"],
|
||||
"enum": ["data_available", "planned", "in_progress", "implmented"]
|
||||
"enum": ["data_available", "planned", "in_progress", "implemented"]
|
||||
},
|
||||
"milestone": {
|
||||
"type": ["number", "null"]
|
||||
|
@ -31,9 +37,6 @@
|
|||
"introduced_by_url": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"group": {
|
||||
"type": "string"
|
||||
},
|
||||
"time_frame": {
|
||||
"type": "string",
|
||||
"enum": ["7d", "28d", "all", "none"]
|
||||
|
|
|
@ -2,9 +2,9 @@ key_path: database.adapter
|
|||
description: This metric only returns a value of PostgreSQL in supported versions of GitLab. It could be removed from the usage ping. Historically MySQL was also supported.
|
||||
value_type: string
|
||||
product_category: collection
|
||||
stage: growth
|
||||
product_stage: growth
|
||||
status: data_available
|
||||
group: group::enablement distribution
|
||||
product_group: group::enablement distribution
|
||||
time_frame: none
|
||||
data_source: database
|
||||
distribution: [ee, ce]
|
||||
|
|
|
@ -43,7 +43,7 @@ tracking_files = [
|
|||
tracking_changed_files = all_changed_files & tracking_files
|
||||
usage_data_changed_files = all_changed_files.grep(%r{(usage_data)})
|
||||
metrics_changed_files = all_changed_files.grep(%r{((ee/)?config/metrics/.*\.yml)})
|
||||
dictionary_changed_file = all_changed_files.grep(%r{(doc/developmet/usage_ping/dictionary.md)})
|
||||
dictionary_changed_file = all_changed_files.grep(%r{(doc/development/usage_ping/dictionary.md)})
|
||||
|
||||
usage_changed_files = usage_data_changed_files + tracking_changed_files + metrics_changed_files + dictionary_changed_file
|
||||
|
||||
|
|
|
@ -2028,3 +2028,56 @@ See the [troubleshooting documentation](troubleshooting.md).
|
|||
Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Cloud Native Deployment (optional)
|
||||
|
||||
Hybrid installations leverage the benefits of both cloud native and traditional
|
||||
deployments. We recommend shifting the Sidekiq and Webservice components into
|
||||
Kubernetes to reap cloud native workload management benefits while the others
|
||||
are deployed using the traditional server method already described.
|
||||
|
||||
The following sections detail this hybrid approach.
|
||||
|
||||
### Cluster topology
|
||||
|
||||
The following table provides a starting point for hybrid
|
||||
deployment infrastructure. The recommendations use Google Cloud's Kubernetes Engine (GKE)
|
||||
and associated machine types, but the memory and CPU requirements should
|
||||
translate to most other providers.
|
||||
|
||||
Machine count | Machine type | Allocatable vCPUs | Allocatable memory (GB) | Purpose
|
||||
-|-|-|-|-
|
||||
2 | `n1-standard-4` | 7.75 | 25 | Non-GitLab resources, including Grafana, NGINX, and Prometheus
|
||||
4 | `n1-standard-4` | 15.5 | 50 | GitLab Sidekiq pods
|
||||
4 | `n1-highcpu-32` | 127.5 | 118 | GitLab Webservice pods
|
||||
|
||||
"Allocatable" in this table refers to the amount of resources available to workloads deployed in Kubernetes _after_ accounting for the overhead of running Kubernetes itself.
|
||||
|
||||
### Resource usage settings
|
||||
|
||||
The following formulas help when calculating how many pods may be deployed within resource constraints.
|
||||
The [10k reference architecture example values file](https://gitlab.com/gitlab-org/charts/gitlab/-/blob/master/examples/ref/10k.yaml)
|
||||
documents how to apply the calculated configuration to the Helm Chart.
|
||||
|
||||
#### Sidekiq
|
||||
|
||||
Sidekiq pods should generally have 1 vCPU and 2 GB of memory.
|
||||
|
||||
[The provided starting point](#cluster-topology) allows the deployment of up to
|
||||
16 Sidekiq pods. Expand available resources using the 1vCPU to 2GB memory
|
||||
ratio for each additional pod.
|
||||
|
||||
For further information on resource usage, see the [Sidekiq resources](https://docs.gitlab.com/charts/charts/gitlab/sidekiq/#resources).
|
||||
|
||||
#### Webservice
|
||||
|
||||
Webservice pods typically need about 1 vCPU and 1.25 GB of memory _per worker_.
|
||||
Each Webservice pod will consume roughly 2 vCPUs and 2.5 GB of memory using
|
||||
the [recommended topology](#cluster-topology) because two worker processes
|
||||
are created by default.
|
||||
|
||||
The [provided recommendations](#cluster-topology) allow the deployment of up to 28
|
||||
Webservice pods. Expand available resources using the ratio of 1 vCPU to 1.25 GB of memory
|
||||
_per each worker process_ for each additional Webservice pod.
|
||||
|
||||
For further information on resource usage, see the [Webservice resources](https://docs.gitlab.com/charts/charts/gitlab/webservice/#resources).
|
||||
|
|
|
@ -207,19 +207,41 @@ To add a redirect:
|
|||
1. Assign the MR to a technical writer for review and merge.
|
||||
1. If the redirect is to one of the 4 internal docs projects (not an external URL),
|
||||
create an MR in [`gitlab-docs`](https://gitlab.com/gitlab-org/gitlab-docs):
|
||||
1. Update [`_redirects`](https://gitlab.com/gitlab-org/gitlab-docs/-/blob/master/content/_redirects)
|
||||
1. Update [`redirects.yaml`](https://gitlab.com/gitlab-org/gitlab-docs/-/blob/master/content/_data/redirects.yaml)
|
||||
with one redirect entry for each renamed or moved file. This code works for
|
||||
<https://docs.gitlab.com> links only:
|
||||
<https://docs.gitlab.com> links only. Keep them alphabetically sorted:
|
||||
|
||||
```plaintext
|
||||
/ee/path/to/old_file.html /ee/path/to/new_file 302 # To be removed after YYYY-MM-DD
|
||||
```yaml
|
||||
- from: /ee/path/to/old_file.html
|
||||
to: /ee/path/to/new_file.html
|
||||
remove_date: YYYY-MM-DD
|
||||
```
|
||||
|
||||
The path must start with the internal project directory `/ee` for `gitlab`,
|
||||
`/gitlab-runner`, `/omnibus-gitlab` or `charts`, and must end with `.html`.
|
||||
The path must start with the internal project directory `/ee`,
|
||||
`/runner`, `/omnibus` or `/charts`, and end with either `.html` or `/`
|
||||
for a clean URL.
|
||||
|
||||
`_redirects` entries can be removed after one year.
|
||||
If the `from:` redirect is an `index.html` file, add a duplicate entry for
|
||||
the `/` URL (without `index.html). For example:
|
||||
|
||||
```yaml
|
||||
- from: /ee/user/project/operations/index.html
|
||||
to: /ee/operations/index.html
|
||||
remove_date: 2021-11-01
|
||||
- from: /ee/user/project/operations/
|
||||
to: /ee/operations/index.html
|
||||
remove_date: 2021-11-01
|
||||
```
|
||||
|
||||
The `remove_date` should be one year after the redirect is submitted.
|
||||
|
||||
1. Run the Rake task in the `gitlab-docs` project to populate the `_redirects` file:
|
||||
|
||||
```shell
|
||||
bundle exec rake redirects
|
||||
```
|
||||
|
||||
1. Add both `content/_redirects` and `content/_data/redirects.yaml` to your MR.
|
||||
1. Search for links to the old file. You must find and update all links to the old file:
|
||||
|
||||
- In <https://gitlab.com/gitlab-com/www-gitlab-com>, search for full URLs:
|
||||
|
|
|
@ -37,11 +37,11 @@ Total deployments count
|
|||
| --- | --- |
|
||||
| `key_path` | **counts.deployments** |
|
||||
| `value_type` | integer |
|
||||
| `stage` | release |
|
||||
| `product_stage` | release |
|
||||
| `status` | data_available |
|
||||
| `milestone` | 8.12 |
|
||||
| `introduced_by_url` | [Introduced by](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/735) |
|
||||
| `group` | `group::ops release` |
|
||||
| `product_group` | `group::ops release` |
|
||||
| `time_frame` | all |
|
||||
| `data_source` | Database |
|
||||
| `distribution` | ee, ce |
|
||||
|
@ -56,10 +56,10 @@ Total number of sites in a Geo deployment
|
|||
| `key_path` | **counts.geo_nodes** |
|
||||
| `value_type` | integer |
|
||||
| `product_category` | disaster_recovery |
|
||||
| `stage` | enablement |
|
||||
| `product_stage` | enablement |
|
||||
| `status` | data_available |
|
||||
| `milestone` | 11.2 |
|
||||
| `group` | `group::geo` |
|
||||
| `product_group` | `group::geo` |
|
||||
| `time_frame` | all |
|
||||
| `data_source` | Database |
|
||||
| `distribution` | ee |
|
||||
|
@ -73,11 +73,11 @@ Total deployments count for recent 28 days
|
|||
| --- | --- |
|
||||
| `key_path` | **counts_monthly.deployments** |
|
||||
| `value_type` | integer |
|
||||
| `stage` | release |
|
||||
| `product_stage` | release |
|
||||
| `status` | data_available |
|
||||
| `milestone` | 13.2 |
|
||||
| `introduced_by_url` | [Introduced by](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35493) |
|
||||
| `group` | `group::ops release` |
|
||||
| `product_group` | `group::ops release` |
|
||||
| `time_frame` | 28d |
|
||||
| `data_source` | Database |
|
||||
| `distribution` | ee, ce |
|
||||
|
@ -92,9 +92,9 @@ This metric only returns a value of PostgreSQL in supported versions of GitLab.
|
|||
| `key_path` | **database.adapter** |
|
||||
| `value_type` | string |
|
||||
| `product_category` | collection |
|
||||
| `stage` | growth |
|
||||
| `product_stage` | growth |
|
||||
| `status` | data_available |
|
||||
| `group` | `group::enablement distribution` |
|
||||
| `product_group` | `group::enablement distribution` |
|
||||
| `time_frame` | none |
|
||||
| `data_source` | Database |
|
||||
| `distribution` | ee, ce |
|
||||
|
@ -109,11 +109,11 @@ When the Usage Ping computation was started
|
|||
| `key_path` | **recorded_at** |
|
||||
| `value_type` | string |
|
||||
| `product_category` | collection |
|
||||
| `stage` | growth |
|
||||
| `product_stage` | growth |
|
||||
| `status` | data_available |
|
||||
| `milestone` | 8.1 |
|
||||
| `introduced_by_url` | [Introduced by](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/557) |
|
||||
| `group` | `group::product analytics` |
|
||||
| `product_group` | `group::product intelligence` |
|
||||
| `time_frame` | none |
|
||||
| `data_source` | Ruby |
|
||||
| `distribution` | ee, ce |
|
||||
|
@ -128,11 +128,11 @@ Distinct users count that changed issue title in a group for last recent week
|
|||
| `key_path` | **redis_hll_counters.issues_edit.g_project_management_issue_title_changed_weekly** |
|
||||
| `value_type` | integer |
|
||||
| `product_category` | issue_tracking |
|
||||
| `stage` | plan |
|
||||
| `product_stage` | plan |
|
||||
| `status` | data_available |
|
||||
| `milestone` | 13.6 |
|
||||
| `introduced_by_url` | [Introduced by](https://gitlab.com/gitlab-org/gitlab/-/issues/229918) |
|
||||
| `group` | `group::project management` |
|
||||
| `product_group` | `group::project management` |
|
||||
| `time_frame` | 7d |
|
||||
| `data_source` | Redis_hll |
|
||||
| `distribution` | ee, ce |
|
||||
|
@ -147,11 +147,11 @@ GitLab instance unique identifier
|
|||
| `key_path` | **uuid** |
|
||||
| `value_type` | string |
|
||||
| `product_category` | collection |
|
||||
| `stage` | growth |
|
||||
| `product_stage` | growth |
|
||||
| `status` | data_available |
|
||||
| `milestone` | 9.1 |
|
||||
| `introduced_by_url` | [Introduced by](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1521) |
|
||||
| `group` | `group::product analytics` |
|
||||
| `product_group` | `group::product intelligence` |
|
||||
| `time_frame` | none |
|
||||
| `data_source` | Database |
|
||||
| `distribution` | ee, ce |
|
||||
|
|
|
@ -30,13 +30,13 @@ Each metric is defined in a separate YAML file consisting of a number of fields:
|
|||
| `description` | yes | |
|
||||
| `value_type` | yes | |
|
||||
| `status` | yes | |
|
||||
| `group` | yes | The [group](https://about.gitlab.com/handbook/product/categories/#devops-stages) that owns the metric. |
|
||||
| `product_group` | yes | The [group](https://about.gitlab.com/handbook/product/categories/#devops-stages) that owns the metric. |
|
||||
| `time_frame` | yes | `string`; may be set to a value like "7d" |
|
||||
| `data_source` | yes | `string`: may be set to a value like `database` or `redis_hll`. |
|
||||
| `distribution` | yes | The [distribution](https://about.gitlab.com/handbook/marketing/strategic-marketing/tiers/#definitions) where the metric applies. |
|
||||
| `tier` | yes | The [tier]( https://about.gitlab.com/handbook/marketing/strategic-marketing/tiers/) where the metric applies. |
|
||||
| `product_category` | no | The [product category](https://gitlab.com/gitlab-com/www-gitlab-com/blob/master/data/categories.yml) for the metric. |
|
||||
| `stage` | no | The [stage](https://gitlab.com/gitlab-com/www-gitlab-com/blob/master/data/stages.yml) for the metric. |
|
||||
| `product_stage` | no | The [stage](https://gitlab.com/gitlab-com/www-gitlab-com/blob/master/data/stages.yml) for the metric. |
|
||||
| `milestone` | no | The milestone when the metric is introduced. |
|
||||
| `milestone_removed` | no | The milestone when the metric is removed. |
|
||||
| `introduced_by_url` | no | The URL to the Merge Request that introduced the metric. |
|
||||
|
@ -52,11 +52,11 @@ key_path: uuid
|
|||
description: GitLab instance unique identifier
|
||||
value_type: string
|
||||
product_category: collection
|
||||
stage: growth
|
||||
product_stage: growth
|
||||
status: data_available
|
||||
milestone: 9.1
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1521
|
||||
group: group::product intelligence
|
||||
product_group: group::product intelligence
|
||||
time_frame: none
|
||||
data_source: database
|
||||
distribution: [ee, ce]
|
||||
|
|
|
@ -17,13 +17,14 @@ module Gitlab
|
|||
# @param project [Project]
|
||||
# @param payload [Hash]
|
||||
# @param monitoring_tool [String]
|
||||
def parse(project, payload, monitoring_tool: nil)
|
||||
# @param integration [AlertManagement::HttpIntegration]
|
||||
def parse(project, payload, monitoring_tool: nil, integration: nil)
|
||||
payload_class = payload_class_for(
|
||||
monitoring_tool: monitoring_tool || payload&.dig('monitoring_tool'),
|
||||
payload: payload
|
||||
)
|
||||
|
||||
payload_class.new(project: project, payload: payload)
|
||||
payload_class.new(project: project, payload: payload, integration: integration)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -12,7 +12,7 @@ module Gitlab
|
|||
include Gitlab::Utils::StrongMemoize
|
||||
include Gitlab::Routing
|
||||
|
||||
attr_accessor :project, :payload
|
||||
attr_accessor :project, :payload, :integration
|
||||
|
||||
# Any attribute expected to be specifically read from
|
||||
# or derived from an alert payload should be defined.
|
||||
|
@ -147,6 +147,7 @@ module Gitlab
|
|||
end
|
||||
end
|
||||
|
||||
# Overriden in EE::Gitlab::AlertManagement::Payload::Generic
|
||||
def value_for_paths(paths)
|
||||
target_path = paths.find { |path| payload&.dig(*path) }
|
||||
|
||||
|
|
|
@ -13,7 +13,8 @@ module Gitlab
|
|||
RESCUE_ERRORS = [
|
||||
Gitlab::Config::Loader::FormatError,
|
||||
Extendable::ExtensionError,
|
||||
External::Processor::IncludeError
|
||||
External::Processor::IncludeError,
|
||||
Config::Yaml::Tags::TagError
|
||||
].freeze
|
||||
|
||||
attr_reader :root
|
||||
|
@ -89,6 +90,24 @@ module Gitlab
|
|||
end
|
||||
|
||||
def build_config(config)
|
||||
if ::Feature.enabled?(:ci_custom_yaml_tags, @context.project, default_enabled: :yaml)
|
||||
build_config_with_custom_tags(config)
|
||||
else
|
||||
build_config_without_custom_tags(config)
|
||||
end
|
||||
end
|
||||
|
||||
def build_config_with_custom_tags(config)
|
||||
initial_config = Config::Yaml.load!(config, project: @context.project)
|
||||
initial_config = Config::External::Processor.new(initial_config, @context).perform
|
||||
initial_config = Config::Extendable.new(initial_config).to_hash
|
||||
initial_config = Config::Yaml::Tags::Resolver.new(initial_config).to_hash
|
||||
initial_config = Config::EdgeStagesInjector.new(initial_config).to_hash
|
||||
|
||||
initial_config
|
||||
end
|
||||
|
||||
def build_config_without_custom_tags(config)
|
||||
initial_config = Gitlab::Config::Loader::Yaml.new(config).load!
|
||||
initial_config = Config::External::Processor.new(initial_config, @context).perform
|
||||
initial_config = Config::Extendable.new(initial_config).to_hash
|
||||
|
|
|
@ -60,7 +60,11 @@ module Gitlab
|
|||
|
||||
def content_hash
|
||||
strong_memoize(:content_yaml) do
|
||||
Gitlab::Config::Loader::Yaml.new(content).load!
|
||||
if ::Feature.enabled?(:ci_custom_yaml_tags, context.project, default_enabled: :yaml)
|
||||
::Gitlab::Ci::Config::Yaml.load!(content)
|
||||
else
|
||||
Gitlab::Config::Loader::Yaml.new(content).load!
|
||||
end
|
||||
end
|
||||
rescue Gitlab::Config::Loader::FormatError
|
||||
nil
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Ci
|
||||
class Config
|
||||
module Yaml
|
||||
AVAILABLE_TAGS = [Config::Yaml::Tags::Reference].freeze
|
||||
|
||||
class << self
|
||||
def load!(content, project: nil)
|
||||
ensure_custom_tags
|
||||
|
||||
Gitlab::Config::Loader::Yaml.new(content, additional_permitted_classes: AVAILABLE_TAGS).load!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_custom_tags
|
||||
@ensure_custom_tags ||= begin
|
||||
AVAILABLE_TAGS.each { |klass| Psych.add_tag(klass.tag, klass) }
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Ci
|
||||
class Config
|
||||
module Yaml
|
||||
module Tags
|
||||
TagError = Class.new(StandardError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,72 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Ci
|
||||
class Config
|
||||
module Yaml
|
||||
module Tags
|
||||
class Base
|
||||
CircularReferenceError = Class.new(Tags::TagError)
|
||||
NotValidError = Class.new(Tags::TagError)
|
||||
|
||||
extend ::Gitlab::Utils::Override
|
||||
|
||||
attr_accessor :resolved_status, :resolved_value, :data
|
||||
|
||||
def self.tag
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
# Only one of the `seq`, `scalar`, `map` fields is available.
|
||||
def init_with(coder)
|
||||
@data = {
|
||||
tag: coder.tag, # This is the custom YAML tag, like !reference or !flatten
|
||||
style: coder.style,
|
||||
seq: coder.seq, # This holds Array data
|
||||
scalar: coder.scalar, # This holds data of basic types, like String.
|
||||
map: coder.map # This holds Hash data.
|
||||
}
|
||||
end
|
||||
|
||||
def valid?
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def resolve(resolver)
|
||||
raise NotValidError, validation_error_message unless valid?
|
||||
raise CircularReferenceError, circular_error_message if resolving?
|
||||
return resolved_value if resolved?
|
||||
|
||||
self.resolved_status = :in_progress
|
||||
self.resolved_value = _resolve(resolver)
|
||||
self.resolved_status = :done
|
||||
resolved_value
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def _resolve(resolver)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def resolved?
|
||||
resolved_status == :done
|
||||
end
|
||||
|
||||
def resolving?
|
||||
resolved_status == :in_progress
|
||||
end
|
||||
|
||||
def circular_error_message
|
||||
"#{data[:tag]} #{data[:seq].inspect} is part of a circular chain"
|
||||
end
|
||||
|
||||
def validation_error_message
|
||||
"#{data[:tag]} #{(data[:scalar].presence || data[:map].presence || data[:seq]).inspect} is not valid"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Ci
|
||||
class Config
|
||||
module Yaml
|
||||
module Tags
|
||||
class Reference < Base
|
||||
MissingReferenceError = Class.new(Tags::TagError)
|
||||
|
||||
def self.tag
|
||||
'!reference'
|
||||
end
|
||||
|
||||
override :valid?
|
||||
def valid?
|
||||
data[:seq].is_a?(Array) &&
|
||||
!data[:seq].empty? &&
|
||||
data[:seq].all? { |identifier| identifier.is_a?(String) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def location
|
||||
data[:seq].to_a.map(&:to_sym)
|
||||
end
|
||||
|
||||
override :_resolve
|
||||
def _resolve(resolver)
|
||||
object = resolver.config.dig(*location)
|
||||
value = resolver.deep_resolve(object)
|
||||
|
||||
raise MissingReferenceError, missing_ref_error_message unless value
|
||||
|
||||
value
|
||||
end
|
||||
|
||||
def missing_ref_error_message
|
||||
"#{data[:tag]} #{data[:seq].inspect} could not be found"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Ci
|
||||
class Config
|
||||
module Yaml
|
||||
module Tags
|
||||
# This class is the entry point for transforming custom YAML tags back
|
||||
# into primitive objects.
|
||||
# Usage: `Resolver.new(a_hash_including_custom_tag_objects).to_hash`
|
||||
#
|
||||
class Resolver
|
||||
attr_reader :config
|
||||
|
||||
def initialize(config)
|
||||
@config = config.deep_dup
|
||||
end
|
||||
|
||||
def to_hash
|
||||
deep_resolve(config)
|
||||
end
|
||||
|
||||
def deep_resolve(object)
|
||||
case object
|
||||
when Array
|
||||
object.map(&method(:resolve_wrapper))
|
||||
when Hash
|
||||
object.deep_transform_values(&method(:resolve_wrapper))
|
||||
else
|
||||
resolve_wrapper(object)
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_wrapper(object)
|
||||
if object.respond_to?(:resolve)
|
||||
object.resolve(self)
|
||||
else
|
||||
object
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -12,8 +12,12 @@ module Gitlab
|
|||
MAX_YAML_SIZE = 1.megabyte
|
||||
MAX_YAML_DEPTH = 100
|
||||
|
||||
def initialize(config)
|
||||
@config = YAML.safe_load(config, [Symbol], [], true)
|
||||
def initialize(config, additional_permitted_classes: [])
|
||||
@config = YAML.safe_load(config,
|
||||
permitted_classes: [Symbol, *additional_permitted_classes],
|
||||
permitted_symbols: [],
|
||||
aliases: true
|
||||
)
|
||||
rescue Psych::Exception => e
|
||||
raise Loader::FormatError, e.message
|
||||
end
|
||||
|
|
|
@ -10,7 +10,7 @@ module Gitlab
|
|||
"**#{value}**"
|
||||
when :data_source
|
||||
value.capitalize
|
||||
when :group
|
||||
when :product_group
|
||||
"`#{value}`"
|
||||
when :introduced_by_url
|
||||
"[Introduced by](#{value})"
|
||||
|
|
|
@ -8,16 +8,26 @@ module Gitlab
|
|||
INTERSECTION_OF_AGGREGATED_METRICS = 'AND'
|
||||
ALLOWED_METRICS_AGGREGATIONS = [UNION_OF_AGGREGATED_METRICS, INTERSECTION_OF_AGGREGATED_METRICS].freeze
|
||||
AGGREGATED_METRICS_PATH = Rails.root.join('lib/gitlab/usage_data_counters/aggregated_metrics/*.yml')
|
||||
UnknownAggregationOperator = Class.new(StandardError)
|
||||
AggregatedMetricError = Class.new(StandardError)
|
||||
UnknownAggregationOperator = Class.new(AggregatedMetricError)
|
||||
UnknownAggregationSource = Class.new(AggregatedMetricError)
|
||||
|
||||
DATABASE_SOURCE = 'database'
|
||||
REDIS_SOURCE = 'redis'
|
||||
|
||||
SOURCES = {
|
||||
DATABASE_SOURCE => Sources::PostgresHll,
|
||||
REDIS_SOURCE => Sources::RedisHll
|
||||
}.freeze
|
||||
|
||||
class Aggregate
|
||||
delegate :calculate_events_union,
|
||||
:weekly_time_range,
|
||||
delegate :weekly_time_range,
|
||||
:monthly_time_range,
|
||||
to: Gitlab::UsageDataCounters::HLLRedisCounter
|
||||
|
||||
def initialize
|
||||
@aggregated_metrics = load_events(AGGREGATED_METRICS_PATH)
|
||||
def initialize(recorded_at)
|
||||
@aggregated_metrics = load_metrics(AGGREGATED_METRICS_PATH)
|
||||
@recorded_at = recorded_at
|
||||
end
|
||||
|
||||
def monthly_data
|
||||
|
@ -30,35 +40,49 @@ module Gitlab
|
|||
|
||||
private
|
||||
|
||||
attr_accessor :aggregated_metrics
|
||||
attr_accessor :aggregated_metrics, :recorded_at
|
||||
|
||||
def aggregated_metrics_data(start_date:, end_date:)
|
||||
aggregated_metrics.each_with_object({}) do |aggregation, weekly_data|
|
||||
aggregated_metrics.each_with_object({}) do |aggregation, data|
|
||||
next if aggregation[:feature_flag] && Feature.disabled?(aggregation[:feature_flag], default_enabled: false, type: :development)
|
||||
|
||||
weekly_data[aggregation[:name]] = calculate_count_for_aggregation(aggregation, start_date: start_date, end_date: end_date)
|
||||
case aggregation[:source]
|
||||
when REDIS_SOURCE
|
||||
data[aggregation[:name]] = calculate_count_for_aggregation(aggregation: aggregation, start_date: start_date, end_date: end_date)
|
||||
when DATABASE_SOURCE
|
||||
next unless Feature.enabled?('database_sourced_aggregated_metrics', default_enabled: false, type: :development)
|
||||
|
||||
data[aggregation[:name]] = calculate_count_for_aggregation(aggregation: aggregation, start_date: start_date, end_date: end_date)
|
||||
else
|
||||
Gitlab::ErrorTracking
|
||||
.track_and_raise_for_dev_exception(UnknownAggregationSource.new("Aggregation source: '#{aggregation[:source]}' must be included in #{SOURCES.keys}"))
|
||||
|
||||
data[aggregation[:name]] = Gitlab::Utils::UsageData::FALLBACK
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_count_for_aggregation(aggregation, start_date:, end_date:)
|
||||
def calculate_count_for_aggregation(aggregation:, start_date:, end_date:)
|
||||
source = SOURCES[aggregation[:source]]
|
||||
|
||||
case aggregation[:operator]
|
||||
when UNION_OF_AGGREGATED_METRICS
|
||||
calculate_events_union(event_names: aggregation[:events], start_date: start_date, end_date: end_date)
|
||||
source.calculate_metrics_union(metric_names: aggregation[:events], start_date: start_date, end_date: end_date, recorded_at: recorded_at)
|
||||
when INTERSECTION_OF_AGGREGATED_METRICS
|
||||
calculate_events_intersections(event_names: aggregation[:events], start_date: start_date, end_date: end_date)
|
||||
calculate_metrics_intersections(source: source, metric_names: aggregation[:events], start_date: start_date, end_date: end_date)
|
||||
else
|
||||
Gitlab::ErrorTracking
|
||||
.track_and_raise_for_dev_exception(UnknownAggregationOperator.new("Events should be aggregated with one of operators #{ALLOWED_METRICS_AGGREGATIONS}"))
|
||||
Gitlab::Utils::UsageData::FALLBACK
|
||||
end
|
||||
rescue Gitlab::UsageDataCounters::HLLRedisCounter::EventError => error
|
||||
rescue Gitlab::UsageDataCounters::HLLRedisCounter::EventError, AggregatedMetricError => error
|
||||
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(error)
|
||||
Gitlab::Utils::UsageData::FALLBACK
|
||||
end
|
||||
|
||||
# calculate intersection of 'n' sets based on inclusion exclusion principle https://en.wikipedia.org/wiki/Inclusion%E2%80%93exclusion_principle
|
||||
# this method will be extracted to dedicated module with https://gitlab.com/gitlab-org/gitlab/-/issues/273391
|
||||
def calculate_events_intersections(event_names:, start_date:, end_date:, subset_powers_cache: Hash.new({}))
|
||||
def calculate_metrics_intersections(source:, metric_names:, start_date:, end_date:, subset_powers_cache: Hash.new({}))
|
||||
# calculate power of intersection of all given metrics from inclusion exclusion principle
|
||||
# |A + B + C| = (|A| + |B| + |C|) - (|A & B| + |A & C| + .. + |C & D|) + (|A & B & C|) =>
|
||||
# |A & B & C| = - (|A| + |B| + |C|) + (|A & B| + |A & C| + .. + |C & D|) + |A + B + C|
|
||||
|
@ -66,12 +90,12 @@ module Gitlab
|
|||
# |A & B & C & D| = (|A| + |B| + |C| + |D|) - (|A & B| + |A & C| + .. + |C & D|) + (|A & B & C| + |B & C & D|) - |A + B + C + D|
|
||||
|
||||
# calculate each components of equation except for the last one |A & B & C & D| = (|A| + |B| + |C| + |D|) - (|A & B| + |A & C| + .. + |C & D|) + (|A & B & C| + |B & C & D|) - ...
|
||||
subset_powers_data = subsets_intersection_powers(event_names, start_date, end_date, subset_powers_cache)
|
||||
subset_powers_data = subsets_intersection_powers(source, metric_names, start_date, end_date, subset_powers_cache)
|
||||
|
||||
# calculate last component of the equation |A & B & C & D| = .... - |A + B + C + D|
|
||||
power_of_union_of_all_events = begin
|
||||
subset_powers_cache[event_names.size][event_names.join('_+_')] ||= \
|
||||
calculate_events_union(event_names: event_names, start_date: start_date, end_date: end_date)
|
||||
power_of_union_of_all_metrics = begin
|
||||
subset_powers_cache[metric_names.size][metric_names.join('_+_')] ||= \
|
||||
source.calculate_metrics_union(metric_names: metric_names, start_date: start_date, end_date: end_date, recorded_at: recorded_at)
|
||||
end
|
||||
|
||||
# in order to determine if part of equation (|A & B & C|, |A & B & C & D|), that represents the intersection that we need to calculate,
|
||||
|
@ -86,7 +110,7 @@ module Gitlab
|
|||
sum_of_all_subset_powers = sum_subset_powers(subset_powers_data, subset_powers_size_even)
|
||||
|
||||
# add last component of the equation |A & B & C & D| = sum_of_all_subset_powers - |A + B + C + D|
|
||||
sum_of_all_subset_powers + (subset_powers_size_even ? power_of_union_of_all_events : -power_of_union_of_all_events)
|
||||
sum_of_all_subset_powers + (subset_powers_size_even ? power_of_union_of_all_metrics : -power_of_union_of_all_metrics)
|
||||
end
|
||||
|
||||
def sum_subset_powers(subset_powers_data, subset_powers_size_even)
|
||||
|
@ -97,29 +121,29 @@ module Gitlab
|
|||
(subset_powers_size_even ? -1 : 1) * sum_without_sign
|
||||
end
|
||||
|
||||
def subsets_intersection_powers(event_names, start_date, end_date, subset_powers_cache)
|
||||
subset_sizes = (1..(event_names.size - 1))
|
||||
def subsets_intersection_powers(source, metric_names, start_date, end_date, subset_powers_cache)
|
||||
subset_sizes = (1...metric_names.size)
|
||||
|
||||
subset_sizes.map do |subset_size|
|
||||
if subset_size > 1
|
||||
# calculate sum of powers of intersection between each subset (with given size) of metrics: #|A + B + C + D| = ... - (|A & B| + |A & C| + .. + |C & D|)
|
||||
event_names.combination(subset_size).sum do |events_subset|
|
||||
subset_powers_cache[subset_size][events_subset.join('_&_')] ||= \
|
||||
calculate_events_intersections(event_names: events_subset, start_date: start_date, end_date: end_date, subset_powers_cache: subset_powers_cache)
|
||||
metric_names.combination(subset_size).sum do |metrics_subset|
|
||||
subset_powers_cache[subset_size][metrics_subset.join('_&_')] ||=
|
||||
calculate_metrics_intersections(source: source, metric_names: metrics_subset, start_date: start_date, end_date: end_date, subset_powers_cache: subset_powers_cache)
|
||||
end
|
||||
else
|
||||
# calculate sum of powers of each set (metric) alone #|A + B + C + D| = (|A| + |B| + |C| + |D|) - ...
|
||||
event_names.sum do |event|
|
||||
subset_powers_cache[subset_size][event] ||= \
|
||||
calculate_events_union(event_names: event, start_date: start_date, end_date: end_date)
|
||||
metric_names.sum do |metric|
|
||||
subset_powers_cache[subset_size][metric] ||= \
|
||||
source.calculate_metrics_union(metric_names: metric, start_date: start_date, end_date: end_date, recorded_at: recorded_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def load_events(wildcard)
|
||||
Dir[wildcard].each_with_object([]) do |path, events|
|
||||
events.push(*load_yaml_from_path(path))
|
||||
def load_metrics(wildcard)
|
||||
Dir[wildcard].each_with_object([]) do |path, metrics|
|
||||
metrics.push(*load_yaml_from_path(path))
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Usage
|
||||
module Metrics
|
||||
module Aggregates
|
||||
module Sources
|
||||
class PostgresHll
|
||||
class << self
|
||||
def calculate_metrics_union(metric_names:, start_date:, end_date:, recorded_at:)
|
||||
time_period = start_date && end_date ? (start_date..end_date) : nil
|
||||
|
||||
Array(metric_names).each_with_object(Gitlab::Database::PostgresHll::Buckets.new) do |event, buckets|
|
||||
json = read_aggregated_metric(metric_name: event, time_period: time_period, recorded_at: recorded_at)
|
||||
raise UnionNotAvailable, "Union data not available for #{metric_names}" unless json
|
||||
|
||||
buckets.merge_hash!(Gitlab::Json.parse(json))
|
||||
end.estimated_distinct_count
|
||||
end
|
||||
|
||||
def save_aggregated_metrics(metric_name:, time_period:, recorded_at_timestamp:, data:)
|
||||
unless data.is_a? ::Gitlab::Database::PostgresHll::Buckets
|
||||
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(StandardError.new("Unsupported data type: #{data.class}"))
|
||||
return
|
||||
end
|
||||
|
||||
# Usage Ping report generation for gitlab.com is very long running process
|
||||
# to make sure that saved keys are available at the end of report generation process
|
||||
# lets use triple max generation time
|
||||
keys_expiration = ::Gitlab::UsageData::MAX_GENERATION_TIME_FOR_SAAS * 3
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
redis.set(
|
||||
redis_key(metric_name: metric_name, time_period: time_period&.values&.first, recorded_at: recorded_at_timestamp),
|
||||
data.to_json,
|
||||
ex: keys_expiration
|
||||
)
|
||||
end
|
||||
rescue ::Redis::CommandError => e
|
||||
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def read_aggregated_metric(metric_name:, time_period:, recorded_at:)
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
redis.get(redis_key(metric_name: metric_name, time_period: time_period, recorded_at: recorded_at))
|
||||
end
|
||||
end
|
||||
|
||||
def redis_key(metric_name:, time_period:, recorded_at:)
|
||||
# add timestamp at the end of the key to avoid stale keys if
|
||||
# usage ping job is retried
|
||||
"#{metric_name}_#{time_period_to_human_name(time_period)}-#{recorded_at.to_i}"
|
||||
end
|
||||
|
||||
def time_period_to_human_name(time_period)
|
||||
return Gitlab::Utils::UsageData::ALL_TIME_PERIOD_HUMAN_NAME if time_period.blank?
|
||||
|
||||
start_date = time_period.first.to_date
|
||||
end_date = time_period.last.to_date
|
||||
|
||||
if (end_date - start_date).to_i > 7
|
||||
Gitlab::Utils::UsageData::MONTHLY_PERIOD_HUMAN_NAME
|
||||
else
|
||||
Gitlab::Utils::UsageData::WEEKLY_PERIOD_HUMAN_NAME
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Usage
|
||||
module Metrics
|
||||
module Aggregates
|
||||
module Sources
|
||||
UnionNotAvailable = Class.new(AggregatedMetricError)
|
||||
|
||||
class RedisHll
|
||||
def self.calculate_metrics_union(metric_names:, start_date:, end_date:, recorded_at: nil)
|
||||
union = Gitlab::UsageDataCounters::HLLRedisCounter
|
||||
.calculate_events_union(event_names: metric_names, start_date: start_date, end_date: end_date)
|
||||
|
||||
return union if union >= 0
|
||||
|
||||
raise UnionNotAvailable, "Union data not available for #{metric_names}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -13,6 +13,7 @@
|
|||
module Gitlab
|
||||
class UsageData
|
||||
DEPRECATED_VALUE = -1000
|
||||
MAX_GENERATION_TIME_FOR_SAAS = 40.hours
|
||||
|
||||
CE_MEMOIZED_VALUES = %i(
|
||||
issue_minimum_id
|
||||
|
@ -754,7 +755,7 @@ module Gitlab
|
|||
private
|
||||
|
||||
def aggregated_metrics
|
||||
@aggregated_metrics ||= ::Gitlab::Usage::Metrics::Aggregates::Aggregate.new
|
||||
@aggregated_metrics ||= ::Gitlab::Usage::Metrics::Aggregates::Aggregate.new(recorded_at)
|
||||
end
|
||||
|
||||
def event_monthly_active_users(date_range)
|
||||
|
|
|
@ -4,21 +4,28 @@
|
|||
# - "AND": counts unique elements that were observed triggering all of following events
|
||||
# events: list of events names to aggregate into metric. All events in this list must have the same 'redis_slot' and 'aggregation' attributes
|
||||
# see from lib/gitlab/usage_data_counters/known_events/ for the list of valid events.
|
||||
# source: defines which datasource will be used to locate events that should be included in aggregated metric. Valid values are:
|
||||
# - database
|
||||
# - redis
|
||||
# feature_flag: name of development feature flag that will be checked before metrics aggregation is performed.
|
||||
# Corresponding feature flag should have `default_enabled` attribute set to `false`.
|
||||
# This attribute is OPTIONAL and can be omitted, when `feature_flag` is missing no feature flag will be checked.
|
||||
---
|
||||
- name: compliance_features_track_unique_visits_union
|
||||
operator: OR
|
||||
source: redis
|
||||
events: ['g_compliance_audit_events', 'g_compliance_dashboard', 'i_compliance_audit_events', 'a_compliance_audit_events_api', 'i_compliance_credential_inventory']
|
||||
- name: product_analytics_test_metrics_union
|
||||
operator: OR
|
||||
source: redis
|
||||
events: ['i_search_total', 'i_search_advanced', 'i_search_paid']
|
||||
- name: product_analytics_test_metrics_intersection
|
||||
operator: AND
|
||||
source: redis
|
||||
events: ['i_search_total', 'i_search_advanced', 'i_search_paid']
|
||||
- name: incident_management_alerts_total_unique_counts
|
||||
operator: OR
|
||||
source: redis
|
||||
events: [
|
||||
'incident_management_alert_status_changed',
|
||||
'incident_management_alert_assigned',
|
||||
|
@ -27,6 +34,7 @@
|
|||
]
|
||||
- name: incident_management_incidents_total_unique_counts
|
||||
operator: OR
|
||||
source: redis
|
||||
events: [
|
||||
'incident_management_incident_created',
|
||||
'incident_management_incident_reopened',
|
||||
|
|
|
@ -80,27 +80,6 @@ module Gitlab
|
|||
DISTRIBUTED_HLL_FALLBACK
|
||||
end
|
||||
|
||||
def save_aggregated_metrics(metric_name:, time_period:, recorded_at_timestamp:, data:)
|
||||
unless data.is_a? ::Gitlab::Database::PostgresHll::Buckets
|
||||
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(StandardError.new("Unsupported data type: #{data.class}"))
|
||||
return
|
||||
end
|
||||
|
||||
# the longest recorded usage ping generation time for gitlab.com
|
||||
# was below 40 hours, there is added error margin of 20 h
|
||||
usage_ping_generation_period = 80.hours
|
||||
|
||||
# add timestamp at the end of the key to avoid stale keys if
|
||||
# usage ping job is retried
|
||||
redis_key = "#{metric_name}_#{time_period_to_human_name(time_period)}-#{recorded_at_timestamp}"
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
redis.set(redis_key, data.to_json, ex: usage_ping_generation_period)
|
||||
end
|
||||
rescue ::Redis::CommandError => e
|
||||
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
|
||||
end
|
||||
|
||||
def sum(relation, column, batch_size: nil, start: nil, finish: nil)
|
||||
Gitlab::Database::BatchCount.batch_sum(relation, column, batch_size: batch_size, start: start, finish: finish)
|
||||
rescue ActiveRecord::StatementInvalid
|
||||
|
@ -152,20 +131,6 @@ module Gitlab
|
|||
Gitlab::UsageDataCounters::HLLRedisCounter.track_event(event_name.to_s, values: values)
|
||||
end
|
||||
|
||||
def time_period_to_human_name(time_period)
|
||||
return ALL_TIME_PERIOD_HUMAN_NAME if time_period.blank?
|
||||
|
||||
date_range = time_period.values[0]
|
||||
start_date = date_range.first.to_date
|
||||
end_date = date_range.last.to_date
|
||||
|
||||
if (end_date - start_date).to_i > 7
|
||||
MONTHLY_PERIOD_HUMAN_NAME
|
||||
else
|
||||
WEEKLY_PERIOD_HUMAN_NAME
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prometheus_client(verify:)
|
||||
|
|
|
@ -29389,6 +29389,9 @@ msgstr ""
|
|||
msgid "There was an error fetching the Node's Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "There was an error fetching the Search Counts"
|
||||
msgstr ""
|
||||
|
||||
msgid "There was an error fetching the deploy freezes."
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -177,9 +177,9 @@
|
|||
"docdash": "^1.0.2",
|
||||
"eslint": "7.19.0",
|
||||
"eslint-import-resolver-jest": "3.0.0",
|
||||
"eslint-import-resolver-webpack": "0.12.1",
|
||||
"eslint-plugin-jasmine": "4.1.0",
|
||||
"eslint-plugin-no-jquery": "2.3.1",
|
||||
"eslint-import-resolver-webpack": "0.13.0",
|
||||
"eslint-plugin-jasmine": "4.1.2",
|
||||
"eslint-plugin-no-jquery": "2.5.0",
|
||||
"gettext-extractor": "^3.4.3",
|
||||
"gettext-extractor-vue": "^4.0.2",
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
|
|
|
@ -4,7 +4,7 @@ module QA
|
|||
module Page
|
||||
module Search
|
||||
class Results < QA::Page::Base
|
||||
view 'app/views/search/_category.html.haml' do
|
||||
view 'app/assets/javascripts/search/topbar/constants.js' do
|
||||
element :code_tab
|
||||
element :projects_tab
|
||||
end
|
||||
|
|
|
@ -1371,7 +1371,6 @@ RSpec.describe Projects::MergeRequestsController do
|
|||
describe 'GET test_reports' do
|
||||
let_it_be(:merge_request) do
|
||||
create(:merge_request,
|
||||
:with_diffs,
|
||||
:with_merge_request_pipeline,
|
||||
target_project: project,
|
||||
source_project: project
|
||||
|
@ -1482,7 +1481,6 @@ RSpec.describe Projects::MergeRequestsController do
|
|||
describe 'GET accessibility_reports' do
|
||||
let_it_be(:merge_request) do
|
||||
create(:merge_request,
|
||||
:with_diffs,
|
||||
:with_merge_request_pipeline,
|
||||
target_project: project,
|
||||
source_project: project
|
||||
|
@ -1603,7 +1601,6 @@ RSpec.describe Projects::MergeRequestsController do
|
|||
describe 'GET codequality_reports' do
|
||||
let_it_be(:merge_request) do
|
||||
create(:merge_request,
|
||||
:with_diffs,
|
||||
:with_merge_request_pipeline,
|
||||
target_project: project,
|
||||
source_project: project
|
||||
|
|
|
@ -5,6 +5,7 @@ require('spec_helper')
|
|||
RSpec.describe ProjectsController do
|
||||
include ExternalAuthorizationServiceHelpers
|
||||
include ProjectForksHelper
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
let_it_be(:project, reload: true) { create(:project, service_desk_enabled: false) }
|
||||
let_it_be(:public_project) { create(:project, :public) }
|
||||
|
@ -324,14 +325,39 @@ RSpec.describe ProjectsController do
|
|||
end
|
||||
end
|
||||
|
||||
context "redirection from http://someproject.git" do
|
||||
it 'redirects to project page (format.html)' do
|
||||
project = create(:project, :public)
|
||||
context 'redirection from http://someproject.git' do
|
||||
where(:user_type, :project_visibility, :expected_redirect) do
|
||||
:anonymous | :public | :redirect_to_project
|
||||
:anonymous | :internal | :redirect_to_signup
|
||||
:anonymous | :private | :redirect_to_signup
|
||||
|
||||
get :show, params: { namespace_id: project.namespace, id: project }, format: :git
|
||||
:signed_in | :public | :redirect_to_project
|
||||
:signed_in | :internal | :redirect_to_project
|
||||
:signed_in | :private | nil
|
||||
|
||||
expect(response).to have_gitlab_http_status(:found)
|
||||
expect(response).to redirect_to(namespace_project_path)
|
||||
:member | :public | :redirect_to_project
|
||||
:member | :internal | :redirect_to_project
|
||||
:member | :private | :redirect_to_project
|
||||
end
|
||||
|
||||
with_them do
|
||||
let(:redirect_to_signup) { new_user_session_path }
|
||||
let(:redirect_to_project) { project_path(project) }
|
||||
|
||||
let(:expected_status) { expected_redirect ? :found : :not_found }
|
||||
|
||||
before do
|
||||
project.update!(visibility: project_visibility.to_s)
|
||||
project.team.add_user(user, :guest) if user_type == :member
|
||||
sign_in(user) unless user_type == :anonymous
|
||||
end
|
||||
|
||||
it 'returns the expected status' do
|
||||
get :show, params: { namespace_id: project.namespace, id: project }, format: :git
|
||||
|
||||
expect(response).to have_gitlab_http_status(expected_status)
|
||||
expect(response).to redirect_to(send(expected_redirect)) if expected_status == :found
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -21,9 +21,6 @@ FactoryBot.define do
|
|||
|
||||
merge_status { "can_be_merged" }
|
||||
|
||||
trait :with_diffs do
|
||||
end
|
||||
|
||||
trait :jira_title do
|
||||
title { generate(:jira_title) }
|
||||
end
|
||||
|
@ -306,7 +303,7 @@ FactoryBot.define do
|
|||
factory :closed_merge_request, traits: [:closed]
|
||||
factory :reopened_merge_request, traits: [:opened]
|
||||
factory :invalid_merge_request, traits: [:invalid]
|
||||
factory :merge_request_with_diffs, traits: [:with_diffs]
|
||||
factory :merge_request_with_diffs
|
||||
factory :merge_request_with_diff_notes do
|
||||
after(:create) do |mr|
|
||||
create(:diff_note_on_merge_request, noteable: mr, project: mr.source_project)
|
||||
|
|
|
@ -28,7 +28,7 @@ RSpec.describe 'Global search' do
|
|||
create_list(:issue, 2, project: project, title: 'initial')
|
||||
end
|
||||
|
||||
it "has a pagination" do
|
||||
it "has a pagination", :js do
|
||||
submit_search('initial')
|
||||
select_search_scope('Issues')
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'IDE merge request', :js do
|
||||
let(:merge_request) { create(:merge_request, :with_diffs, :simple, source_project: project) }
|
||||
let(:merge_request) { create(:merge_request, :simple, source_project: project) }
|
||||
let(:project) { create(:project, :public, :repository) }
|
||||
let(:user) { project.owner }
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inline do
|
||||
let(:merge_request) { create(:merge_request, :with_diffs, :simple, source_project: project) }
|
||||
let(:merge_request) { create(:merge_request, :simple, source_project: project) }
|
||||
let(:project) { create(:project, :public, :repository) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'User reverts a merge request', :js do
|
||||
let(:merge_request) { create(:merge_request, :with_diffs, :simple, source_project: project) }
|
||||
let(:merge_request) { create(:merge_request, :simple, source_project: project) }
|
||||
let(:project) { create(:project, :public, :repository) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ RSpec.describe 'Merge Requests > User filters by milestones', :js do
|
|||
let(:milestone) { create(:milestone, project: project) }
|
||||
|
||||
before do
|
||||
create(:merge_request, :with_diffs, source_project: project)
|
||||
create(:merge_request, source_project: project)
|
||||
create(:merge_request, :simple, source_project: project, milestone: milestone)
|
||||
|
||||
sign_in(user)
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'User searches their settings', :js do
|
||||
let(:user) { create(:user) }
|
||||
let(:search_input_placeholder) { 'Search settings' }
|
||||
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
context 'when search_settings_in_page feature flag is on' do
|
||||
it 'allows searching in the user profile page' do
|
||||
search_term = 'Public Avatar'
|
||||
hidden_section_name = 'Main settings'
|
||||
|
||||
visit profile_path
|
||||
fill_in search_input_placeholder, with: search_term
|
||||
|
||||
expect(page).to have_content(search_term)
|
||||
expect(page).not_to have_content(hidden_section_name)
|
||||
end
|
||||
|
||||
it 'allows searching in the user applications page' do
|
||||
visit applications_profile_path
|
||||
|
||||
expect(page.find_field(placeholder: search_input_placeholder)).not_to be_disabled
|
||||
end
|
||||
|
||||
it 'allows searching in the user preferences page' do
|
||||
search_term = 'Syntax highlighting theme'
|
||||
hidden_section_name = 'Behavior'
|
||||
|
||||
visit profile_preferences_path
|
||||
fill_in search_input_placeholder, with: search_term
|
||||
|
||||
expect(page).to have_content(search_term)
|
||||
expect(page).not_to have_content(hidden_section_name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when search_settings_in_page feature flag is off' do
|
||||
before do
|
||||
stub_feature_flags(search_settings_in_page: false)
|
||||
visit(profile_path)
|
||||
end
|
||||
|
||||
it 'does not allow searching in the user settings pages' do
|
||||
expect(page).not_to have_content(search_input_placeholder)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -95,7 +95,7 @@ RSpec.describe 'issuable templates', :js do
|
|||
let(:bug_template_content) { 'this is merge request bug template' }
|
||||
let(:template_override_warning) { 'Applying a template will replace the existing issue description.' }
|
||||
let(:updated_description) { 'updated merge request description' }
|
||||
let(:merge_request) { create(:merge_request, :with_diffs, source_project: project) }
|
||||
let(:merge_request) { create(:merge_request, source_project: project) }
|
||||
|
||||
before do
|
||||
project.repository.create_file(
|
||||
|
@ -154,7 +154,7 @@ RSpec.describe 'issuable templates', :js do
|
|||
let(:template_content) { 'this is a test "feature-proposal" template' }
|
||||
let(:fork_user) { create(:user) }
|
||||
let(:forked_project) { fork_project(project, fork_user, repository: true) }
|
||||
let(:merge_request) { create(:merge_request, :with_diffs, source_project: forked_project, target_project: project) }
|
||||
let(:merge_request) { create(:merge_request, source_project: forked_project, target_project: project) }
|
||||
|
||||
before do
|
||||
sign_out(:user)
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'User searches for code' do
|
||||
RSpec.describe 'User searches for code', :js do
|
||||
let(:user) { create(:user) }
|
||||
let(:project) { create(:project, :repository, namespace: user.namespace) }
|
||||
|
||||
|
@ -16,6 +16,7 @@ RSpec.describe 'User searches for code' do
|
|||
visit(project_path(project))
|
||||
|
||||
submit_search('application.js')
|
||||
|
||||
select_search_scope('Code')
|
||||
|
||||
expect(page).to have_selector('.results', text: 'application.js')
|
||||
|
@ -24,7 +25,7 @@ RSpec.describe 'User searches for code' do
|
|||
expect(page).to have_link('application.js', href: /master\/files\/js\/application.js/)
|
||||
end
|
||||
|
||||
context 'when on a project page', :js do
|
||||
context 'when on a project page' do
|
||||
before do
|
||||
visit(search_path)
|
||||
find('[data-testid="project-filter"]').click
|
||||
|
@ -48,7 +49,7 @@ RSpec.describe 'User searches for code' do
|
|||
expect(current_url).to match(/master\/.gitignore#L3/)
|
||||
end
|
||||
|
||||
it 'search mutiple words with refs switching' do
|
||||
it 'search multiple words with refs switching' do
|
||||
expected_result = 'Use `snake_case` for naming files'
|
||||
search = 'for naming files'
|
||||
|
||||
|
@ -67,7 +68,7 @@ RSpec.describe 'User searches for code' do
|
|||
end
|
||||
end
|
||||
|
||||
context 'search code within refs', :js do
|
||||
context 'search code within refs' do
|
||||
let(:ref_name) { 'v1.0.0' }
|
||||
|
||||
before do
|
||||
|
@ -85,9 +86,9 @@ RSpec.describe 'User searches for code' do
|
|||
expect(find('.js-project-refs-dropdown')).to have_text(ref_name)
|
||||
end
|
||||
|
||||
# this example is use to test the desgine that the refs is not
|
||||
# only repersent the branch as well as the tags.
|
||||
it 'ref swither list all the branchs and tags' do
|
||||
# this example is use to test the design that the refs is not
|
||||
# only represent the branch as well as the tags.
|
||||
it 'ref switcher list all the branches and tags' do
|
||||
find('.js-project-refs-dropdown').click
|
||||
expect(find('.dropdown-page-one .dropdown-content')).to have_link('sha-starting-with-large-number')
|
||||
expect(find('.dropdown-page-one .dropdown-content')).to have_link('v1.0.0')
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'User searches for comments' do
|
||||
RSpec.describe 'User searches for comments', :js do
|
||||
let(:project) { create(:project, :repository) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'User searches for users' do
|
||||
RSpec.describe 'User searches for users', :js do
|
||||
let(:user1) { create(:user, username: 'gob_bluth', name: 'Gob Bluth') }
|
||||
let(:user2) { create(:user, username: 'michael_bluth', name: 'Michael Bluth') }
|
||||
let(:user3) { create(:user, username: 'gob_2018', name: 'George Oscar Bluth') }
|
||||
|
@ -12,7 +12,7 @@ RSpec.describe 'User searches for users' do
|
|||
end
|
||||
|
||||
context 'when on the dashboard' do
|
||||
it 'finds the user', :js do
|
||||
it 'finds the user' do
|
||||
visit dashboard_projects_path
|
||||
|
||||
submit_search('gob')
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'Search Snippets' do
|
||||
RSpec.describe 'Search Snippets', :js do
|
||||
it 'user searches for snippets by title' do
|
||||
public_snippet = create(:personal_snippet, :public, title: 'Beginning and Middle')
|
||||
private_snippet = create(:personal_snippet, :private, title: 'Middle and End')
|
||||
|
|
|
@ -22,7 +22,6 @@ RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type:
|
|||
let(:merge_request) do
|
||||
create(
|
||||
:merge_request,
|
||||
:with_diffs,
|
||||
source_project: project,
|
||||
target_project: project,
|
||||
description: description
|
||||
|
|
|
@ -8,7 +8,7 @@ RSpec.describe Projects::MergeRequests::DiffsController, '(JavaScript fixtures)'
|
|||
let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
|
||||
let(:project) { create(:project, :repository, namespace: namespace, path: 'merge-requests-project') }
|
||||
let(:user) { project.owner }
|
||||
let(:merge_request) { create(:merge_request, :with_diffs, source_project: project, target_project: project, description: '- [ ] Task List Item') }
|
||||
let(:merge_request) { create(:merge_request, source_project: project, target_project: project, description: '- [ ] Task List Item') }
|
||||
let(:path) { "files/ruby/popen.rb" }
|
||||
let(:position) do
|
||||
build(:text_diff_position, :added,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import { GlSearchBoxByType } from '@gitlab/ui';
|
||||
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
|
||||
import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue';
|
||||
import { createStore } from '~/frequent_items/store';
|
||||
|
@ -15,6 +16,8 @@ describe('FrequentItemsSearchInputComponent', () => {
|
|||
propsData: { namespace },
|
||||
});
|
||||
|
||||
const findSearchBoxByType = () => wrapper.find(GlSearchBoxByType);
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore({ dropdownType: 'project' });
|
||||
jest.spyOn(store, 'dispatch').mockImplementation(() => {});
|
||||
|
@ -32,26 +35,13 @@ describe('FrequentItemsSearchInputComponent', () => {
|
|||
vm.$destroy();
|
||||
});
|
||||
|
||||
describe('methods', () => {
|
||||
describe('setFocus', () => {
|
||||
it('should set focus to search input', () => {
|
||||
jest.spyOn(vm.$refs.search, 'focus').mockImplementation(() => {});
|
||||
|
||||
vm.setFocus();
|
||||
|
||||
expect(vm.$refs.search.focus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
it('should render component element', () => {
|
||||
expect(wrapper.classes()).toContain('search-input-container');
|
||||
expect(wrapper.find('input.form-control').exists()).toBe(true);
|
||||
expect(wrapper.find('.search-icon').exists()).toBe(true);
|
||||
expect(wrapper.find('input.form-control').attributes('placeholder')).toBe(
|
||||
'Search your projects',
|
||||
);
|
||||
expect(findSearchBoxByType().exists()).toBe(true);
|
||||
expect(findSearchBoxByType().attributes()).toMatchObject({
|
||||
placeholder: 'Search your projects',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -62,9 +52,7 @@ describe('FrequentItemsSearchInputComponent', () => {
|
|||
|
||||
const value = 'my project';
|
||||
|
||||
const input = wrapper.find('input');
|
||||
input.setValue(value);
|
||||
input.trigger('input');
|
||||
findSearchBoxByType().vm.$emit('input', value);
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`pages/search/show/refresh_counts fetches and displays search counts 1`] = `
|
||||
"<div class=\\"badge\\">22</div>
|
||||
<div class=\\"badge js-search-count\\" data-url=\\"http://test.host/search/count?search=lorem+ipsum&project_id=3&scope=issues\\">4</div>
|
||||
<div class=\\"badge js-search-count\\" data-url=\\"http://test.host/search/count?search=lorem+ipsum&project_id=3&scope=merge_requests\\">5</div>"
|
||||
`;
|
|
@ -1,38 +0,0 @@
|
|||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { TEST_HOST } from 'helpers/test_constants';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import refreshCounts from '~/pages/search/show/refresh_counts';
|
||||
|
||||
const URL = `${TEST_HOST}/search/count?search=lorem+ipsum&project_id=3`;
|
||||
const urlWithScope = (scope) => `${URL}&scope=${scope}`;
|
||||
const counts = [
|
||||
{ scope: 'issues', count: 4 },
|
||||
{ scope: 'merge_requests', count: 5 },
|
||||
];
|
||||
const fixture = `<div class="badge">22</div>
|
||||
<div class="badge js-search-count hidden" data-url="${urlWithScope('issues')}"></div>
|
||||
<div class="badge js-search-count hidden" data-url="${urlWithScope('merge_requests')}"></div>`;
|
||||
|
||||
describe('pages/search/show/refresh_counts', () => {
|
||||
let mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(axios);
|
||||
setFixtures(fixture);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('fetches and displays search counts', () => {
|
||||
counts.forEach(({ scope, count }) => {
|
||||
mock.onGet(urlWithScope(scope)).reply(200, { count });
|
||||
});
|
||||
|
||||
// assert before act behavior
|
||||
return refreshCounts().then(() => {
|
||||
expect(document.body.innerHTML).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -61,3 +61,28 @@ export const MOCK_SORT_OPTIONS = [
|
|||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_SEARCH_COUNTS_INPUT = {
|
||||
scopeTabs: ['issues', 'snippet_titles', 'merge_requests'],
|
||||
activeCount: '15',
|
||||
};
|
||||
|
||||
export const MOCK_SEARCH_COUNT = { scope: 'issues', count: '15' };
|
||||
|
||||
export const MOCK_SEARCH_COUNTS_SUCCESS = [
|
||||
{ scope: 'issues', count: '15' },
|
||||
{ scope: 'snippet_titles', count: '15' },
|
||||
{ scope: 'merge_requests', count: '15' },
|
||||
];
|
||||
|
||||
export const MOCK_SEARCH_COUNTS = [
|
||||
{ scope: 'issues', count: '15' },
|
||||
{ scope: 'snippet_titles', count: '5' },
|
||||
{ scope: 'merge_requests', count: '1' },
|
||||
];
|
||||
|
||||
export const MOCK_SCOPE_TABS = [
|
||||
{ scope: 'issues', title: 'Issues', count: '15' },
|
||||
{ scope: 'snippet_titles', title: 'Titles and Descriptions', count: '5' },
|
||||
{ scope: 'merge_requests', title: 'Merge requests', count: '1' },
|
||||
];
|
||||
|
|
|
@ -7,7 +7,15 @@ import * as urlUtils from '~/lib/utils/url_utility';
|
|||
import createState from '~/search/store/state';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import createFlash from '~/flash';
|
||||
import { MOCK_QUERY, MOCK_GROUPS, MOCK_PROJECT, MOCK_PROJECTS } from '../mock_data';
|
||||
import {
|
||||
MOCK_QUERY,
|
||||
MOCK_GROUPS,
|
||||
MOCK_PROJECT,
|
||||
MOCK_PROJECTS,
|
||||
MOCK_SEARCH_COUNT,
|
||||
MOCK_SEARCH_COUNTS_SUCCESS,
|
||||
MOCK_SEARCH_COUNTS_INPUT,
|
||||
} from '../mock_data';
|
||||
|
||||
jest.mock('~/flash');
|
||||
jest.mock('~/lib/utils/url_utility', () => ({
|
||||
|
@ -37,19 +45,21 @@ describe('Global Search Store Actions', () => {
|
|||
});
|
||||
|
||||
describe.each`
|
||||
action | axiosMock | type | expectedMutations | callback
|
||||
${actions.fetchGroups} | ${{ method: 'onGet', code: 200, res: MOCK_GROUPS }} | ${'success'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_SUCCESS, payload: MOCK_GROUPS }]} | ${noCallback}
|
||||
${actions.fetchGroups} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_ERROR }]} | ${flashCallback}
|
||||
${actions.fetchProjects} | ${{ method: 'onGet', code: 200, res: MOCK_PROJECTS }} | ${'success'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_SUCCESS, payload: MOCK_PROJECTS }]} | ${noCallback}
|
||||
${actions.fetchProjects} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_ERROR }]} | ${flashCallback}
|
||||
`(`axios calls`, ({ action, axiosMock, type, expectedMutations, callback }) => {
|
||||
action | axiosMock | payload | type | expectedMutations | callback
|
||||
${actions.fetchGroups} | ${{ method: 'onGet', code: 200, res: MOCK_GROUPS }} | ${null} | ${'success'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_SUCCESS, payload: MOCK_GROUPS }]} | ${noCallback}
|
||||
${actions.fetchGroups} | ${{ method: 'onGet', code: 500, res: null }} | ${null} | ${'error'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_ERROR }]} | ${flashCallback}
|
||||
${actions.fetchProjects} | ${{ method: 'onGet', code: 200, res: MOCK_PROJECTS }} | ${null} | ${'success'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_SUCCESS, payload: MOCK_PROJECTS }]} | ${noCallback}
|
||||
${actions.fetchProjects} | ${{ method: 'onGet', code: 500, res: null }} | ${null} | ${'error'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_ERROR }]} | ${flashCallback}
|
||||
${actions.fetchSearchCounts} | ${{ method: 'onGet', code: 200, res: MOCK_SEARCH_COUNT }} | ${MOCK_SEARCH_COUNTS_INPUT} | ${'success'} | ${[{ type: types.REQUEST_SEARCH_COUNTS, payload: MOCK_SEARCH_COUNTS_INPUT }, { type: types.RECEIVE_SEARCH_COUNTS_SUCCESS, payload: MOCK_SEARCH_COUNTS_SUCCESS }]} | ${noCallback}
|
||||
${actions.fetchSearchCounts} | ${{ method: 'onGet', code: 500, res: null }} | ${MOCK_SEARCH_COUNTS_INPUT} | ${'error'} | ${[{ type: types.REQUEST_SEARCH_COUNTS, payload: MOCK_SEARCH_COUNTS_INPUT }]} | ${flashCallback}
|
||||
`(`axios calls`, ({ action, axiosMock, payload, type, expectedMutations, callback }) => {
|
||||
describe(action.name, () => {
|
||||
describe(`on ${type}`, () => {
|
||||
beforeEach(() => {
|
||||
mock[axiosMock.method]().replyOnce(axiosMock.code, axiosMock.res);
|
||||
mock[axiosMock.method]().reply(axiosMock.code, axiosMock.res);
|
||||
});
|
||||
it(`should dispatch the correct mutations`, () => {
|
||||
return testAction({ action, state, expectedMutations }).then(() => callback());
|
||||
return testAction({ action, payload, state, expectedMutations }).then(() => callback());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -115,9 +125,25 @@ describe('Global Search Store Actions', () => {
|
|||
page: null,
|
||||
state: null,
|
||||
confidential: null,
|
||||
nav_source: null,
|
||||
});
|
||||
expect(urlUtils.visitUrl).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setUrlParams with snippets, group_id, and project_id when snippets param is true', () => {
|
||||
return testAction(actions.resetQuery, true, state, [], [], () => {
|
||||
expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
|
||||
...state.query,
|
||||
page: null,
|
||||
state: null,
|
||||
confidential: null,
|
||||
nav_source: null,
|
||||
group_id: null,
|
||||
project_id: null,
|
||||
snippets: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import mutations from '~/search/store/mutations';
|
||||
import createState from '~/search/store/state';
|
||||
import * as types from '~/search/store/mutation_types';
|
||||
import { MOCK_QUERY, MOCK_GROUPS, MOCK_PROJECTS } from '../mock_data';
|
||||
import {
|
||||
MOCK_QUERY,
|
||||
MOCK_GROUPS,
|
||||
MOCK_PROJECTS,
|
||||
MOCK_SEARCH_COUNTS,
|
||||
MOCK_SCOPE_TABS,
|
||||
} from '../mock_data';
|
||||
|
||||
describe('Global Search Store Mutations', () => {
|
||||
let state;
|
||||
|
@ -71,4 +77,32 @@ describe('Global Search Store Mutations', () => {
|
|||
expect(state.query[payload.key]).toBe(payload.value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('REQUEST_SEARCH_COUNTS', () => {
|
||||
it('sets the count to for the query.scope activeCount', () => {
|
||||
const payload = { scopeTabs: ['issues'], activeCount: '22' };
|
||||
mutations[types.REQUEST_SEARCH_COUNTS](state, payload);
|
||||
|
||||
expect(state.inflatedScopeTabs).toStrictEqual([
|
||||
{ scope: 'issues', title: 'Issues', count: '22' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sets other scopes count to empty string', () => {
|
||||
const payload = { scopeTabs: ['milestones'], activeCount: '22' };
|
||||
mutations[types.REQUEST_SEARCH_COUNTS](state, payload);
|
||||
|
||||
expect(state.inflatedScopeTabs).toStrictEqual([
|
||||
{ scope: 'milestones', title: 'Milestones', count: '' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RECEIVE_SEARCH_COUNTS_SUCCESS', () => {
|
||||
it('sets the count from the input for all tabs', () => {
|
||||
mutations[types.RECEIVE_SEARCH_COUNTS_SUCCESS](state, MOCK_SEARCH_COUNTS);
|
||||
|
||||
expect(state.inflatedScopeTabs).toStrictEqual(MOCK_SCOPE_TABS);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,6 +5,7 @@ import { MOCK_QUERY } from 'jest/search/mock_data';
|
|||
import GlobalSearchTopbar from '~/search/topbar/components/app.vue';
|
||||
import GroupFilter from '~/search/topbar/components/group_filter.vue';
|
||||
import ProjectFilter from '~/search/topbar/components/project_filter.vue';
|
||||
import ScopeTabs from '~/search/topbar/components/scope_tabs.vue';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
@ -42,6 +43,7 @@ describe('GlobalSearchTopbar', () => {
|
|||
const findGroupFilter = () => wrapper.find(GroupFilter);
|
||||
const findProjectFilter = () => wrapper.find(ProjectFilter);
|
||||
const findSearchButton = () => wrapper.find(GlButton);
|
||||
const findScopeTabs = () => wrapper.find(ScopeTabs);
|
||||
|
||||
describe('template', () => {
|
||||
beforeEach(() => {
|
||||
|
@ -52,6 +54,18 @@ describe('GlobalSearchTopbar', () => {
|
|||
expect(findTopbarForm().exists()).toBe(true);
|
||||
});
|
||||
|
||||
describe('Scope Tabs', () => {
|
||||
it('renders when search param is set', () => {
|
||||
createComponent({ query: { search: 'test' } });
|
||||
expect(findScopeTabs().exists()).toBe(true);
|
||||
});
|
||||
it('does not render search param is blank', () => {
|
||||
createComponent({ query: {} });
|
||||
|
||||
expect(findScopeTabs().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search box', () => {
|
||||
it('renders always', () => {
|
||||
expect(findGlSearchBox().exists()).toBe(true);
|
||||
|
|
|
@ -0,0 +1,122 @@
|
|||
import Vuex from 'vuex';
|
||||
import { createLocalVue, mount } from '@vue/test-utils';
|
||||
import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
|
||||
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||
import { MOCK_QUERY, MOCK_SCOPE_TABS } from 'jest/search/mock_data';
|
||||
import ScopeTabs from '~/search/topbar/components/scope_tabs.vue';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
describe('ScopeTabs', () => {
|
||||
let wrapper;
|
||||
|
||||
const actionSpies = {
|
||||
fetchSearchCounts: jest.fn(),
|
||||
setQuery: jest.fn(),
|
||||
resetQuery: jest.fn(),
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
scopeTabs: ['issues', 'merge_requests', 'milestones'],
|
||||
count: '20',
|
||||
};
|
||||
|
||||
const createComponent = (props = {}, initialState = {}) => {
|
||||
const store = new Vuex.Store({
|
||||
state: {
|
||||
query: {
|
||||
...MOCK_QUERY,
|
||||
search: 'test',
|
||||
},
|
||||
...initialState,
|
||||
},
|
||||
actions: actionSpies,
|
||||
});
|
||||
|
||||
wrapper = extendedWrapper(
|
||||
mount(ScopeTabs, {
|
||||
localVue,
|
||||
store,
|
||||
propsData: {
|
||||
...defaultProps,
|
||||
...props,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
const findScopeTabs = () => wrapper.find(GlTabs);
|
||||
const findTabs = () => wrapper.findAll(GlTab);
|
||||
const findBadges = () => wrapper.findAll(GlBadge);
|
||||
const findTabsTitle = () =>
|
||||
wrapper.findAll('[data-testid="tab-title"]').wrappers.map((w) => w.text());
|
||||
const findBadgesTitle = () => findBadges().wrappers.map((w) => w.text());
|
||||
const findBadgeByScope = (scope) => wrapper.findByTestId(`badge-${scope}`);
|
||||
const findTabByScope = (scope) => wrapper.findByTestId(`tab-${scope}`);
|
||||
|
||||
describe('template', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({}, { inflatedScopeTabs: MOCK_SCOPE_TABS });
|
||||
});
|
||||
|
||||
it('always renders Scope Tabs', () => {
|
||||
expect(findScopeTabs().exists()).toBe(true);
|
||||
});
|
||||
|
||||
describe('findTabs', () => {
|
||||
it('renders a tab for each scope', () => {
|
||||
expect(findTabs()).toHaveLength(defaultProps.scopeTabs.length);
|
||||
expect(findTabsTitle()).toStrictEqual([
|
||||
'Issues',
|
||||
'Titles and Descriptions',
|
||||
'Merge requests',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findBadges', () => {
|
||||
it('renders a badge for each scope', () => {
|
||||
expect(findBadges()).toHaveLength(defaultProps.scopeTabs.length);
|
||||
expect(findBadgesTitle()).toStrictEqual(['15', '5', '1']);
|
||||
});
|
||||
|
||||
it('sets the variant to neutral for active tab only', () => {
|
||||
expect(findBadgeByScope('issues').classes()).toContain('badge-neutral');
|
||||
expect(findBadgeByScope('snippet_titles').classes()).toContain('badge-muted');
|
||||
expect(findBadgeByScope('merge_requests').classes()).toContain('badge-muted');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('methods', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({}, { inflatedScopeTabs: MOCK_SCOPE_TABS });
|
||||
|
||||
findTabByScope('snippet_titles').vm.$emit('click');
|
||||
});
|
||||
|
||||
describe('handleTabChange', () => {
|
||||
it('calls setQuery with scope, applies any search params from ALL_SCOPE_TABS, and sends nulls for page, state, confidential, and nav_source', () => {
|
||||
expect(actionSpies.setQuery).toHaveBeenCalledWith(expect.any(Object), {
|
||||
key: 'scope',
|
||||
value: 'snippet_titles',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls resetQuery and sends true for snippet_titles tab', () => {
|
||||
expect(actionSpies.resetQuery).toHaveBeenCalledWith(expect.any(Object), true);
|
||||
});
|
||||
|
||||
it('calls resetQuery and does not send true for other tabs', () => {
|
||||
findTabByScope('issues').vm.$emit('click');
|
||||
expect(actionSpies.resetQuery).toHaveBeenCalledWith(expect.any(Object), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -6,7 +6,7 @@ RSpec.describe Resolvers::PackagesResolver do
|
|||
include GraphqlHelpers
|
||||
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:project) { create(:project) }
|
||||
let_it_be(:project) { create(:project, :public) }
|
||||
let_it_be(:package) { create(:package, project: project) }
|
||||
|
||||
describe '#resolve' do
|
||||
|
|
|
@ -6,9 +6,10 @@ RSpec.describe Resolvers::ReleaseMilestonesResolver do
|
|||
include GraphqlHelpers
|
||||
|
||||
let_it_be(:release) { create(:release, :with_milestones, milestones_count: 2) }
|
||||
let_it_be(:current_user) { create(:user, developer_projects: [release.project]) }
|
||||
|
||||
let(:resolved) do
|
||||
resolve(described_class, obj: release)
|
||||
resolve(described_class, obj: release, ctx: { current_user: current_user })
|
||||
end
|
||||
|
||||
describe '#resolve' do
|
||||
|
|
|
@ -392,63 +392,6 @@ RSpec.describe SearchHelper do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'search_filter_link' do
|
||||
it 'renders a search filter link for the current scope' do
|
||||
@scope = 'projects'
|
||||
@search_results = double
|
||||
|
||||
expect(@search_results).to receive(:formatted_count).with('projects').and_return('23')
|
||||
|
||||
link = search_filter_link('projects', 'Projects')
|
||||
|
||||
expect(link).to have_css('li.active')
|
||||
expect(link).to have_link('Projects', href: search_path(scope: 'projects'))
|
||||
expect(link).to have_css('span.badge.badge-pill:not(.js-search-count):not(.hidden):not([data-url])', text: '23')
|
||||
end
|
||||
|
||||
it 'renders a search filter link for another scope' do
|
||||
link = search_filter_link('projects', 'Projects')
|
||||
count_path = search_count_path(scope: 'projects')
|
||||
|
||||
expect(link).to have_css('li:not([class="active"])')
|
||||
expect(link).to have_link('Projects', href: search_path(scope: 'projects'))
|
||||
expect(link).to have_css("span.badge.badge-pill.js-search-count.hidden[data-url='#{count_path}']", text: '')
|
||||
end
|
||||
|
||||
it 'merges in the current search params and given params' do
|
||||
expect(self).to receive(:params).and_return(
|
||||
ActionController::Parameters.new(
|
||||
search: 'hello',
|
||||
scope: 'ignored',
|
||||
other_param: 'ignored'
|
||||
)
|
||||
)
|
||||
|
||||
link = search_filter_link('projects', 'Projects', search: { project_id: 23 })
|
||||
|
||||
expect(link).to have_link('Projects', href: search_path(scope: 'projects', search: 'hello', project_id: 23))
|
||||
end
|
||||
|
||||
it 'restricts the params' do
|
||||
expect(self).to receive(:params).and_return(
|
||||
ActionController::Parameters.new(
|
||||
search: 'hello',
|
||||
unknown: 42
|
||||
)
|
||||
)
|
||||
|
||||
link = search_filter_link('projects', 'Projects')
|
||||
|
||||
expect(link).to have_link('Projects', href: search_path(scope: 'projects', search: 'hello'))
|
||||
end
|
||||
|
||||
it 'assigns given data attributes on the list container' do
|
||||
link = search_filter_link('projects', 'Projects', data: { foo: 'bar' })
|
||||
|
||||
expect(link).to have_css('li[data-foo="bar"]')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#show_user_search_tab?' do
|
||||
subject { show_user_search_tab? }
|
||||
|
||||
|
@ -631,4 +574,86 @@ RSpec.describe SearchHelper do
|
|||
expect(search_sort_options).to eq([mock_created_sort])
|
||||
end
|
||||
end
|
||||
|
||||
describe '#search_nav_tabs' do
|
||||
subject { search_nav_tabs }
|
||||
|
||||
let(:current_user) { nil }
|
||||
|
||||
before do
|
||||
allow(self).to receive(:current_user).and_return(current_user)
|
||||
end
|
||||
|
||||
context 'when @show_snippets is present' do
|
||||
before do
|
||||
@show_snippets = 1
|
||||
end
|
||||
|
||||
it { is_expected.to eq([:snippet_titles]) }
|
||||
|
||||
context 'and @project is present' do
|
||||
before do
|
||||
@project = 1
|
||||
allow(self).to receive(:project_search_tabs?).with(anything).and_return(true)
|
||||
end
|
||||
|
||||
it { is_expected.to eq([:blobs, :issues, :merge_requests, :milestones, :notes, :wiki_blobs, :commits, :users]) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when @project is present' do
|
||||
before do
|
||||
@project = 1
|
||||
end
|
||||
|
||||
context 'when user has access to project' do
|
||||
before do
|
||||
allow(self).to receive(:project_search_tabs?).with(anything).and_return(true)
|
||||
end
|
||||
|
||||
it { is_expected.to eq([:blobs, :issues, :merge_requests, :milestones, :notes, :wiki_blobs, :commits, :users]) }
|
||||
end
|
||||
|
||||
context 'when user does not have access to project' do
|
||||
before do
|
||||
allow(self).to receive(:project_search_tabs?).with(anything).and_return(false)
|
||||
end
|
||||
|
||||
it { is_expected.to eq([]) }
|
||||
end
|
||||
|
||||
context 'when user does not have access to read members for project' do
|
||||
before do
|
||||
allow(self).to receive(:project_search_tabs?).with(:members).and_return(false)
|
||||
allow(self).to receive(:project_search_tabs?).with(:merge_requests).and_return(true)
|
||||
allow(self).to receive(:project_search_tabs?).with(:milestones).and_return(true)
|
||||
allow(self).to receive(:project_search_tabs?).with(:wiki_blobs).and_return(true)
|
||||
allow(self).to receive(:project_search_tabs?).with(:issues).and_return(true)
|
||||
allow(self).to receive(:project_search_tabs?).with(:blobs).and_return(true)
|
||||
allow(self).to receive(:project_search_tabs?).with(:notes).and_return(true)
|
||||
allow(self).to receive(:project_search_tabs?).with(:commits).and_return(true)
|
||||
end
|
||||
|
||||
it { is_expected.to eq([:blobs, :issues, :merge_requests, :milestones, :notes, :wiki_blobs, :commits]) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when @show_snippets and @project are not present' do
|
||||
context 'when user has access to read users' do
|
||||
before do
|
||||
allow(self).to receive(:can?).with(current_user, :read_users_list).and_return(true)
|
||||
end
|
||||
|
||||
it { is_expected.to eq([:projects, :issues, :merge_requests, :milestones, :users]) }
|
||||
end
|
||||
|
||||
context 'when user does not have access to read users' do
|
||||
before do
|
||||
allow(self).to receive(:can?).with(current_user, :read_users_list).and_return(false)
|
||||
end
|
||||
|
||||
it { is_expected.to eq([:projects, :issues, :merge_requests, :milestones]) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -7,7 +7,7 @@ RSpec.describe ::API::Entities::MergeRequestBasic do
|
|||
let_it_be(:project) { create(:project, :public) }
|
||||
let_it_be(:merge_request) { create(:merge_request) }
|
||||
let_it_be(:labels) { create_list(:label, 3) }
|
||||
let_it_be(:merge_requests) { create_list(:labeled_merge_request, 10, :unique_branches, :with_diffs, labels: labels) }
|
||||
let_it_be(:merge_requests) { create_list(:labeled_merge_request, 10, :unique_branches, labels: labels) }
|
||||
|
||||
# This mimics the behavior of the `Grape::Entity` serializer
|
||||
def present(obj)
|
||||
|
|
|
@ -216,7 +216,7 @@ RSpec.describe Banzai::Filter::MergeRequestReferenceFilter do
|
|||
end
|
||||
|
||||
context 'URL reference for a commit' do
|
||||
let(:mr) { create(:merge_request, :with_diffs) }
|
||||
let(:mr) { create(:merge_request) }
|
||||
let(:reference) do
|
||||
urls.project_merge_request_url(mr.project, mr) + "/diffs?commit_id=#{mr.diff_head_sha}"
|
||||
end
|
||||
|
|
|
@ -56,5 +56,20 @@ RSpec.describe Gitlab::AlertManagement::Payload do
|
|||
it { is_expected.to be_a Gitlab::AlertManagement::Payload::Generic }
|
||||
end
|
||||
end
|
||||
|
||||
context 'with integration specified by caller' do
|
||||
let(:integration) { instance_double(AlertManagement::HttpIntegration) }
|
||||
|
||||
subject { described_class.parse(project, payload, integration: integration) }
|
||||
|
||||
it 'passes an integration to a specific payload' do
|
||||
expect(::Gitlab::AlertManagement::Payload::Generic)
|
||||
.to receive(:new)
|
||||
.with(project: project, payload: payload, integration: integration)
|
||||
.and_call_original
|
||||
|
||||
subject
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Ci::Config::Yaml::Tags::Reference do
|
||||
let(:config) do
|
||||
Gitlab::Ci::Config::Yaml.load!(yaml)
|
||||
end
|
||||
|
||||
describe '.tag' do
|
||||
it 'implements the tag method' do
|
||||
expect(described_class.tag).to eq('!reference')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#resolve' do
|
||||
subject { Gitlab::Ci::Config::Yaml::Tags::Resolver.new(config).to_hash }
|
||||
|
||||
context 'with circular references' do
|
||||
let(:yaml) do
|
||||
<<~YML
|
||||
a: !reference [b]
|
||||
b: !reference [a]
|
||||
YML
|
||||
end
|
||||
|
||||
it 'raises CircularReferenceError' do
|
||||
expect { subject }.to raise_error Gitlab::Ci::Config::Yaml::Tags::TagError, '!reference ["b"] is part of a circular chain'
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nested circular references' do
|
||||
let(:yaml) do
|
||||
<<~YML
|
||||
a: !reference [b, c]
|
||||
b: { c: !reference [d, e, f] }
|
||||
d: { e: { f: !reference [a] } }
|
||||
YML
|
||||
end
|
||||
|
||||
it 'raises CircularReferenceError' do
|
||||
expect { subject }.to raise_error Gitlab::Ci::Config::Yaml::Tags::TagError, '!reference ["b", "c"] is part of a circular chain'
|
||||
end
|
||||
end
|
||||
|
||||
context 'with missing references' do
|
||||
let(:yaml) { 'a: !reference [b]' }
|
||||
|
||||
it 'raises MissingReferenceError' do
|
||||
expect { subject }.to raise_error Gitlab::Ci::Config::Yaml::Tags::TagError, '!reference ["b"] could not be found'
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid references' do
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
where(:yaml, :error_message) do
|
||||
'a: !reference' | '!reference [] is not valid'
|
||||
'a: !reference str' | '!reference "str" is not valid'
|
||||
'a: !reference 1' | '!reference "1" is not valid'
|
||||
'a: !reference [1]' | '!reference [1] is not valid'
|
||||
'a: !reference { b: c }' | '!reference {"b"=>"c"} is not valid'
|
||||
end
|
||||
|
||||
with_them do
|
||||
it 'raises an error' do
|
||||
expect { subject }.to raise_error Gitlab::Ci::Config::Yaml::Tags::TagError, error_message
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with arrays' do
|
||||
let(:yaml) do
|
||||
<<~YML
|
||||
a: { b: [1, 2] }
|
||||
c: { d: { e: [3, 4] } }
|
||||
f: { g: [ !reference [a, b], 5, !reference [c, d, e]] }
|
||||
YML
|
||||
end
|
||||
|
||||
it { is_expected.to match(a_hash_including({ f: { g: [[1, 2], 5, [3, 4]] } })) }
|
||||
end
|
||||
|
||||
context 'with hashes' do
|
||||
context 'when referencing an entire hash' do
|
||||
let(:yaml) do
|
||||
<<~YML
|
||||
a: { b: { c: 'c', d: 'd' } }
|
||||
e: { f: !reference [a, b] }
|
||||
YML
|
||||
end
|
||||
|
||||
it { is_expected.to match(a_hash_including({ e: { f: { c: 'c', d: 'd' } } })) }
|
||||
end
|
||||
|
||||
context 'when referencing only a hash value' do
|
||||
let(:yaml) do
|
||||
<<~YML
|
||||
a: { b: { c: 'c', d: 'd' } }
|
||||
e: { f: { g: !reference [a, b, c], h: 'h' } }
|
||||
i: !reference [e, f]
|
||||
YML
|
||||
end
|
||||
|
||||
it { is_expected.to match(a_hash_including({ i: { g: 'c', h: 'h' } })) }
|
||||
end
|
||||
|
||||
context 'when referencing a value before its definition' do
|
||||
let(:yaml) do
|
||||
<<~YML
|
||||
a: { b: !reference [c, d] }
|
||||
g: { h: { i: 'i', j: 1 } }
|
||||
c: { d: { e: !reference [g, h, j], f: 'f' } }
|
||||
YML
|
||||
end
|
||||
|
||||
it { is_expected.to match(a_hash_including({ a: { b: { e: 1, f: 'f' } } })) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,123 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Ci::Config::Yaml::Tags::Resolver do
|
||||
let(:config) do
|
||||
Gitlab::Ci::Config::Yaml.load!(yaml)
|
||||
end
|
||||
|
||||
describe '#to_hash' do
|
||||
subject { described_class.new(config).to_hash }
|
||||
|
||||
context 'when referencing deeply nested arrays' do
|
||||
let(:yaml_templates) do
|
||||
<<~YML
|
||||
.job-1:
|
||||
script:
|
||||
- echo doing step 1 of job 1
|
||||
- echo doing step 2 of job 1
|
||||
|
||||
.job-2:
|
||||
script:
|
||||
- echo doing step 1 of job 2
|
||||
- !reference [.job-1, script]
|
||||
- echo doing step 2 of job 2
|
||||
|
||||
.job-3:
|
||||
script:
|
||||
- echo doing step 1 of job 3
|
||||
- !reference [.job-2, script]
|
||||
- echo doing step 2 of job 3
|
||||
YML
|
||||
end
|
||||
|
||||
let(:job_yaml) do
|
||||
<<~YML
|
||||
test:
|
||||
script:
|
||||
- echo preparing to test
|
||||
- !reference [.job-3, script]
|
||||
- echo test finished
|
||||
YML
|
||||
end
|
||||
|
||||
shared_examples 'expands references' do
|
||||
it 'expands the references' do
|
||||
is_expected.to match({
|
||||
'.job-1': {
|
||||
script: [
|
||||
'echo doing step 1 of job 1',
|
||||
'echo doing step 2 of job 1'
|
||||
]
|
||||
},
|
||||
'.job-2': {
|
||||
script: [
|
||||
'echo doing step 1 of job 2',
|
||||
[
|
||||
'echo doing step 1 of job 1',
|
||||
'echo doing step 2 of job 1'
|
||||
],
|
||||
'echo doing step 2 of job 2'
|
||||
]
|
||||
},
|
||||
'.job-3': {
|
||||
script: [
|
||||
'echo doing step 1 of job 3',
|
||||
[
|
||||
'echo doing step 1 of job 2',
|
||||
[
|
||||
'echo doing step 1 of job 1',
|
||||
'echo doing step 2 of job 1'
|
||||
],
|
||||
'echo doing step 2 of job 2'
|
||||
],
|
||||
'echo doing step 2 of job 3'
|
||||
]
|
||||
},
|
||||
test: {
|
||||
script: [
|
||||
'echo preparing to test',
|
||||
[
|
||||
'echo doing step 1 of job 3',
|
||||
[
|
||||
'echo doing step 1 of job 2',
|
||||
[
|
||||
'echo doing step 1 of job 1',
|
||||
'echo doing step 2 of job 1'
|
||||
],
|
||||
'echo doing step 2 of job 2'
|
||||
],
|
||||
'echo doing step 2 of job 3'
|
||||
],
|
||||
'echo test finished'
|
||||
]
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'when templates are defined before the job' do
|
||||
let(:yaml) do
|
||||
<<~YML
|
||||
#{yaml_templates}
|
||||
#{job_yaml}
|
||||
YML
|
||||
end
|
||||
|
||||
it_behaves_like 'expands references'
|
||||
end
|
||||
|
||||
context 'when templates are defined after the job' do
|
||||
let(:yaml) do
|
||||
<<~YML
|
||||
#{job_yaml}
|
||||
#{yaml_templates}
|
||||
YML
|
||||
end
|
||||
|
||||
it_behaves_like 'expands references'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue