diff options
49 files changed, 734 insertions, 249 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index aee9990bc0b..6ec77186298 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -5,6 +5,8 @@ import { joinPaths } from './lib/utils/url_utility'; import flash from '~/flash'; import { __ } from '~/locale'; +const DEFAULT_PER_PAGE = 20; + const Api = { groupsPath: '/api/:version/groups.json', groupPath: '/api/:version/groups/:id', @@ -66,7 +68,7 @@ const Api = { params: Object.assign( { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, }, options, ), @@ -90,7 +92,7 @@ const Api = { .get(url, { params: { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, }, }) .then(({ data }) => callback(data)); @@ -101,7 +103,7 @@ const Api = { const url = Api.buildUrl(Api.projectsPath); const defaults = { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, simple: true, }; @@ -126,7 +128,7 @@ const Api = { .get(url, { params: { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, ...options, }, }) @@ -235,7 +237,7 @@ const Api = { const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId); const defaults = { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, }; return axios .get(url, { @@ -325,7 +327,7 @@ const Api = { params: Object.assign( { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, }, options, ), @@ -355,7 +357,7 @@ const Api = { const url = Api.buildUrl(Api.userProjectsPath).replace(':id', userId); const defaults = { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, }; return axios .get(url, { @@ -371,7 +373,7 @@ const Api = { return axios.get(url, { params: { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, ...options, }, }); @@ -403,10 +405,15 @@ const Api = { return axios.post(url); }, - releases(id) { + releases(id, options = {}) { const url = Api.buildUrl(this.releasesPath).replace(':id', encodeURIComponent(id)); - return axios.get(url); + return axios.get(url, { + params: { + per_page: DEFAULT_PER_PAGE, + ...options, + }, + }); }, release(projectPath, tagName) { diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index d7ffa0abb79..98f1f385e9b 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -19,6 +19,7 @@ export default { 'resolvableDiscussionsCount', 'firstUnresolvedDiscussionId', 'unresolvedDiscussionsCount', + 'getDiscussion', ]), isLoggedIn() { return this.getUserData.id; @@ -40,9 +41,10 @@ export default { ...mapActions(['expandDiscussion']), jumpToFirstUnresolvedDiscussion() { const diffTab = window.mrTabs.currentAction === 'diffs'; - const discussionId = this.firstUnresolvedDiscussionId(diffTab); - - this.jumpToDiscussion(discussionId); + const discussionId = + this.firstUnresolvedDiscussionId(diffTab) || this.firstUnresolvedDiscussionId(); + const firstDiscussion = this.getDiscussion(discussionId); + this.jumpToDiscussion(firstDiscussion); }, }, }; diff --git a/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue b/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue index 7fbfe8eebb2..7d742fbfeee 100644 --- a/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue +++ b/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue @@ -19,7 +19,11 @@ export default { }; }, computed: { - ...mapGetters(['nextUnresolvedDiscussionId', 'previousUnresolvedDiscussionId']), + ...mapGetters([ + 'nextUnresolvedDiscussionId', + 'previousUnresolvedDiscussionId', + 'getDiscussion', + ]), }, mounted() { Mousetrap.bind('n', () => this.jumpToNextDiscussion()); @@ -33,14 +37,14 @@ export default { ...mapActions(['expandDiscussion']), jumpToNextDiscussion() { const nextId = this.nextUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView); - - this.jumpToDiscussion(nextId); + const nextDiscussion = this.getDiscussion(nextId); + this.jumpToDiscussion(nextDiscussion); this.currentDiscussionId = nextId; }, jumpToPreviousDiscussion() { const prevId = this.previousUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView); - - this.jumpToDiscussion(prevId); + const prevDiscussion = this.getDiscussion(prevId); + this.jumpToDiscussion(prevDiscussion); this.currentDiscussionId = prevId; }, }, diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 47ec740b63a..62d401d4911 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -84,6 +84,7 @@ export default { 'hasUnresolvedDiscussions', 'showJumpToNextDiscussion', 'getUserData', + 'getDiscussion', ]), currentUser() { return this.getUserData; @@ -221,8 +222,9 @@ export default { this.discussion.id, this.discussionsByDiffOrder, ); + const nextDiscussion = this.getDiscussion(nextId); - this.jumpToDiscussion(nextId); + this.jumpToDiscussion(nextDiscussion); }, deleteNoteHandler(note) { this.$emit('noteDeleted', this.discussion, note); diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 3d89d907777..94ca01e44cc 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -35,20 +35,26 @@ export default { return false; }, - jumpToDiscussion(id) { + + switchToDiscussionsTabAndJumpTo(id) { + window.mrTabs.eventHub.$once('MergeRequestTabChange', () => { + setTimeout(() => this.discussionJump(id), 0); + }); + + window.mrTabs.tabShown('show'); + }, + + jumpToDiscussion(discussion) { + const { id, diff_discussion: isDiffDiscussion } = discussion; if (id) { const activeTab = window.mrTabs.currentAction; - if (activeTab === 'diffs') { + if (activeTab === 'diffs' && isDiffDiscussion) { this.diffsJump(id); - } else if (activeTab === 'commits' || activeTab === 'pipelines') { - window.mrTabs.eventHub.$once('MergeRequestTabChange', () => { - setTimeout(() => this.discussionJump(id), 0); - }); - - window.mrTabs.tabShown('show'); - } else { + } else if (activeTab === 'show') { this.discussionJump(id); + } else { + this.switchToDiscussionsTabAndJumpTo(id); } } }, diff --git a/app/assets/javascripts/releases/list/components/app.vue b/app/assets/javascripts/releases/list/components/app.vue index 5a06c4fec58..a414b3ccd4e 100644 --- a/app/assets/javascripts/releases/list/components/app.vue +++ b/app/assets/javascripts/releases/list/components/app.vue @@ -1,6 +1,12 @@ <script> import { mapState, mapActions } from 'vuex'; import { GlSkeletonLoading, GlEmptyState } from '@gitlab/ui'; +import { + getParameterByName, + historyPushState, + buildUrlWithCurrentLocation, +} from '~/lib/utils/common_utils'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import ReleaseBlock from './release_block.vue'; export default { @@ -9,6 +15,7 @@ export default { GlSkeletonLoading, GlEmptyState, ReleaseBlock, + TablePagination, }, props: { projectId: { @@ -25,7 +32,7 @@ export default { }, }, computed: { - ...mapState(['isLoading', 'releases', 'hasError']), + ...mapState(['isLoading', 'releases', 'hasError', 'pageInfo']), shouldRenderEmptyState() { return !this.releases.length && !this.hasError && !this.isLoading; }, @@ -34,10 +41,17 @@ export default { }, }, created() { - this.fetchReleases(this.projectId); + this.fetchReleases({ + page: getParameterByName('page'), + projectId: this.projectId, + }); }, methods: { ...mapActions(['fetchReleases']), + onChangePage(page) { + historyPushState(buildUrlWithCurrentLocation(`?page=${page}`)); + this.fetchReleases({ page, projectId: this.projectId }); + }, }, }; </script> @@ -67,6 +81,8 @@ export default { :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }" /> </div> + + <table-pagination v-if="!isLoading" :change="onChangePage" :page-info="pageInfo" /> </div> </template> <style> diff --git a/app/assets/javascripts/releases/list/store/actions.js b/app/assets/javascripts/releases/list/store/actions.js index e0a922d5ef6..b15fb69226f 100644 --- a/app/assets/javascripts/releases/list/store/actions.js +++ b/app/assets/javascripts/releases/list/store/actions.js @@ -2,6 +2,7 @@ import * as types from './mutation_types'; import createFlash from '~/flash'; import { __ } from '~/locale'; import api from '~/api'; +import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; /** * Commits a mutation to update the state while the main endpoint is being requested. @@ -16,17 +17,19 @@ export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES); * * @param {String} projectId */ -export const fetchReleases = ({ dispatch }, projectId) => { +export const fetchReleases = ({ dispatch }, { page = '1', projectId }) => { dispatch('requestReleases'); api - .releases(projectId) - .then(({ data }) => dispatch('receiveReleasesSuccess', data)) + .releases(projectId, { page }) + .then(response => dispatch('receiveReleasesSuccess', response)) .catch(() => dispatch('receiveReleasesError')); }; -export const receiveReleasesSuccess = ({ commit }, data) => - commit(types.RECEIVE_RELEASES_SUCCESS, data); +export const receiveReleasesSuccess = ({ commit }, { data, headers }) => { + const pageInfo = parseIntPagination(normalizeHeaders(headers)); + commit(types.RECEIVE_RELEASES_SUCCESS, { data, pageInfo }); +}; export const receiveReleasesError = ({ commit }) => { commit(types.RECEIVE_RELEASES_ERROR); diff --git a/app/assets/javascripts/releases/list/store/mutations.js b/app/assets/javascripts/releases/list/store/mutations.js index b97dc6cb0ab..99fc096264a 100644 --- a/app/assets/javascripts/releases/list/store/mutations.js +++ b/app/assets/javascripts/releases/list/store/mutations.js @@ -13,13 +13,15 @@ export default { * Sets isLoading to false. * Sets hasError to false. * Sets the received data + * Sets the received pagination information * @param {Object} state - * @param {Object} data + * @param {Object} resp */ - [types.RECEIVE_RELEASES_SUCCESS](state, data) { + [types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) { state.hasError = false; state.isLoading = false; state.releases = data; + state.pageInfo = pageInfo; }, /** diff --git a/app/assets/javascripts/releases/list/store/state.js b/app/assets/javascripts/releases/list/store/state.js index bf25e651c99..c251f56c9c5 100644 --- a/app/assets/javascripts/releases/list/store/state.js +++ b/app/assets/javascripts/releases/list/store/state.js @@ -2,4 +2,5 @@ export default () => ({ isLoading: false, hasError: false, releases: [], + pageInfo: {}, }); diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index da1a7c290f8..57fbb88ca2e 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, one-var, no-var, prefer-rest-params, vars-on-top, consistent-return, no-shadow, no-else-return, no-self-compare, no-unused-expressions, yoda, prefer-spread, camelcase, no-param-reassign */ +/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-else-return, no-self-compare, no-unused-expressions, yoda, prefer-spread, camelcase, no-param-reassign */ /* global Issuable */ /* global emitSidebarEvent */ @@ -13,7 +13,7 @@ import { parseBoolean } from './lib/utils/common_utils'; window.emitSidebarEvent = window.emitSidebarEvent || $.noop; function UsersSelect(currentUser, els, options = {}) { - var $els; + const $els = $(els || '.js-user-search'); this.users = this.users.bind(this); this.user = this.user.bind(this); this.usersPath = '/autocomplete/users.json'; @@ -28,36 +28,11 @@ function UsersSelect(currentUser, els, options = {}) { const { handleClick } = options; - $els = $(els); - - if (!els) { - $els = $('.js-user-search'); - } - $els.each( (function(_this) { return function(i, dropdown) { - var options = {}; - var $block, - $collapsedSidebar, - $dropdown, - $loading, - $selectbox, - $value, - abilityName, - assignTo, - assigneeTemplate, - collapsedAssigneeTemplate, - defaultLabel, - defaultNullUser, - firstUser, - issueURL, - selectedId, - selectedIdDefault, - showAnyUser, - showNullUser, - showMenuAbove; - $dropdown = $(dropdown); + const options = {}; + const $dropdown = $(dropdown); options.projectId = $dropdown.data('projectId'); options.groupId = $dropdown.data('groupId'); options.showCurrentUser = $dropdown.data('currentUser'); @@ -65,22 +40,25 @@ function UsersSelect(currentUser, els, options = {}) { options.todoStateFilter = $dropdown.data('todoStateFilter'); options.iid = $dropdown.data('iid'); options.issuableType = $dropdown.data('issuableType'); - showNullUser = $dropdown.data('nullUser'); - defaultNullUser = $dropdown.data('nullUserDefault'); - showMenuAbove = $dropdown.data('showMenuAbove'); - showAnyUser = $dropdown.data('anyUser'); - firstUser = $dropdown.data('firstUser'); + const showNullUser = $dropdown.data('nullUser'); + const defaultNullUser = $dropdown.data('nullUserDefault'); + const showMenuAbove = $dropdown.data('showMenuAbove'); + const showAnyUser = $dropdown.data('anyUser'); + const firstUser = $dropdown.data('firstUser'); options.authorId = $dropdown.data('authorId'); - defaultLabel = $dropdown.data('defaultLabel'); - issueURL = $dropdown.data('issueUpdate'); - $selectbox = $dropdown.closest('.selectbox'); - $block = $selectbox.closest('.block'); - abilityName = $dropdown.data('abilityName'); - $value = $block.find('.value'); - $collapsedSidebar = $block.find('.sidebar-collapsed-user'); - $loading = $block.find('.block-loading').fadeOut(); - selectedIdDefault = defaultNullUser && showNullUser ? 0 : null; - selectedId = $dropdown.data('selected'); + const defaultLabel = $dropdown.data('defaultLabel'); + const issueURL = $dropdown.data('issueUpdate'); + const $selectbox = $dropdown.closest('.selectbox'); + let $block = $selectbox.closest('.block'); + const abilityName = $dropdown.data('abilityName'); + let $value = $block.find('.value'); + const $collapsedSidebar = $block.find('.sidebar-collapsed-user'); + const $loading = $block.find('.block-loading').fadeOut(); + const selectedIdDefault = defaultNullUser && showNullUser ? 0 : null; + let selectedId = $dropdown.data('selected'); + let assignTo; + let assigneeTemplate; + let collapsedAssigneeTemplate; if (selectedId === undefined) { selectedId = selectedIdDefault; @@ -207,15 +185,15 @@ function UsersSelect(currentUser, els, options = {}) { }); assignTo = function(selected) { - var data; - data = {}; + const data = {}; data[abilityName] = {}; data[abilityName].assignee_id = selected != null ? selected : null; $loading.removeClass('hidden').fadeIn(); $dropdown.trigger('loading.gl.dropdown'); return axios.put(issueURL, data).then(({ data }) => { - var user, tooltipTitle; + let user = {}; + let tooltipTitle = user.name; $dropdown.trigger('loaded.gl.dropdown'); $loading.fadeOut(); if (data.assignee) { @@ -471,10 +449,9 @@ function UsersSelect(currentUser, els, options = {}) { } } - var isIssueIndex, isMRIndex, page, selected; - page = $('body').attr('data-page'); - isIssueIndex = page === 'projects:issues:index'; - isMRIndex = page === page && page === 'projects:merge_requests:index'; + const page = $('body').attr('data-page'); + const isIssueIndex = page === 'projects:issues:index'; + const isMRIndex = page === page && page === 'projects:merge_requests:index'; if ( $dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown') @@ -501,7 +478,7 @@ function UsersSelect(currentUser, els, options = {}) { } else if ($dropdown.hasClass('js-filter-submit')) { return $dropdown.closest('form').submit(); } else if (!$dropdown.hasClass('js-multiselect')) { - selected = $dropdown + const selected = $dropdown .closest('.selectbox') .find(`input[name='${$dropdown.data('fieldName')}']`) .val(); @@ -544,9 +521,8 @@ function UsersSelect(currentUser, els, options = {}) { }, updateLabel: $dropdown.data('dropdownTitle'), renderRow(user) { - var avatar, img, username; - username = user.username ? `@${user.username}` : ''; - avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url; + const username = user.username ? `@${user.username}` : ''; + const avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url; let selected = false; @@ -565,7 +541,7 @@ function UsersSelect(currentUser, els, options = {}) { selected = user.id === selectedId; } - img = ''; + let img = ''; if (user.beforeDivider != null) { `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape( user.name, @@ -586,35 +562,34 @@ function UsersSelect(currentUser, els, options = {}) { $('.ajax-users-select').each( (function(_this) { return function(i, select) { - var firstUser, showAnyUser, showEmailUser, showNullUser; - var options = {}; + const options = {}; options.skipLdap = $(select).hasClass('skip_ldap'); options.projectId = $(select).data('projectId'); options.groupId = $(select).data('groupId'); options.showCurrentUser = $(select).data('currentUser'); options.authorId = $(select).data('authorId'); options.skipUsers = $(select).data('skipUsers'); - showNullUser = $(select).data('nullUser'); - showAnyUser = $(select).data('anyUser'); - showEmailUser = $(select).data('emailUser'); - firstUser = $(select).data('firstUser'); + const showNullUser = $(select).data('nullUser'); + const showAnyUser = $(select).data('anyUser'); + const showEmailUser = $(select).data('emailUser'); + const firstUser = $(select).data('firstUser'); return $(select).select2({ placeholder: __('Search for a user'), multiple: $(select).hasClass('multiselect'), minimumInputLength: 0, query(query) { return _this.users(query.term, options, users => { - var anyUser, data, emailUser, index, len, name, nullUser, obj, ref; - data = { + let name; + const data = { results: users, }; if (query.term.length === 0) { if (firstUser) { // Move current user to the front of the list - ref = data.results; + const ref = data.results; - for (index = 0, len = ref.length; index < len; index += 1) { - obj = ref[index]; + for (let index = 0, len = ref.length; index < len; index += 1) { + const obj = ref[index]; if (obj.username === firstUser) { data.results.splice(index, 1); data.results.unshift(obj); @@ -623,7 +598,7 @@ function UsersSelect(currentUser, els, options = {}) { } } if (showNullUser) { - nullUser = { + const nullUser = { name: s__('UsersSelect|Unassigned'), id: 0, }; @@ -634,7 +609,7 @@ function UsersSelect(currentUser, els, options = {}) { if (name === true) { name = s__('UsersSelect|Any User'); } - anyUser = { + const anyUser = { name, id: null, }; @@ -646,8 +621,8 @@ function UsersSelect(currentUser, els, options = {}) { data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/) ) { - var trimmed = query.term.trim(); - emailUser = { + const trimmed = query.term.trim(); + const emailUser = { name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }), username: trimmed, id: trimmed, @@ -659,18 +634,15 @@ function UsersSelect(currentUser, els, options = {}) { }); }, initSelection() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; return _this.initSelection.apply(_this, args); }, formatResult() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; return _this.formatResult.apply(_this, args); }, formatSelection() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; return _this.formatSelection.apply(_this, args); }, dropdownCssClass: 'ajax-users-dropdown', @@ -687,10 +659,9 @@ function UsersSelect(currentUser, els, options = {}) { } UsersSelect.prototype.initSelection = function(element, callback) { - var id, nullUser; - id = $(element).val(); + const id = $(element).val(); if (id === '0') { - nullUser = { + const nullUser = { name: s__('UsersSelect|Unassigned'), }; return callback(nullUser); @@ -700,11 +671,9 @@ UsersSelect.prototype.initSelection = function(element, callback) { }; UsersSelect.prototype.formatResult = function(user) { - var avatar; + let avatar = gon.default_avatar_url; if (user.avatar_url) { avatar = user.avatar_url; - } else { - avatar = gon.default_avatar_url; } return ` <div class='user-result'> @@ -732,8 +701,7 @@ UsersSelect.prototype.user = function(user_id, callback) { return false; } - var url; - url = this.buildUrl(this.userPath); + let url = this.buildUrl(this.userPath); url = url.replace(':id', user_id); return axios.get(url).then(({ data }) => { callback(data); diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb index 1645af695be..a78d803927c 100644 --- a/app/controllers/concerns/cycle_analytics_params.rb +++ b/app/controllers/concerns/cycle_analytics_params.rb @@ -38,7 +38,8 @@ module CycleAnalyticsParams end def to_utc_time(field) - Date.parse(field).to_time.utc + date = field.is_a?(Date) ? field : Date.parse(field) + date.to_time.utc end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 009765702ab..5cbfabebe39 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -44,7 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action do push_frontend_feature_flag(:vue_issuable_sidebar, project.group) - push_frontend_feature_flag(:release_search_filter, project) + push_frontend_feature_flag(:release_search_filter, project, default_enabled: true) end respond_to :html diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 766ec1e33f3..566a7ed46ca 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -24,7 +24,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action do push_frontend_feature_flag(:vue_issuable_sidebar, @project.group) - push_frontend_feature_flag(:release_search_filter, @project) + push_frontend_feature_flag(:release_search_filter, @project, default_enabled: true) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions] diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index dfddd32d7df..e3ea81d5564 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -13,6 +13,7 @@ # group_id: integer # project_id: integer # milestone_title: string +# release_tag: string # author_id: integer # author_username: string # assignee_id: integer or 'None' or 'Any' @@ -59,6 +60,7 @@ class IssuableFinder author_username label_name milestone_title + release_tag my_reaction_emoji search in @@ -126,6 +128,7 @@ class IssuableFinder items = by_non_archived(items) items = by_iids(items) items = by_milestone(items) + items = by_release(items) items = by_label(items) by_my_reaction_emoji(items) end @@ -364,6 +367,10 @@ class IssuableFinder end end + def releases? + params[:release_tag].present? + end + private def force_cte? @@ -570,6 +577,18 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord + def by_release(items) + return items unless releases? + + if filter_by_no_release? + items.without_release + elsif filter_by_any_release? + items.any_release + else + items.with_release(params[:release_tag], params[:project_id]) + end + end + def filter_by_no_milestone? # Accepts `No Milestone` for compatibility params[:milestone_title].to_s.downcase == FILTER_NONE || params[:milestone_title] == Milestone::None.title @@ -588,6 +607,14 @@ class IssuableFinder params[:milestone_title] == Milestone::Started.name end + def filter_by_no_release? + params[:release_tag].to_s.downcase == FILTER_NONE + end + + def filter_by_any_release? + params[:release_tag].to_s.downcase == FILTER_ANY + end + def by_label(items) return items unless labels? diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 1c9c7ec68d0..275a01330bf 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -12,6 +12,7 @@ # group_id: integer # project_id: integer # milestone_title: string +# release_tag: string # author_id: integer # assignee_id: integer # search: string diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb index f5aadc42ff0..092a805f275 100644 --- a/app/finders/pipelines_finder.rb +++ b/app/finders/pipelines_finder.rb @@ -3,7 +3,7 @@ class PipelinesFinder attr_reader :project, :pipelines, :params, :current_user - ALLOWED_INDEXED_COLUMNS = %w[id status ref user_id].freeze + ALLOWED_INDEXED_COLUMNS = %w[id status ref updated_at user_id].freeze def initialize(project, current_user, params = {}) @project = project diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 71338fedbe9..205bf4a5a26 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -99,6 +99,8 @@ module Issuable scope :of_milestones, ->(ids) { where(milestone_id: ids) } scope :any_milestone, -> { where('milestone_id IS NOT NULL') } scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } + scope :any_release, -> { joins_milestone_releases } + scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) } scope :opened, -> { with_state(:opened) } scope :only_opened, -> { with_state(:opened) } scope :closed, -> { with_state(:closed) } @@ -120,6 +122,16 @@ module Issuable scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) } scope :order_milestone_due_asc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC')) } + scope :without_release, -> do + joins("LEFT OUTER JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id") + .where('milestone_releases.release_id IS NULL') + end + + scope :joins_milestone_releases, -> do + joins("JOIN milestone_releases ON issues.milestone_id = milestone_releases.milestone_id + JOIN releases ON milestone_releases.release_id = releases.id").distinct + end + scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) } scope :any_label, -> { joins(:label_links).group(:id) } scope :join_project, -> { joins(:project) } diff --git a/changelogs/unreleased/13768-fix-redo-icn.yml b/changelogs/unreleased/13768-fix-redo-icn.yml index 3ef194bc4b0..ddac33ef6f5 100644 --- a/changelogs/unreleased/13768-fix-redo-icn.yml +++ b/changelogs/unreleased/13768-fix-redo-icn.yml @@ -1,5 +1,5 @@ --- -title: Replacing incorrect icon for Retry in Pipeline list page +title: Replacing incorrect icon in security dashboard. merge_request: 20510 author: type: changed diff --git a/changelogs/unreleased/33099-add-pipelines-api-order-by-updated-at.yml b/changelogs/unreleased/33099-add-pipelines-api-order-by-updated-at.yml new file mode 100644 index 00000000000..0648adc96bf --- /dev/null +++ b/changelogs/unreleased/33099-add-pipelines-api-order-by-updated-at.yml @@ -0,0 +1,5 @@ +--- +title: Allow order_by updated_at in Pipelines API +merge_request: 19886 +author: +type: added diff --git a/changelogs/unreleased/feat-ui-releases-pagination.yml b/changelogs/unreleased/feat-ui-releases-pagination.yml new file mode 100644 index 00000000000..8f6efe8ca01 --- /dev/null +++ b/changelogs/unreleased/feat-ui-releases-pagination.yml @@ -0,0 +1,5 @@ +--- +title: Implement pagination for project releases page +merge_request: 19912 +author: Fabio Huser +type: added diff --git a/changelogs/unreleased/include-worker-attributes-in-sidekiq-metrics-v2.yml b/changelogs/unreleased/include-worker-attributes-in-sidekiq-metrics-v2.yml new file mode 100644 index 00000000000..a5881b6e187 --- /dev/null +++ b/changelogs/unreleased/include-worker-attributes-in-sidekiq-metrics-v2.yml @@ -0,0 +1,5 @@ +--- +title: Add worker attributes to Sidekiq metrics +merge_request: 20292 +author: +type: other diff --git a/changelogs/unreleased/xanf-fix-unresolved-discussion-jump.yml b/changelogs/unreleased/xanf-fix-unresolved-discussion-jump.yml new file mode 100644 index 00000000000..4dedf4e8d1d --- /dev/null +++ b/changelogs/unreleased/xanf-fix-unresolved-discussion-jump.yml @@ -0,0 +1,5 @@ +--- +title: Ensure next unresolved discussion button takes user to the right place +merge_request: 20620 +author: +type: fixed diff --git a/db/migrate/20191111175230_add_index_on_ci_pipelines_updated_at.rb b/db/migrate/20191111175230_add_index_on_ci_pipelines_updated_at.rb new file mode 100644 index 00000000000..566bb16ac65 --- /dev/null +++ b/db/migrate/20191111175230_add_index_on_ci_pipelines_updated_at.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class AddIndexOnCiPipelinesUpdatedAt < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + INDEX_COLUMNS = [:project_id, :status, :updated_at] + + disable_ddl_transaction! + + def up + add_concurrent_index(:ci_pipelines, INDEX_COLUMNS) + end + + def down + remove_concurrent_index(:ci_pipelines, INDEX_COLUMNS) + end +end diff --git a/db/schema.rb b/db/schema.rb index 7b21db5b098..5c22ef8fe4b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -851,6 +851,7 @@ ActiveRecord::Schema.define(version: 2019_11_19_023952) do t.index ["project_id", "sha"], name: "index_ci_pipelines_on_project_id_and_sha" t.index ["project_id", "source"], name: "index_ci_pipelines_on_project_id_and_source" t.index ["project_id", "status", "config_source"], name: "index_ci_pipelines_on_project_id_and_status_and_config_source" + t.index ["project_id", "status", "updated_at"], name: "index_ci_pipelines_on_project_id_and_status_and_updated_at" t.index ["project_id"], name: "index_ci_pipelines_on_project_id" t.index ["status"], name: "index_ci_pipelines_on_status" t.index ["user_id"], name: "index_ci_pipelines_on_user_id" diff --git a/doc/administration/operations/extra_sidekiq_processes.md b/doc/administration/operations/extra_sidekiq_processes.md index 0b5ddfd03ee..e15f91ebab2 100644 --- a/doc/administration/operations/extra_sidekiq_processes.md +++ b/doc/administration/operations/extra_sidekiq_processes.md @@ -126,12 +126,26 @@ queues will use three threads in total. ## Limiting concurrency -To limit the concurrency of the Sidekiq processes: +To limit the concurrency of the Sidekiq process: 1. Edit `/etc/gitlab/gitlab.rb` and add: ```ruby - sidekiq_cluster['concurrency'] = 25 + sidekiq['concurrency'] = 25 + ``` + +1. Save the file and reconfigure GitLab for the changes to take effect: + + ```sh + sudo gitlab-ctl reconfigure + ``` + +To limit the max concurrency of the Sidekiq cluster processes: + +1. Edit `/etc/gitlab/gitlab.rb` and add: + + ```ruby + sidekiq_cluster['max_concurrency'] = 25 ``` 1. Save the file and reconfigure GitLab for the changes to take effect: diff --git a/doc/api/pipelines.md b/doc/api/pipelines.md index 90a4f8d6e26..97dc316cc96 100644 --- a/doc/api/pipelines.md +++ b/doc/api/pipelines.md @@ -18,7 +18,7 @@ GET /projects/:id/pipelines | `yaml_errors`| boolean | no | Returns pipelines with invalid configurations | | `name`| string | no | The name of the user who triggered pipelines | | `username`| string | no | The username of the user who triggered pipelines | -| `order_by`| string | no | Order pipelines by `id`, `status`, `ref`, or `user_id` (default: `id`) | +| `order_by`| string | no | Order pipelines by `id`, `status`, `ref`, `updated_at` or `user_id` (default: `id`) | | `sort` | string | no | Sort pipelines in `asc` or `desc` order (default: `desc`) | ``` diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 73e976a6145..ca04bbd0444 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -135,6 +135,7 @@ The following job parameters can be defined inside a `default:` block: - [`before_script`](#before_script-and-after_script) - [`after_script`](#before_script-and-after_script) - [`cache`](#cache) +- [`timeout`](#timeout) - [`interruptible`](#interruptible) In the following example, the `ruby:2.5` image is set as the default for all diff --git a/doc/user/project/clusters/add_remove_clusters.md b/doc/user/project/clusters/add_remove_clusters.md index 07ef8bca972..c73368fbbd2 100644 --- a/doc/user/project/clusters/add_remove_clusters.md +++ b/doc/user/project/clusters/add_remove_clusters.md @@ -206,9 +206,46 @@ GitLab supports: Before creating your first cluster on Amazon EKS with GitLab's integration, make sure the following requirements are met: +- Enable the `create_eks_clusters` feature flag for your GitLab instance. - An [Amazon Web Services](https://aws.amazon.com/) account is set up and you are able to log in. - You have permissions to manage IAM resources. +#### Enable the `create_eks_clusters` feature flag **(CORE ONLY)** + +NOTE: **Note:** +If you are running a self-managed instance, EKS cluster creation will not be available +unless the feature flag `create_eks_clusters` is enabled. This can be done from the Rails console +by instance administrators. + +Use these commands to start the Rails console: + +```sh +# Omnibus GitLab +gitlab-rails console + +# Installation from source +cd /home/git/gitlab +sudo -u git -H bin/rails console RAILS_ENV=production +``` + +Then run the following command to enable the feature flag: + +``` +Feature.enable(:create_eks_clusters) +``` + +You can also enable the feature flag only for specific projects with: + +``` +Feature.enable(:create_eks_clusters, Project.find_by_full_path('my_group/my_project')) +``` + +Run the following command to disable the feature flag: + +``` +Feature.disable(:create_eks_clusters) +``` + ##### Additional requirements for self-managed instances If you are using a self-managed GitLab instance, GitLab must first diff --git a/lib/gitlab/analytics/cycle_analytics/data_collector.rb b/lib/gitlab/analytics/cycle_analytics/data_collector.rb index 05b16672912..5eca364a697 100644 --- a/lib/gitlab/analytics/cycle_analytics/data_collector.rb +++ b/lib/gitlab/analytics/cycle_analytics/data_collector.rb @@ -42,3 +42,5 @@ module Gitlab end end end + +Gitlab::Analytics::CycleAnalytics::DataCollector.prepend_if_ee('EE::Gitlab::Analytics::CycleAnalytics::DataCollector') diff --git a/lib/gitlab/analytics/cycle_analytics/stage_query_helpers.rb b/lib/gitlab/analytics/cycle_analytics/stage_query_helpers.rb index 34c726b2254..29a2d55df1a 100644 --- a/lib/gitlab/analytics/cycle_analytics/stage_query_helpers.rb +++ b/lib/gitlab/analytics/cycle_analytics/stage_query_helpers.rb @@ -9,11 +9,11 @@ module Gitlab end def zero_interval - Arel::Nodes::NamedFunction.new("CAST", [Arel.sql("'0' AS INTERVAL")]) + Arel::Nodes::NamedFunction.new('CAST', [Arel.sql("'0' AS INTERVAL")]) end def round_duration_to_seconds - Arel::Nodes::Extract.new(duration, :epoch) + Arel::Nodes::NamedFunction.new('ROUND', [Arel::Nodes::Extract.new(duration, :epoch)]) end def duration diff --git a/lib/gitlab/ci/config/entry/default.rb b/lib/gitlab/ci/config/entry/default.rb index 83127bde6e4..646f06a60a9 100644 --- a/lib/gitlab/ci/config/entry/default.rb +++ b/lib/gitlab/ci/config/entry/default.rb @@ -14,7 +14,8 @@ module Gitlab include ::Gitlab::Config::Entry::Inheritable ALLOWED_KEYS = %i[before_script image services - after_script cache interruptible].freeze + after_script cache interruptible + timeout].freeze validations do validates :config, allowed_keys: ALLOWED_KEYS @@ -44,7 +45,11 @@ module Gitlab description: 'Set jobs interruptible default value.', inherit: false - helpers :before_script, :image, :services, :after_script, :cache, :interruptible + entry :timeout, Entry::Timeout, + description: 'Set jobs default timeout.', + inherit: false + + helpers :before_script, :image, :services, :after_script, :cache, :interruptible, :timeout private diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb index c75ae87a985..a109265f2a7 100644 --- a/lib/gitlab/ci/config/entry/job.rb +++ b/lib/gitlab/ci/config/entry/job.rb @@ -46,8 +46,6 @@ module Gitlab message: "should be one of: #{ALLOWED_WHEN.join(', ')}" } - validates :timeout, duration: { limit: ChronicDuration.output(Project::MAX_BUILD_TIMEOUT) } - validates :dependencies, array_of_strings: true validates :extends, array_of_strings_or_string: true validates :rules, array_of_hashes: true @@ -103,6 +101,10 @@ module Gitlab description: 'Set jobs interruptible value.', inherit: true + entry :timeout, Entry::Timeout, + description: 'Timeout duration of this job.', + inherit: true + entry :only, Entry::Policy, description: 'Refs policy this job will be executed for.', default: Entry::Policy::DEFAULT_ONLY, diff --git a/lib/gitlab/ci/config/entry/timeout.rb b/lib/gitlab/ci/config/entry/timeout.rb new file mode 100644 index 00000000000..0bffa9340de --- /dev/null +++ b/lib/gitlab/ci/config/entry/timeout.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents the interrutible value. + # + class Timeout < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + + validations do + validates :config, duration: { limit: ChronicDuration.output(Project::MAX_BUILD_TIMEOUT) } + end + end + end + end + end +end diff --git a/lib/gitlab/sidekiq_middleware/metrics.rb b/lib/gitlab/sidekiq_middleware/metrics.rb index bd819843bd4..7bfb0d54d80 100644 --- a/lib/gitlab/sidekiq_middleware/metrics.rb +++ b/lib/gitlab/sidekiq_middleware/metrics.rb @@ -7,14 +7,17 @@ module Gitlab # timeframes than the DEFAULT_BUCKET definition. Defined in seconds. SIDEKIQ_LATENCY_BUCKETS = [0.1, 0.25, 0.5, 1, 2.5, 5, 10, 60, 300, 600].freeze + TRUE_LABEL = "yes" + FALSE_LABEL = "no" + def initialize @metrics = init_metrics @metrics[:sidekiq_concurrency].set({}, Sidekiq.options[:concurrency].to_i) end - def call(_worker, job, queue) - labels = create_labels(queue) + def call(worker, job, queue) + labels = create_labels(worker.class, queue) queue_duration = ::Gitlab::InstrumentationHelper.queue_duration_for_job(job) @metrics[:sidekiq_jobs_queue_duration_seconds].observe(labels, queue_duration) if queue_duration @@ -42,7 +45,7 @@ module Gitlab @metrics[:sidekiq_jobs_failed_total].increment(labels, 1) unless job_succeeded # job_status: done, fail match the job_status attribute in structured logging - labels[:job_status] = job_succeeded ? :done : :fail + labels[:job_status] = job_succeeded ? "done" : "fail" @metrics[:sidekiq_jobs_cpu_seconds].observe(labels, job_thread_cputime) @metrics[:sidekiq_jobs_completion_seconds].observe(labels, monotonic_time) end @@ -62,10 +65,24 @@ module Gitlab } end - def create_labels(queue) - { - queue: queue - } + def create_labels(worker_class, queue) + labels = { queue: queue.to_s, latency_sensitive: FALSE_LABEL, external_dependencies: FALSE_LABEL, feature_category: "", boundary: "" } + return labels unless worker_class.include? WorkerAttributes + + labels[:latency_sensitive] = bool_as_label(worker_class.latency_sensitive_worker?) + labels[:external_dependencies] = bool_as_label(worker_class.worker_has_external_dependencies?) + + feature_category = worker_class.get_feature_category + labels[:feature_category] = feature_category.to_s + + resource_boundary = worker_class.get_worker_resource_boundary + labels[:boundary] = resource_boundary == :unknown ? "" : resource_boundary.to_s + + labels + end + + def bool_as_label(value) + value ? TRUE_LABEL : FALSE_LABEL end def get_thread_cputime diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 45b99b71e06..475ea4f0f7d 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -38,6 +38,7 @@ describe('issue_comment_form component', () => { }, store, sync: false, + attachToDocument: true, }); }; diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js index 91f9dab2530..3ccfea121b0 100644 --- a/spec/frontend/notes/components/discussion_actions_spec.js +++ b/spec/frontend/notes/components/discussion_actions_spec.js @@ -37,6 +37,8 @@ describe('DiscussionActions', () => { shouldShowJumpToNextDiscussion: true, ...props, }, + sync: false, + attachToDocument: true, }); }; diff --git a/spec/frontend/notes/components/discussion_jump_to_next_button_spec.js b/spec/frontend/notes/components/discussion_jump_to_next_button_spec.js index fd439ba46bd..ed173eacfab 100644 --- a/spec/frontend/notes/components/discussion_jump_to_next_button_spec.js +++ b/spec/frontend/notes/components/discussion_jump_to_next_button_spec.js @@ -7,6 +7,7 @@ describe('JumpToNextDiscussionButton', () => { beforeEach(() => { wrapper = shallowMount(JumpToNextDiscussionButton, { sync: false, + attachToDocument: true, }); }); diff --git a/spec/frontend/notes/components/discussion_keyboard_navigator_spec.js b/spec/frontend/notes/components/discussion_keyboard_navigator_spec.js index 8881bedf3cc..b38cfa8fb4a 100644 --- a/spec/frontend/notes/components/discussion_keyboard_navigator_spec.js +++ b/spec/frontend/notes/components/discussion_keyboard_navigator_spec.js @@ -37,6 +37,7 @@ describe('notes/components/discussion_keyboard_navigator', () => { isDiff ? NEXT_DIFF_ID : NEXT_ID; notes.getters.previousUnresolvedDiscussionId = () => (currId, isDiff) => isDiff ? PREV_DIFF_ID : PREV_ID; + notes.getters.getDiscussion = () => id => ({ id }); storeOptions = { modules: { @@ -63,14 +64,18 @@ describe('notes/components/discussion_keyboard_navigator', () => { it('calls jumpToNextDiscussion when pressing `n`', () => { Mousetrap.trigger('n'); - expect(wrapper.vm.jumpToDiscussion).toHaveBeenCalledWith(expectedNextId); + expect(wrapper.vm.jumpToDiscussion).toHaveBeenCalledWith( + expect.objectContaining({ id: expectedNextId }), + ); expect(wrapper.vm.currentDiscussionId).toEqual(expectedNextId); }); it('calls jumpToPreviousDiscussion when pressing `p`', () => { Mousetrap.trigger('p'); - expect(wrapper.vm.jumpToDiscussion).toHaveBeenCalledWith(expectedPrevId); + expect(wrapper.vm.jumpToDiscussion).toHaveBeenCalledWith( + expect.objectContaining({ id: expectedPrevId }), + ); expect(wrapper.vm.currentDiscussionId).toEqual(expectedPrevId); }); }); diff --git a/spec/frontend/notes/components/discussion_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js index f77236b14bc..5ab26d742ca 100644 --- a/spec/frontend/notes/components/discussion_notes_spec.js +++ b/spec/frontend/notes/components/discussion_notes_spec.js @@ -36,6 +36,7 @@ describe('DiscussionNotes', () => { 'avatar-badge': '<span class="avatar-badge-slot-content" />', }, sync: false, + attachToDocument: true, }); }; diff --git a/spec/javascripts/notes/components/discussion_counter_spec.js b/spec/javascripts/notes/components/discussion_counter_spec.js index fecc0d604b1..2ad9428dd6f 100644 --- a/spec/javascripts/notes/components/discussion_counter_spec.js +++ b/spec/javascripts/notes/components/discussion_counter_spec.js @@ -27,6 +27,8 @@ describe('DiscussionCounter component', () => { describe('methods', () => { describe('jumpToFirstUnresolvedDiscussion', () => { it('expands unresolved discussion', () => { + window.mrTabs.currentAction = 'show'; + spyOn(vm, 'expandDiscussion').and.stub(); const discussions = [ { @@ -47,14 +49,39 @@ describe('DiscussionCounter component', () => { ...store.state, discussions, }); - setFixtures(` - <div class="discussion" data-discussion-id="${firstDiscussionId}"></div> - `); - vm.jumpToFirstUnresolvedDiscussion(); expect(vm.expandDiscussion).toHaveBeenCalledWith({ discussionId: firstDiscussionId }); }); + + it('jumps to first unresolved discussion from diff tab if all diff discussions are resolved', () => { + window.mrTabs.currentAction = 'diff'; + spyOn(vm, 'switchToDiscussionsTabAndJumpTo').and.stub(); + + const unresolvedId = discussionMock.id + 1; + const discussions = [ + { + ...discussionMock, + id: discussionMock.id, + diff_discussion: true, + notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: true }], + resolved: true, + }, + { + ...discussionMock, + id: unresolvedId, + notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: false }], + resolved: false, + }, + ]; + store.replaceState({ + ...store.state, + discussions, + }); + vm.jumpToFirstUnresolvedDiscussion(); + + expect(vm.switchToDiscussionsTabAndJumpTo).toHaveBeenCalledWith(unresolvedId); + }); }); }); }); diff --git a/spec/javascripts/releases/list/components/app_spec.js b/spec/javascripts/releases/list/components/app_spec.js index 471c442e497..994488581d7 100644 --- a/spec/javascripts/releases/list/components/app_spec.js +++ b/spec/javascripts/releases/list/components/app_spec.js @@ -1,15 +1,22 @@ import Vue from 'vue'; +import _ from 'underscore'; import app from '~/releases/list/components/app.vue'; import createStore from '~/releases/list/store'; import api from '~/api'; import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { resetStore } from '../store/helpers'; -import { releases } from '../../mock_data'; +import { + pageInfoHeadersWithoutPagination, + pageInfoHeadersWithPagination, + release, + releases, +} from '../../mock_data'; describe('Releases App ', () => { const Component = Vue.extend(app); let store; let vm; + let releasesPagination; const props = { projectId: 'gitlab-ce', @@ -19,6 +26,7 @@ describe('Releases App ', () => { beforeEach(() => { store = createStore(); + releasesPagination = _.range(21).map(index => ({ ...release, tag_name: `${index}.00` })); }); afterEach(() => { @@ -28,7 +36,7 @@ describe('Releases App ', () => { describe('while loading', () => { beforeEach(() => { - spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [] })); + spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [], headers: {} })); vm = mountComponentWithStore(Component, { props, store }); }); @@ -36,6 +44,7 @@ describe('Releases App ', () => { expect(vm.$el.querySelector('.js-loading')).not.toBeNull(); expect(vm.$el.querySelector('.js-empty-state')).toBeNull(); expect(vm.$el.querySelector('.js-success-state')).toBeNull(); + expect(vm.$el.querySelector('.gl-pagination')).toBeNull(); setTimeout(() => { done(); @@ -45,7 +54,9 @@ describe('Releases App ', () => { describe('with successful request', () => { beforeEach(() => { - spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: releases })); + spyOn(api, 'releases').and.returnValue( + Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination }), + ); vm = mountComponentWithStore(Component, { props, store }); }); @@ -54,6 +65,27 @@ describe('Releases App ', () => { expect(vm.$el.querySelector('.js-loading')).toBeNull(); expect(vm.$el.querySelector('.js-empty-state')).toBeNull(); expect(vm.$el.querySelector('.js-success-state')).not.toBeNull(); + expect(vm.$el.querySelector('.gl-pagination')).toBeNull(); + + done(); + }, 0); + }); + }); + + describe('with successful request and pagination', () => { + beforeEach(() => { + spyOn(api, 'releases').and.returnValue( + Promise.resolve({ data: releasesPagination, headers: pageInfoHeadersWithPagination }), + ); + vm = mountComponentWithStore(Component, { props, store }); + }); + + it('renders success state', done => { + setTimeout(() => { + expect(vm.$el.querySelector('.js-loading')).toBeNull(); + expect(vm.$el.querySelector('.js-empty-state')).toBeNull(); + expect(vm.$el.querySelector('.js-success-state')).not.toBeNull(); + expect(vm.$el.querySelector('.gl-pagination')).not.toBeNull(); done(); }, 0); @@ -62,7 +94,7 @@ describe('Releases App ', () => { describe('with empty request', () => { beforeEach(() => { - spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [] })); + spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [], headers: {} })); vm = mountComponentWithStore(Component, { props, store }); }); @@ -71,6 +103,7 @@ describe('Releases App ', () => { expect(vm.$el.querySelector('.js-loading')).toBeNull(); expect(vm.$el.querySelector('.js-empty-state')).not.toBeNull(); expect(vm.$el.querySelector('.js-success-state')).toBeNull(); + expect(vm.$el.querySelector('.gl-pagination')).toBeNull(); done(); }, 0); diff --git a/spec/javascripts/releases/list/store/actions_spec.js b/spec/javascripts/releases/list/store/actions_spec.js index 8e78a631a5f..c4b49c39e28 100644 --- a/spec/javascripts/releases/list/store/actions_spec.js +++ b/spec/javascripts/releases/list/store/actions_spec.js @@ -7,14 +7,17 @@ import { import state from '~/releases/list/store/state'; import * as types from '~/releases/list/store/mutation_types'; import api from '~/api'; +import { parseIntPagination } from '~/lib/utils/common_utils'; import testAction from 'spec/helpers/vuex_action_helper'; -import { releases } from '../../mock_data'; +import { pageInfoHeadersWithoutPagination, releases } from '../../mock_data'; describe('Releases State actions', () => { let mockedState; + let pageInfo; beforeEach(() => { mockedState = state(); + pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination); }); describe('requestReleases', () => { @@ -25,12 +28,16 @@ describe('Releases State actions', () => { describe('fetchReleases', () => { describe('success', () => { - it('dispatches requestReleases and receiveReleasesSuccess ', done => { - spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: releases })); + it('dispatches requestReleases and receiveReleasesSuccess', done => { + spyOn(api, 'releases').and.callFake((id, options) => { + expect(id).toEqual(1); + expect(options.page).toEqual('1'); + return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination }); + }); testAction( fetchReleases, - releases, + { projectId: 1 }, mockedState, [], [ @@ -38,7 +45,31 @@ describe('Releases State actions', () => { type: 'requestReleases', }, { - payload: releases, + payload: { data: releases, headers: pageInfoHeadersWithoutPagination }, + type: 'receiveReleasesSuccess', + }, + ], + done, + ); + }); + + it('dispatches requestReleases and receiveReleasesSuccess on page two', done => { + spyOn(api, 'releases').and.callFake((_, options) => { + expect(options.page).toEqual('2'); + return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination }); + }); + + testAction( + fetchReleases, + { page: '2', projectId: 1 }, + mockedState, + [], + [ + { + type: 'requestReleases', + }, + { + payload: { data: releases, headers: pageInfoHeadersWithoutPagination }, type: 'receiveReleasesSuccess', }, ], @@ -48,12 +79,12 @@ describe('Releases State actions', () => { }); describe('error', () => { - it('dispatches requestReleases and receiveReleasesError ', done => { + it('dispatches requestReleases and receiveReleasesError', done => { spyOn(api, 'releases').and.returnValue(Promise.reject()); testAction( fetchReleases, - null, + { projectId: null }, mockedState, [], [ @@ -74,9 +105,9 @@ describe('Releases State actions', () => { it('should commit RECEIVE_RELEASES_SUCCESS mutation', done => { testAction( receiveReleasesSuccess, - releases, + { data: releases, headers: pageInfoHeadersWithoutPagination }, mockedState, - [{ type: types.RECEIVE_RELEASES_SUCCESS, payload: releases }], + [{ type: types.RECEIVE_RELEASES_SUCCESS, payload: { pageInfo, data: releases } }], [], done, ); diff --git a/spec/javascripts/releases/list/store/mutations_spec.js b/spec/javascripts/releases/list/store/mutations_spec.js index d2577891495..d756c69d53b 100644 --- a/spec/javascripts/releases/list/store/mutations_spec.js +++ b/spec/javascripts/releases/list/store/mutations_spec.js @@ -1,13 +1,16 @@ import state from '~/releases/list/store/state'; import mutations from '~/releases/list/store/mutations'; import * as types from '~/releases/list/store/mutation_types'; -import { releases } from '../../mock_data'; +import { parseIntPagination } from '~/lib/utils/common_utils'; +import { pageInfoHeadersWithoutPagination, releases } from '../../mock_data'; describe('Releases Store Mutations', () => { let stateCopy; + let pageInfo; beforeEach(() => { stateCopy = state(); + pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination); }); describe('REQUEST_RELEASES', () => { @@ -20,7 +23,7 @@ describe('Releases Store Mutations', () => { describe('RECEIVE_RELEASES_SUCCESS', () => { beforeEach(() => { - mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, releases); + mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { pageInfo, data: releases }); }); it('sets is loading to false', () => { @@ -34,6 +37,10 @@ describe('Releases Store Mutations', () => { it('sets data', () => { expect(stateCopy.releases).toEqual(releases); }); + + it('sets pageInfo', () => { + expect(stateCopy.pageInfo).toEqual(pageInfo); + }); }); describe('RECEIVE_RELEASES_ERROR', () => { @@ -42,6 +49,7 @@ describe('Releases Store Mutations', () => { expect(stateCopy.isLoading).toEqual(false); expect(stateCopy.releases).toEqual([]); + expect(stateCopy.pageInfo).toEqual({}); }); }); }); diff --git a/spec/javascripts/releases/mock_data.js b/spec/javascripts/releases/mock_data.js index 7197eb7bca8..72875dff172 100644 --- a/spec/javascripts/releases/mock_data.js +++ b/spec/javascripts/releases/mock_data.js @@ -1,3 +1,21 @@ +export const pageInfoHeadersWithoutPagination = { + 'X-NEXT-PAGE': '', + 'X-PAGE': '1', + 'X-PER-PAGE': '20', + 'X-PREV-PAGE': '', + 'X-TOTAL': '19', + 'X-TOTAL-PAGES': '1', +}; + +export const pageInfoHeadersWithPagination = { + 'X-NEXT-PAGE': '2', + 'X-PAGE': '1', + 'X-PER-PAGE': '20', + 'X-PREV-PAGE': '', + 'X-TOTAL': '21', + 'X-TOTAL-PAGES': '2', +}; + export const release = { name: 'Bionic Beaver', tag_name: '18.04', diff --git a/spec/lib/gitlab/ci/config/entry/default_spec.rb b/spec/lib/gitlab/ci/config/entry/default_spec.rb index dad4f408e50..0366a63ef05 100644 --- a/spec/lib/gitlab/ci/config/entry/default_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/default_spec.rb @@ -26,7 +26,8 @@ describe Gitlab::Ci::Config::Entry::Default do it 'contains the expected node names' do expect(described_class.nodes.keys) .to match_array(%i[before_script image services - after_script cache interruptible]) + after_script cache interruptible + timeout]) end end end diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index fe83171c57a..b0e08e49d78 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -24,7 +24,7 @@ describe Gitlab::Ci::Config::Entry::Job do let(:result) do %i[before_script script stage type after_script cache image services only except rules needs variables artifacts - environment coverage retry interruptible] + environment coverage retry interruptible timeout] end it { is_expected.to match_array result } @@ -417,21 +417,21 @@ describe Gitlab::Ci::Config::Entry::Job do context 'when timeout value is not correct' do context 'when it is higher than instance wide timeout' do - let(:config) { { timeout: '3 months' } } + let(:config) { { timeout: '3 months', script: 'test' } } it 'returns error about value too high' do expect(entry).not_to be_valid expect(entry.errors) - .to include "job timeout should not exceed the limit" + .to include "timeout config should not exceed the limit" end end context 'when it is not a duration' do - let(:config) { { timeout: 100 } } + let(:config) { { timeout: 100, script: 'test' } } it 'returns error about wrong value' do expect(entry).not_to be_valid - expect(entry.errors).to include 'job timeout should be a duration' + expect(entry.errors).to include 'timeout config should be a duration' end end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index 4b1c7483b11..66f6402b9a2 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -1375,7 +1375,7 @@ module Gitlab end it 'raises an error for invalid number' do - expect { builds }.to raise_error('jobs:deploy_to_production timeout should be a duration') + expect { builds }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:deploy_to_production:timeout config should be a duration') end end diff --git a/spec/lib/gitlab/sidekiq_middleware/metrics_spec.rb b/spec/lib/gitlab/sidekiq_middleware/metrics_spec.rb index 0d8cff3a295..36c6f377bde 100644 --- a/spec/lib/gitlab/sidekiq_middleware/metrics_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/metrics_spec.rb @@ -3,106 +3,201 @@ require 'fast_spec_helper' describe Gitlab::SidekiqMiddleware::Metrics do - let(:middleware) { described_class.new } - let(:concurrency_metric) { double('concurrency metric') } - - let(:queue_duration_seconds) { double('queue duration seconds metric') } - let(:completion_seconds_metric) { double('completion seconds metric') } - let(:user_execution_seconds_metric) { double('user execution seconds metric') } - let(:failed_total_metric) { double('failed total metric') } - let(:retried_total_metric) { double('retried total metric') } - let(:running_jobs_metric) { double('running jobs metric') } - - before do - allow(Gitlab::Metrics).to receive(:histogram).with(:sidekiq_jobs_queue_duration_seconds, anything, anything, anything).and_return(queue_duration_seconds) - allow(Gitlab::Metrics).to receive(:histogram).with(:sidekiq_jobs_completion_seconds, anything, anything, anything).and_return(completion_seconds_metric) - allow(Gitlab::Metrics).to receive(:histogram).with(:sidekiq_jobs_cpu_seconds, anything, anything, anything).and_return(user_execution_seconds_metric) - allow(Gitlab::Metrics).to receive(:counter).with(:sidekiq_jobs_failed_total, anything).and_return(failed_total_metric) - allow(Gitlab::Metrics).to receive(:counter).with(:sidekiq_jobs_retried_total, anything).and_return(retried_total_metric) - allow(Gitlab::Metrics).to receive(:gauge).with(:sidekiq_running_jobs, anything, {}, :all).and_return(running_jobs_metric) - allow(Gitlab::Metrics).to receive(:gauge).with(:sidekiq_concurrency, anything, {}, :all).and_return(concurrency_metric) - - allow(concurrency_metric).to receive(:set) - end + context "with worker attribution" do + subject { described_class.new } - describe '#initialize' do - it 'sets general metrics' do - expect(concurrency_metric).to receive(:set).with({}, Sidekiq.options[:concurrency].to_i) + let(:queue) { :test } + let(:worker_class) { worker.class } + let(:job) { {} } + let(:job_status) { :done } + let(:labels_with_job_status) { labels.merge(job_status: job_status.to_s) } + let(:default_labels) { { queue: queue.to_s, boundary: "", external_dependencies: "no", feature_category: "", latency_sensitive: "no" } } + + shared_examples "a metrics middleware" do + context "with mocked prometheus" do + let(:concurrency_metric) { double('concurrency metric') } + + let(:queue_duration_seconds) { double('queue duration seconds metric') } + let(:completion_seconds_metric) { double('completion seconds metric') } + let(:user_execution_seconds_metric) { double('user execution seconds metric') } + let(:failed_total_metric) { double('failed total metric') } + let(:retried_total_metric) { double('retried total metric') } + let(:running_jobs_metric) { double('running jobs metric') } + + before do + allow(Gitlab::Metrics).to receive(:histogram).with(:sidekiq_jobs_queue_duration_seconds, anything, anything, anything).and_return(queue_duration_seconds) + allow(Gitlab::Metrics).to receive(:histogram).with(:sidekiq_jobs_completion_seconds, anything, anything, anything).and_return(completion_seconds_metric) + allow(Gitlab::Metrics).to receive(:histogram).with(:sidekiq_jobs_cpu_seconds, anything, anything, anything).and_return(user_execution_seconds_metric) + allow(Gitlab::Metrics).to receive(:counter).with(:sidekiq_jobs_failed_total, anything).and_return(failed_total_metric) + allow(Gitlab::Metrics).to receive(:counter).with(:sidekiq_jobs_retried_total, anything).and_return(retried_total_metric) + allow(Gitlab::Metrics).to receive(:gauge).with(:sidekiq_running_jobs, anything, {}, :all).and_return(running_jobs_metric) + allow(Gitlab::Metrics).to receive(:gauge).with(:sidekiq_concurrency, anything, {}, :all).and_return(concurrency_metric) + + allow(concurrency_metric).to receive(:set) + end + + describe '#initialize' do + it 'sets concurrency metrics' do + expect(concurrency_metric).to receive(:set).with({}, Sidekiq.options[:concurrency].to_i) + + subject + end + end + + describe '#call' do + let(:thread_cputime_before) { 1 } + let(:thread_cputime_after) { 2 } + let(:thread_cputime_duration) { thread_cputime_after - thread_cputime_before } + + let(:monotonic_time_before) { 11 } + let(:monotonic_time_after) { 20 } + let(:monotonic_time_duration) { monotonic_time_after - monotonic_time_before } + + let(:queue_duration_for_job) { 0.01 } + + before do + allow(subject).to receive(:get_thread_cputime).and_return(thread_cputime_before, thread_cputime_after) + allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(monotonic_time_before, monotonic_time_after) + allow(Gitlab::InstrumentationHelper).to receive(:queue_duration_for_job).with(job).and_return(queue_duration_for_job) + + expect(running_jobs_metric).to receive(:increment).with(labels, 1) + expect(running_jobs_metric).to receive(:increment).with(labels, -1) + + expect(queue_duration_seconds).to receive(:observe).with(labels, queue_duration_for_job) if queue_duration_for_job + expect(user_execution_seconds_metric).to receive(:observe).with(labels_with_job_status, thread_cputime_duration) + expect(completion_seconds_metric).to receive(:observe).with(labels_with_job_status, monotonic_time_duration) + end + + it 'yields block' do + expect { |b| subject.call(worker, job, :test, &b) }.to yield_control.once + end + + it 'sets queue specific metrics' do + subject.call(worker, job, :test) { nil } + end + + context 'when job_duration is not available' do + let(:queue_duration_for_job) { nil } + + it 'does not set the queue_duration_seconds histogram' do + expect(queue_duration_seconds).not_to receive(:observe) + + subject.call(worker, job, :test) { nil } + end + end + + context 'when error is raised' do + let(:job_status) { :fail } + + it 'sets sidekiq_jobs_failed_total and reraises' do + expect(failed_total_metric).to receive(:increment).with(labels, 1) + + expect { subject.call(worker, job, :test) { raise StandardError, "Failed" } }.to raise_error(StandardError, "Failed") + end + end + + context 'when job is retried' do + let(:job) { { 'retry_count' => 1 } } + + it 'sets sidekiq_jobs_retried_total metric' do + expect(retried_total_metric).to receive(:increment) + + subject.call(worker, job, :test) { nil } + end + end + end + end - middleware - end - end + context "with prometheus integrated" do + describe '#call' do + it 'yields block' do + expect { |b| subject.call(worker, job, :test, &b) }.to yield_control.once + end - it 'ignore user execution when measured 0' do - allow(completion_seconds_metric).to receive(:observe) + context 'when error is raised' do + let(:job_status) { :fail } - expect(user_execution_seconds_metric).not_to receive(:observe) - end + it 'sets sidekiq_jobs_failed_total and reraises' do + expect { subject.call(worker, job, :test) { raise StandardError, "Failed" } }.to raise_error(StandardError, "Failed") + end + end + end + end + end - describe '#call' do - let(:worker) { double(:worker) } + context "when workers are not attributed" do + class TestNonAttributedWorker + include Sidekiq::Worker + end + let(:worker) { TestNonAttributedWorker.new } + let(:labels) { default_labels } - let(:job) { {} } - let(:job_status) { :done } - let(:labels) { { queue: :test } } - let(:labels_with_job_status) { { queue: :test, job_status: job_status } } + it_behaves_like "a metrics middleware" + end - let(:thread_cputime_before) { 1 } - let(:thread_cputime_after) { 2 } - let(:thread_cputime_duration) { thread_cputime_after - thread_cputime_before } + context "when workers are attributed" do + def create_attributed_worker_class(latency_sensitive, external_dependencies, resource_boundary, category) + Class.new do + include Sidekiq::Worker + include WorkerAttributes + + latency_sensitive_worker! if latency_sensitive + worker_has_external_dependencies! if external_dependencies + worker_resource_boundary resource_boundary unless resource_boundary == :unknown + feature_category category unless category.nil? + end + end - let(:monotonic_time_before) { 11 } - let(:monotonic_time_after) { 20 } - let(:monotonic_time_duration) { monotonic_time_after - monotonic_time_before } + let(:latency_sensitive) { false } + let(:external_dependencies) { false } + let(:resource_boundary) { :unknown } + let(:feature_category) { nil } + let(:worker_class) { create_attributed_worker_class(latency_sensitive, external_dependencies, resource_boundary, feature_category) } + let(:worker) { worker_class.new } - let(:queue_duration_for_job) { 0.01 } + context "latency sensitive" do + let(:latency_sensitive) { true } + let(:labels) { default_labels.merge(latency_sensitive: "yes") } - before do - allow(middleware).to receive(:get_thread_cputime).and_return(thread_cputime_before, thread_cputime_after) - allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(monotonic_time_before, monotonic_time_after) - allow(Gitlab::InstrumentationHelper).to receive(:queue_duration_for_job).with(job).and_return(queue_duration_for_job) + it_behaves_like "a metrics middleware" + end - expect(running_jobs_metric).to receive(:increment).with(labels, 1) - expect(running_jobs_metric).to receive(:increment).with(labels, -1) + context "external dependencies" do + let(:external_dependencies) { true } + let(:labels) { default_labels.merge(external_dependencies: "yes") } - expect(queue_duration_seconds).to receive(:observe).with(labels, queue_duration_for_job) if queue_duration_for_job - expect(user_execution_seconds_metric).to receive(:observe).with(labels_with_job_status, thread_cputime_duration) - expect(completion_seconds_metric).to receive(:observe).with(labels_with_job_status, monotonic_time_duration) - end + it_behaves_like "a metrics middleware" + end - it 'yields block' do - expect { |b| middleware.call(worker, job, :test, &b) }.to yield_control.once - end + context "cpu boundary" do + let(:resource_boundary) { :cpu } + let(:labels) { default_labels.merge(boundary: "cpu") } - it 'sets queue specific metrics' do - middleware.call(worker, job, :test) { nil } - end + it_behaves_like "a metrics middleware" + end - context 'when job_duration is not available' do - let(:queue_duration_for_job) { nil } + context "memory boundary" do + let(:resource_boundary) { :memory } + let(:labels) { default_labels.merge(boundary: "memory") } - it 'does not set the queue_duration_seconds histogram' do - middleware.call(worker, job, :test) { nil } + it_behaves_like "a metrics middleware" end - end - context 'when job is retried' do - let(:job) { { 'retry_count' => 1 } } + context "feature category" do + let(:feature_category) { :authentication } + let(:labels) { default_labels.merge(feature_category: "authentication") } - it 'sets sidekiq_jobs_retried_total metric' do - expect(retried_total_metric).to receive(:increment) - - middleware.call(worker, job, :test) { nil } + it_behaves_like "a metrics middleware" end - end - - context 'when error is raised' do - let(:job_status) { :fail } - it 'sets sidekiq_jobs_failed_total and reraises' do - expect(failed_total_metric).to receive(:increment).with(labels, 1) + context "combined" do + let(:latency_sensitive) { true } + let(:external_dependencies) { true } + let(:resource_boundary) { :cpu } + let(:feature_category) { :authentication } + let(:labels) { default_labels.merge(latency_sensitive: "yes", external_dependencies: "yes", boundary: "cpu", feature_category: "authentication") } - expect { middleware.call(worker, job, :test) { raise StandardError, "Failed" } }.to raise_error(StandardError, "Failed") + it_behaves_like "a metrics middleware" end end end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index f7bef9e71e2..4a6a9026f77 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -852,4 +852,77 @@ describe Issuable do it_behaves_like 'matches_cross_reference_regex? fails fast' end end + + describe 'release scopes' do + let_it_be(:project) { create(:project) } + + let_it_be(:release_1) { create(:release, tag: 'v1.0', project: project) } + let_it_be(:release_2) { create(:release, tag: 'v2.0', project: project) } + let_it_be(:release_3) { create(:release, tag: 'v3.0', project: project) } + let_it_be(:release_4) { create(:release, tag: 'v4.0', project: project) } + + let_it_be(:milestone_1) { create(:milestone, releases: [release_1], title: 'm1', project: project) } + let_it_be(:milestone_2) { create(:milestone, releases: [release_1, release_2], title: 'm2', project: project) } + let_it_be(:milestone_3) { create(:milestone, releases: [release_2, release_4], title: 'm3', project: project) } + let_it_be(:milestone_4) { create(:milestone, releases: [release_3], title: 'm4', project: project) } + let_it_be(:milestone_5) { create(:milestone, releases: [release_3], title: 'm5', project: project) } + let_it_be(:milestone_6) { create(:milestone, title: 'm6', project: project) } + + let_it_be(:issue_1) { create(:issue, milestone: milestone_1, project: project) } + let_it_be(:issue_2) { create(:issue, milestone: milestone_1, project: project) } + let_it_be(:issue_3) { create(:issue, milestone: milestone_2, project: project) } + let_it_be(:issue_4) { create(:issue, milestone: milestone_5, project: project) } + let_it_be(:issue_5) { create(:issue, milestone: milestone_6, project: project) } + let_it_be(:issue_6) { create(:issue, project: project) } + + let_it_be(:items) { Issue.all } + + describe '#without_release' do + it 'returns the issues not tied to any milestone and the ones tied to milestone with no release' do + expect(items.without_release).to contain_exactly(issue_5, issue_6) + end + end + + describe '#any_release' do + it 'returns all issues tied to a release' do + expect(items.any_release).to contain_exactly(issue_1, issue_2, issue_3, issue_4) + end + end + + describe '#with_release' do + it 'returns the issues tied a specfic release' do + expect(items.with_release('v1.0', project.id)).to contain_exactly(issue_1, issue_2, issue_3) + end + + context 'when a release has a milestone with one issue and another one with no issue' do + it 'returns that one issue' do + expect(items.with_release('v2.0', project.id)).to contain_exactly(issue_3) + end + + context 'when the milestone with no issue is added as a filter' do + it 'returns an empty list' do + expect(items.with_release('v2.0', project.id).with_milestone('m3')).to be_empty + end + end + + context 'when the milestone with the issue is added as a filter' do + it 'returns this issue' do + expect(items.with_release('v2.0', project.id).with_milestone('m2')).to contain_exactly(issue_3) + end + end + end + + context 'when there is no issue under a specific release' do + it 'returns no issue' do + expect(items.with_release('v4.0', project.id)).to be_empty + end + end + + context 'when a non-existent release tag is passed in' do + it 'returns no issue' do + expect(items.with_release('v999.0', project.id)).to be_empty + end + end + end + end end |