Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-02-09 12:09:48 +00:00
parent e7462f7b49
commit 3c53fbc50b
119 changed files with 2038 additions and 673 deletions

View File

@ -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>

View File

@ -0,0 +1,3 @@
import initSearchSettings from '~/search_settings';
initSearchSettings();

View File

@ -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();
});

View File

@ -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));
}

View File

@ -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
};

View File

@ -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 }));
};

View File

@ -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';

View File

@ -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;
},

View File

@ -4,5 +4,6 @@ const createState = ({ query }) => ({
fetchingGroups: false,
projects: [],
fetchingProjects: false,
inflatedScopeTabs: [],
});
export default createState;

View File

@ -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>

View File

@ -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>

View File

@ -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' },
};

View File

@ -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,
},
});
},

View File

@ -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)),

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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...'))

View File

@ -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

View File

@ -4,4 +4,5 @@
- nav "profile"
- @left_sidebar = true
- enable_search_settings locals: { container_class: 'gl-my-5' }
= render template: "layouts/application"

View File

@ -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

View File

@ -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)

View 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

View File

@ -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'

View File

@ -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")

View File

@ -0,0 +1,5 @@
---
title: Don't expose project existence by redirecting from its .git URL
merge_request: 52818
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Change search tab to Vue component
merge_request: 52018
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Apply new GitLab UI for buttons in commit page
merge_request: 53555
author: Yogi (@yo)
type: other

View File

@ -0,0 +1,5 @@
---
title: Apply new GitLab UI for new trial page
merge_request: 53447
author: Yogi (@yo)
type: other

View File

@ -0,0 +1,5 @@
---
title: Skip new note notifications when author is deleted
merge_request: 53699
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Apply new GitLab UI for search in frequent items search
merge_request: 53368
author: Yogi (@yo)
type: other

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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"]

View File

@ -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]

View File

@ -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

View 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).

View File

@ -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:

View File

@ -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 |

View File

@ -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]

View File

@ -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

View File

@ -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) }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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})"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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',

View File

@ -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:)

View File

@ -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 ""

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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')

View File

@ -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 }

View File

@ -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) }

View File

@ -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) }

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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')

View File

@ -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) }

View File

@ -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')

View File

@ -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')

View File

@ -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

View File

@ -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,

View File

@ -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();

View File

@ -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&amp;project_id=3&amp;scope=issues\\">4</div>
<div class=\\"badge js-search-count\\" data-url=\\"http://test.host/search/count?search=lorem+ipsum&amp;project_id=3&amp;scope=merge_requests\\">5</div>"
`;

View File

@ -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();
});
});
});

View File

@ -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' },
];

View File

@ -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,
});
});
});
});

View File

@ -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);
});
});
});

View File

@ -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);

View File

@ -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);
});
});
});
});

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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