Commit ea4762d4 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 68b6846f
...@@ -5,6 +5,8 @@ import { joinPaths } from './lib/utils/url_utility'; ...@@ -5,6 +5,8 @@ import { joinPaths } from './lib/utils/url_utility';
import flash from '~/flash'; import flash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
const DEFAULT_PER_PAGE = 20;
const Api = { const Api = {
groupsPath: '/api/:version/groups.json', groupsPath: '/api/:version/groups.json',
groupPath: '/api/:version/groups/:id', groupPath: '/api/:version/groups/:id',
...@@ -66,7 +68,7 @@ const Api = { ...@@ -66,7 +68,7 @@ const Api = {
params: Object.assign( params: Object.assign(
{ {
search: query, search: query,
per_page: 20, per_page: DEFAULT_PER_PAGE,
}, },
options, options,
), ),
...@@ -90,7 +92,7 @@ const Api = { ...@@ -90,7 +92,7 @@ const Api = {
.get(url, { .get(url, {
params: { params: {
search: query, search: query,
per_page: 20, per_page: DEFAULT_PER_PAGE,
}, },
}) })
.then(({ data }) => callback(data)); .then(({ data }) => callback(data));
...@@ -101,7 +103,7 @@ const Api = { ...@@ -101,7 +103,7 @@ const Api = {
const url = Api.buildUrl(Api.projectsPath); const url = Api.buildUrl(Api.projectsPath);
const defaults = { const defaults = {
search: query, search: query,
per_page: 20, per_page: DEFAULT_PER_PAGE,
simple: true, simple: true,
}; };
...@@ -126,7 +128,7 @@ const Api = { ...@@ -126,7 +128,7 @@ const Api = {
.get(url, { .get(url, {
params: { params: {
search: query, search: query,
per_page: 20, per_page: DEFAULT_PER_PAGE,
...options, ...options,
}, },
}) })
...@@ -235,7 +237,7 @@ const Api = { ...@@ -235,7 +237,7 @@ const Api = {
const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId); const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId);
const defaults = { const defaults = {
search: query, search: query,
per_page: 20, per_page: DEFAULT_PER_PAGE,
}; };
return axios return axios
.get(url, { .get(url, {
...@@ -325,7 +327,7 @@ const Api = { ...@@ -325,7 +327,7 @@ const Api = {
params: Object.assign( params: Object.assign(
{ {
search: query, search: query,
per_page: 20, per_page: DEFAULT_PER_PAGE,
}, },
options, options,
), ),
...@@ -355,7 +357,7 @@ const Api = { ...@@ -355,7 +357,7 @@ const Api = {
const url = Api.buildUrl(Api.userProjectsPath).replace(':id', userId); const url = Api.buildUrl(Api.userProjectsPath).replace(':id', userId);
const defaults = { const defaults = {
search: query, search: query,
per_page: 20, per_page: DEFAULT_PER_PAGE,
}; };
return axios return axios
.get(url, { .get(url, {
...@@ -371,7 +373,7 @@ const Api = { ...@@ -371,7 +373,7 @@ const Api = {
return axios.get(url, { return axios.get(url, {
params: { params: {
search: query, search: query,
per_page: 20, per_page: DEFAULT_PER_PAGE,
...options, ...options,
}, },
}); });
...@@ -403,10 +405,15 @@ const Api = { ...@@ -403,10 +405,15 @@ const Api = {
return axios.post(url); return axios.post(url);
}, },
releases(id) { releases(id, options = {}) {
const url = Api.buildUrl(this.releasesPath).replace(':id', encodeURIComponent(id)); 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) { release(projectPath, tagName) {
......
...@@ -19,6 +19,7 @@ export default { ...@@ -19,6 +19,7 @@ export default {
'resolvableDiscussionsCount', 'resolvableDiscussionsCount',
'firstUnresolvedDiscussionId', 'firstUnresolvedDiscussionId',
'unresolvedDiscussionsCount', 'unresolvedDiscussionsCount',
'getDiscussion',
]), ]),
isLoggedIn() { isLoggedIn() {
return this.getUserData.id; return this.getUserData.id;
...@@ -40,9 +41,10 @@ export default { ...@@ -40,9 +41,10 @@ export default {
...mapActions(['expandDiscussion']), ...mapActions(['expandDiscussion']),
jumpToFirstUnresolvedDiscussion() { jumpToFirstUnresolvedDiscussion() {
const diffTab = window.mrTabs.currentAction === 'diffs'; const diffTab = window.mrTabs.currentAction === 'diffs';
const discussionId = this.firstUnresolvedDiscussionId(diffTab); const discussionId =
this.firstUnresolvedDiscussionId(diffTab) || this.firstUnresolvedDiscussionId();
this.jumpToDiscussion(discussionId); const firstDiscussion = this.getDiscussion(discussionId);
this.jumpToDiscussion(firstDiscussion);
}, },
}, },
}; };
......
...@@ -19,7 +19,11 @@ export default { ...@@ -19,7 +19,11 @@ export default {
}; };
}, },
computed: { computed: {
...mapGetters(['nextUnresolvedDiscussionId', 'previousUnresolvedDiscussionId']), ...mapGetters([
'nextUnresolvedDiscussionId',
'previousUnresolvedDiscussionId',
'getDiscussion',
]),
}, },
mounted() { mounted() {
Mousetrap.bind('n', () => this.jumpToNextDiscussion()); Mousetrap.bind('n', () => this.jumpToNextDiscussion());
...@@ -33,14 +37,14 @@ export default { ...@@ -33,14 +37,14 @@ export default {
...mapActions(['expandDiscussion']), ...mapActions(['expandDiscussion']),
jumpToNextDiscussion() { jumpToNextDiscussion() {
const nextId = this.nextUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView); const nextId = this.nextUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView);
const nextDiscussion = this.getDiscussion(nextId);
this.jumpToDiscussion(nextId); this.jumpToDiscussion(nextDiscussion);
this.currentDiscussionId = nextId; this.currentDiscussionId = nextId;
}, },
jumpToPreviousDiscussion() { jumpToPreviousDiscussion() {
const prevId = this.previousUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView); const prevId = this.previousUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView);
const prevDiscussion = this.getDiscussion(prevId);
this.jumpToDiscussion(prevId); this.jumpToDiscussion(prevDiscussion);
this.currentDiscussionId = prevId; this.currentDiscussionId = prevId;
}, },
}, },
......
...@@ -84,6 +84,7 @@ export default { ...@@ -84,6 +84,7 @@ export default {
'hasUnresolvedDiscussions', 'hasUnresolvedDiscussions',
'showJumpToNextDiscussion', 'showJumpToNextDiscussion',
'getUserData', 'getUserData',
'getDiscussion',
]), ]),
currentUser() { currentUser() {
return this.getUserData; return this.getUserData;
...@@ -221,8 +222,9 @@ export default { ...@@ -221,8 +222,9 @@ export default {
this.discussion.id, this.discussion.id,
this.discussionsByDiffOrder, this.discussionsByDiffOrder,
); );
const nextDiscussion = this.getDiscussion(nextId);
this.jumpToDiscussion(nextId); this.jumpToDiscussion(nextDiscussion);
}, },
deleteNoteHandler(note) { deleteNoteHandler(note) {
this.$emit('noteDeleted', this.discussion, note); this.$emit('noteDeleted', this.discussion, note);
......
...@@ -35,20 +35,26 @@ export default { ...@@ -35,20 +35,26 @@ export default {
return false; 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) { if (id) {
const activeTab = window.mrTabs.currentAction; const activeTab = window.mrTabs.currentAction;
if (activeTab === 'diffs') { if (activeTab === 'diffs' && isDiffDiscussion) {
this.diffsJump(id); this.diffsJump(id);
} else if (activeTab === 'commits' || activeTab === 'pipelines') { } else if (activeTab === 'show') {
window.mrTabs.eventHub.$once('MergeRequestTabChange', () => {
setTimeout(() => this.discussionJump(id), 0);
});
window.mrTabs.tabShown('show');
} else {
this.discussionJump(id); this.discussionJump(id);
} else {
this.switchToDiscussionsTabAndJumpTo(id);
} }
} }
}, },
......
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { GlSkeletonLoading, GlEmptyState } from '@gitlab/ui'; 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'; import ReleaseBlock from './release_block.vue';
export default { export default {
...@@ -9,6 +15,7 @@ export default { ...@@ -9,6 +15,7 @@ export default {
GlSkeletonLoading, GlSkeletonLoading,
GlEmptyState, GlEmptyState,
ReleaseBlock, ReleaseBlock,
TablePagination,
}, },
props: { props: {
projectId: { projectId: {
...@@ -25,7 +32,7 @@ export default { ...@@ -25,7 +32,7 @@ export default {
}, },
}, },
computed: { computed: {
...mapState(['isLoading', 'releases', 'hasError']), ...mapState(['isLoading', 'releases', 'hasError', 'pageInfo']),
shouldRenderEmptyState() { shouldRenderEmptyState() {
return !this.releases.length && !this.hasError && !this.isLoading; return !this.releases.length && !this.hasError && !this.isLoading;
}, },
...@@ -34,10 +41,17 @@ export default { ...@@ -34,10 +41,17 @@ export default {
}, },
}, },
created() { created() {
this.fetchReleases(this.projectId); this.fetchReleases({
page: getParameterByName('page'),
projectId: this.projectId,
});
}, },
methods: { methods: {
...mapActions(['fetchReleases']), ...mapActions(['fetchReleases']),
onChangePage(page) {
historyPushState(buildUrlWithCurrentLocation(`?page=${page}`));
this.fetchReleases({ page, projectId: this.projectId });
},
}, },
}; };
</script> </script>
...@@ -67,6 +81,8 @@ export default { ...@@ -67,6 +81,8 @@ export default {
:class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }" :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
/> />
</div> </div>
<table-pagination v-if="!isLoading" :change="onChangePage" :page-info="pageInfo" />
</div> </div>
</template> </template>
<style> <style>
......
...@@ -2,6 +2,7 @@ import * as types from './mutation_types'; ...@@ -2,6 +2,7 @@ import * as types from './mutation_types';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import api from '~/api'; 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. * 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); ...@@ -16,17 +17,19 @@ export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES);
* *
* @param {String} projectId * @param {String} projectId
*/ */
export const fetchReleases = ({ dispatch }, projectId) => { export const fetchReleases = ({ dispatch }, { page = '1', projectId }) => {
dispatch('requestReleases'); dispatch('requestReleases');
api api
.releases(projectId) .releases(projectId, { page })
.then(({ data }) => dispatch('receiveReleasesSuccess', data)) .then(response => dispatch('receiveReleasesSuccess', response))
.catch(() => dispatch('receiveReleasesError')); .catch(() => dispatch('receiveReleasesError'));
}; };
export const receiveReleasesSuccess = ({ commit }, data) => export const receiveReleasesSuccess = ({ commit }, { data, headers }) => {
commit(types.RECEIVE_RELEASES_SUCCESS, data); const pageInfo = parseIntPagination(normalizeHeaders(headers));
commit(types.RECEIVE_RELEASES_SUCCESS, { data, pageInfo });
};
export const receiveReleasesError = ({ commit }) => { export const receiveReleasesError = ({ commit }) => {
commit(types.RECEIVE_RELEASES_ERROR); commit(types.RECEIVE_RELEASES_ERROR);
......
...@@ -13,13 +13,15 @@ export default { ...@@ -13,13 +13,15 @@ export default {
* Sets isLoading to false. * Sets isLoading to false.
* Sets hasError to false. * Sets hasError to false.
* Sets the received data * Sets the received data
* Sets the received pagination information
* @param {Object} state * @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.hasError = false;
state.isLoading = false; state.isLoading = false;
state.releases = data; state.releases = data;
state.pageInfo = pageInfo;
}, },
/** /**
......
...@@ -2,4 +2,5 @@ export default () => ({ ...@@ -2,4 +2,5 @@ export default () => ({
isLoading: false, isLoading: false,
hasError: false, hasError: false,
releases: [], releases: [],
pageInfo: {},
}); });
This diff is collapsed.
...@@ -38,7 +38,8 @@ module CycleAnalyticsParams ...@@ -38,7 +38,8 @@ module CycleAnalyticsParams
end end
def to_utc_time(field) 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
end end
......
...@@ -44,7 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -44,7 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, project.group) 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 end
respond_to :html respond_to :html
......
...@@ -24,7 +24,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -24,7 +24,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action do before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, @project.group) 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 end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions] around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions]
......
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
# group_id: integer # group_id: integer
# project_id: integer # project_id: integer
# milestone_title: string # milestone_title: string
# release_tag: string
# author_id: integer # author_id: integer
# author_username: string # author_username: string
# assignee_id: integer or 'None' or 'Any' # assignee_id: integer or 'None' or 'Any'
...@@ -59,6 +60,7 @@ class IssuableFinder ...@@ -59,6 +60,7 @@ class IssuableFinder
author_username author_username
label_name label_name
milestone_title milestone_title
release_tag
my_reaction_emoji my_reaction_emoji
search search
in in
...@@ -126,6 +128,7 @@ class IssuableFinder ...@@ -126,6 +128,7 @@ class IssuableFinder
items = by_non_archived(items) items = by_non_archived(items)
items = by_iids(items) items = by_iids(items)
items = by_milestone(items) items = by_milestone(items)
items = by_release(items)
items = by_label(items) items = by_label(items)
by_my_reaction_emoji(items) by_my_reaction_emoji(items)
end end
...@@ -364,6 +367,10 @@ class IssuableFinder ...@@ -364,6 +367,10 @@ class IssuableFinder
end end
end end
def releases?
params[:release_tag].present?
end
private private
def force_cte? def force_cte?
...@@ -570,6 +577,18 @@ class IssuableFinder ...@@ -570,6 +577,18 @@ class IssuableFinder
end end
# rubocop: enable CodeReuse/ActiveRecord # 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? def filter_by_no_milestone?
# Accepts `No Milestone` for compatibility # Accepts `No Milestone` for compatibility
params[:milestone_title].to_s.downcase == FILTER_NONE || params[:milestone_title] == Milestone::None.title params[:milestone_title].to_s.downcase == FILTER_NONE || params[:milestone_title] == Milestone::None.title
...@@ -588,6 +607,14 @@ class IssuableFinder ...@@ -588,6 +607,14 @@ class IssuableFinder
params[:milestone_title] == Milestone::Started.name params[:milestone_title] == Milestone::Started.name
end 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) def by_label(items)
return items unless labels? return items unless labels?
......
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
# group_id: integer # group_id: integer
# project_id: integer # project_id: integer
# milestone_title: string # milestone_title: string
# release_tag: string
# author_id: integer # author_id: integer
# assignee_id: integer # assignee_id: integer
# search: string # search: string
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
class PipelinesFinder class PipelinesFinder
attr_reader :project, :pipelines, :params, :current_user 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 = {}) def initialize(project, current_user, params = {})
@project = project @project = project
......
...@@ -99,6 +99,8 @@ module Issuable ...@@ -99,6 +99,8 @@ module Issuable
scope :of_milestones, ->(ids) { where(milestone_id: ids) } scope :of_milestones, ->(ids) { where(milestone_id: ids) }
scope :any_milestone, -> { where('milestone_id IS NOT NULL') } scope :any_milestone, -> { where('milestone_id IS NOT NULL') }
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } 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 :opened, -> { with_state(:opened) }
scope :only_opened, -> { with_state(:opened) } scope :only_opened, -> { with_state(:opened) }
scope :closed, -> { with_state(:closed) } scope :closed, -> { with_state(:closed) }
...@@ -120,6 +122,16 @@ module Issuable ...@@ -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_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 :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 :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 :any_label, -> { joins(:label_links).group(:id) }
scope :join_project, -> { joins(:project) } scope :join_project, -> { joins(:project) }
......
--- ---
title: Replacing incorrect icon for Retry in Pipeline list page title: Replacing incorrect icon in security dashboard.
merge_request: 20510 merge_request: 20510
author: author:
type: changed type: changed
---
title: Allow order_by updated_at in Pipelines API
merge_request: 19886
author:
type: added
---
title: Implement pagination for project releases page
merge_request: 19912
author: Fabio Huser
type: added
---
title: Add worker attributes to Sidekiq metrics
merge_request: 20292
author:
type: other
---
title: Ensure next unresolved discussion button takes user to the right place
merge_request: 20620
author:
type: fixed
# 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
...@@ -851,6 +851,7 @@ ActiveRecord::Schema.define(version: 2019_11_19_023952) do ...@@ -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", "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", "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", "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 ["project_id"], name: "index_ci_pipelines_on_project_id"
t.index ["status"], name: "index_ci_pipelines_on_status" t.index ["status"], name: "index_ci_pipelines_on_status"
t.index ["user_id"], name: "index_ci_pipelines_on_user_id" t.index ["user_id"], name: "index_ci_pipelines_on_user_id"
......
...@@ -126,12 +126,26 @@ queues will use three threads in total. ...@@ -126,12 +126,26 @@ queues will use three threads in total.
## Limiting concurrency ## 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: 1. Edit `/etc/gitlab/gitlab.rb` and add:
```ruby ```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: 1. Save the file and reconfigure GitLab for the changes to take effect:
......
...@@ -18,7 +18,7 @@ GET /projects/:id/pipelines ...@@ -18,7 +18,7 @@ GET /projects/:id/pipelines
| `yaml_errors`| boolean | no | Returns pipelines with invalid configurations | | `yaml_errors`| boolean | no | Returns pipelines with invalid configurations |
| `name`| string | no | The name of the user who triggered pipelines | | `name`| string | no | The name of the user who triggered pipelines |
| `username`| string | no | The username 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`) | | `sort` | string | no | Sort pipelines in `asc` or `desc` order (default: `desc`) |
``` ```
......
...@@ -135,6 +135,7 @@ The following job parameters can be defined inside a `default:` block: ...@@ -135,6 +135,7 @@ The following job parameters can be defined inside a `default:` block:
- [`before_script`](#before_script-and-after_script) - [`before_script`](#before_script-and-after_script)
- [`after_script`](#before_script-and-after_script) - [`after_script`](#before_script-and-after_script)
- [`cache`](#cache) - [`cache`](#cache)
- [`timeout`](#timeout)
- [`interruptible`](#interruptible) - [`interruptible`](#interruptible)
In the following example, the `ruby:2.5` image is set as the default for all In the following example, the `ruby:2.5` image is set as the default for all
......
...@@ -206,9 +206,46 @@ GitLab supports: ...@@ -206,9 +206,46 @@ GitLab supports:
Before creating your first cluster on Amazon EKS with GitLab's integration, Before creating your first cluster on Amazon EKS with GitLab's integration,
make sure the following requirements are met: 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. - 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. - 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 ##### Additional requirements for self-managed instances
If you are using a self-managed GitLab instance, GitLab must first If you are using a self-managed GitLab instance, GitLab must first
......
...@@ -42,3 +42,5 @@ module Gitlab ...@@ -42,3 +42,5 @@ module Gitlab
end end
end end
end end
Gitlab::Analytics::CycleAnalytics::DataCollector.prepend_if_ee('EE::Gitlab::Analytics::CycleAnalytics::DataCollector')
...@@ -9,11 +9,11 @@ module Gitlab ...@@ -9,11 +9,11 @@ module Gitlab
end end
def zero_interval def zero_interval
Arel::Nodes::NamedFunction.new("CAST", [Arel.sql("'0' AS INTERVAL")]) Arel::Nodes::NamedFunction.new('CAST', [Arel.sql("'0' AS INTERVAL")])
end end
def round_duration_to_seconds def round_duration_to_seconds
Arel::Nodes::Extract.new(duration, :epoch) Arel::Nodes::NamedFunction.new('ROUND', [Arel::Nodes::Extract.new(duration, :epoch)])
end end
def duration def duration
......
...@@ -14,7 +14,8 @@ module Gitlab ...@@ -14,7 +14,8 @@ module Gitlab
include ::Gitlab::Config::Entry::Inheritable include ::Gitlab::Config::Entry::Inheritable
ALLOWED_KEYS = %i[before_script image services ALLOWED_KEYS = %i[before_script image services
after_script cache interruptible].freeze after_script cache interruptible
timeout].freeze
validations do validations do
validates :config, allowed_keys: ALLOWED_KEYS validates :config, allowed_keys: ALLOWED_KEYS
...@@ -44,7 +45,11 @@ module Gitlab ...@@ -44,7 +45,11 @@ module Gitlab
description: 'Set jobs interruptible default value.', description: 'Set jobs interruptible default value.',
inherit: false 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 private
......
...@@ -46,8 +46,6 @@ module Gitlab ...@@ -46,8 +46,6 @@ module Gitlab
message: "should be one of: #{ALLOWED_WHEN.join(', ')}" 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 :dependencies, array_of_strings: true
validates :extends, array_of_strings_or_string: true validates :extends, array_of_strings_or_string: true
validates :rules, array_of_hashes: true validates :rules, array_of_hashes: true
...@@ -103,6 +101,10 @@ module Gitlab ...@@ -103,6 +101,10 @@ module Gitlab
description: 'Set jobs interruptible value.', description: 'Set jobs interruptible value.',
inherit: true inherit: true
entry :timeout, Entry::Timeout,
description: 'Timeout duration of this job.',
inherit: true
entry :only, Entry::Policy, entry :only, Entry::Policy,
description: 'Refs policy this job will be executed for.', description: 'Refs policy this job will be executed for.',
default: Entry::Policy::DEFAULT_ONLY, default: Entry::Policy::DEFAULT_ONLY,
......
# 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
...@@ -7,14 +7,17 @@ module Gitlab ...@@ -7,14 +7,17 @@ module Gitlab
# timeframes than the DEFAULT_BUCKET definition. Defined in seconds. # 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 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 def initialize
@metrics = init_metrics @metrics = init_metrics
@metrics[:sidekiq_concurrency].set({}, Sidekiq.options[:concurrency].to_i) @metrics[:sidekiq_concurrency].set({}, Sidekiq.options[:concurrency].to_i)
end end
def call(_worker, job, queue) def call(worker, job, queue)
labels = create_labels(queue) labels = create_labels(worker.class, queue)
queue_duration = ::Gitlab::InstrumentationHelper.queue_duration_for_job(job) queue_duration = ::Gitlab::InstrumentationHelper.queue_duration_for_job(job)
@metrics[:sidekiq_jobs_queue_duration_seconds].observe(labels, queue_duration) if queue_duration @metrics[:sidekiq_jobs_queue_duration_seconds].observe(labels, queue_duration) if queue_duration
...@@ -42,7 +45,7 @@ module Gitlab ...@@ -42,7 +45,7 @@ module Gitlab
@metrics[:sidekiq_jobs_failed_total].increment(labels, 1) unless job_succeeded @metrics[:sidekiq_jobs_failed_total].increment(labels, 1) unless job_succeeded
# job_status: done, fail match the job_status attribute in structured logging # 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_cpu_seconds].observe(labels, job_thread_cputime)
@metrics[:sidekiq_jobs_completion_seconds].observe(labels, monotonic_time) @metrics[:sidekiq_jobs_completion_seconds].observe(labels, monotonic_time)
end end
...@@ -62,10 +65,24 @@ module Gitlab ...@@ -62,10 +65,24 @@ module Gitlab
} }
end end
def create_labels(queue) def create_labels(worker_class, queue)
{ labels = { queue: queue.to_s, latency_sensitive: FALSE_LABEL, external_dependencies: FALSE_LABEL, feature_category: "", boundary: "" }
queue: queue 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 end
def get_thread_cputime def get_thread_cputime
......
...@@ -38,6 +38,7 @@ describe('issue_comment_form component', () => { ...@@ -38,6 +38,7 @@ describe('issue_comment_form component', () => {
}, },
store, store,
sync: false, sync: false,
attachToDocument: true,
}); });
}; };
......
...@@ -37,6 +37,8 @@ describe('DiscussionActions', () => { ...@@ -37,6 +37,8 @@ describe('DiscussionActions', () => {
shouldShowJumpToNextDiscussion: true, shouldShowJumpToNextDiscussion: true,
...props, ...props,
}, },
sync: false,
attachToDocument: true,
}); });
}; };
......
...@@ -7,6 +7,7 @@ describe('JumpToNextDiscussionButton', () => { ...@@ -7,6 +7,7 @@ describe('JumpToNextDiscussionButton', () => {
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(JumpToNextDiscussionButton, { wrapper = shallowMount(JumpToNextDiscussionButton, {
sync: false, sync: false,
attachToDocument: true,
}); });
}); });
......
...@@ -37,6 +37,7 @@ describe('notes/components/discussion_keyboard_navigator', () => { ...@@ -37,6 +37,7 @@ describe('notes/components/discussion_keyboard_navigator', () => {
isDiff ? NEXT_DIFF_ID : NEXT_ID; isDiff ? NEXT_DIFF_ID : NEXT_ID;
notes.getters.previousUnresolvedDiscussionId = () => (currId, isDiff) => notes.getters.previousUnresolvedDiscussionId = () => (currId, isDiff) =>
isDiff ? PREV_DIFF_ID : PREV_ID; isDiff ? PREV_DIFF_ID : PREV_ID;
notes.getters.getDiscussion = () => id => ({ id });
storeOptions = { storeOptions = {
modules: { modules: {
...@@ -63,14 +64,18 @@ describe('notes/components/discussion_keyboard_navigator', () => { ...@@ -63,14 +64,18 @@ describe('notes/components/discussion_keyboard_navigator', () => {
it('calls jumpToNextDiscussion when pressing `n`', () => { it('calls jumpToNextDiscussion when pressing `n`', () => {
Mousetrap.trigger('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); expect(wrapper.vm.currentDiscussionId).toEqual(expectedNextId);
}); });
it('calls jumpToPreviousDiscussion when pressing `p`', () => { it('calls jumpToPreviousDiscussion when pressing `p`', () => {
Mousetrap.trigger('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); expect(wrapper.vm.currentDiscussionId).toEqual(expectedPrevId);
}); });
}); });
......
...@@ -36,6 +36,7 @@ describe('DiscussionNotes', () => { ...@@ -36,6 +36,7 @@ describe('DiscussionNotes', () => {
'avatar-badge': '<span class="avatar-badge-slot-content" />', 'avatar-badge': '<span class="avatar-badge-slot-content" />',
}, },
sync: false, sync: false,
attachToDocument: true,
}); });
}; };
......
...@@ -27,6 +27,8 @@ describe('DiscussionCounter component', () => { ...@@ -27,6 +27,8 @@ describe('DiscussionCounter component', () => {
describe('methods', () => { describe('methods', () => {
describe('jumpToFirstUnresolvedDiscussion', () => { describe('jumpToFirstUnresolvedDiscussion', () => {
it('expands unresolved discussion', () => { it('expands unresolved discussion', () => {
window.mrTabs.currentAction = 'show';
spyOn(vm, 'expandDiscussion').and.stub(); spyOn(vm, 'expandDiscussion').and.stub();
const discussions = [ const discussions = [
{ {
...@@ -47,14 +49,39 @@ describe('DiscussionCounter component', () => { ...@@ -47,14 +49,39 @@ describe('DiscussionCounter component', () => {
...store.state, ...store.state,
discussions, discussions,
}); });
setFixtures(`
<div class="discussion" data-discussion-id="${firstDiscussionId}"></div>
`);
vm.jumpToFirstUnresolvedDiscussion(); vm.jumpToFirstUnresolvedDiscussion();
expect(vm.expandDiscussion).toHaveBeenCalledWith({ discussionId: firstDiscussionId }); 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);
});
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import _ from 'underscore';
import app from '~/releases/list/components/app.vue'; import app from '~/releases/list/components/app.vue';
import createStore from '~/releases/list/store'; import createStore from '~/releases/list/store';
import api from '~/api'; import api from '~/api';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { resetStore } from '../store/helpers'; import { resetStore } from '../store/helpers';
import { releases } from '../../mock_data'; import {
pageInfoHeadersWithoutPagination,
pageInfoHeadersWithPagination,
release,
releases,
} from '../../mock_data';
describe('Releases App ', () => { describe('Releases App ', () => {
const Component = Vue.extend(app); const Component = Vue.extend(app);
let store; let store;
let vm; let vm;
let releasesPagination;
const props = { const props = {
projectId: 'gitlab-ce', projectId: 'gitlab-ce',
...@@ -19,6 +26,7 @@ describe('Releases App ', () => { ...@@ -19,6 +26,7 @@ describe('Releases App ', () => {
beforeEach(() => { beforeEach(() => {
store = createStore(); store = createStore();
releasesPagination = _.range(21).map(index => ({ ...release, tag_name: `${index}.00` }));
}); });
afterEach(() => { afterEach(() => {
...@@ -28,7 +36,7 @@ describe('Releases App ', () => { ...@@ -28,7 +36,7 @@ describe('Releases App ', () => {
describe('while loading', () => { describe('while loading', () => {
beforeEach(() => { beforeEach(() => {
spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [] })); spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [], headers: {} }));
vm = mountComponentWithStore(Component, { props, store }); vm = mountComponentWithStore(Component, { props, store });
}); });
...@@ -36,6 +44,7 @@ describe('Releases App ', () => { ...@@ -36,6 +44,7 @@ describe('Releases App ', () => {
expect(vm.$el.querySelector('.js-loading')).not.toBeNull(); expect(vm.$el.querySelector('.js-loading')).not.toBeNull();
expect(vm.$el.querySelector('.js-empty-state')).toBeNull(); expect(vm.$el.querySelector('.js-empty-state')).toBeNull();
expect(vm.$el.querySelector('.js-success-state')).toBeNull(); expect(vm.$el.querySelector('.js-success-state')).toBeNull();
expect(vm.$el.querySelector('.gl-pagination')).toBeNull();
setTimeout(() => { setTimeout(() => {
done(); done();
...@@ -45,7 +54,9 @@ describe('Releases App ', () => { ...@@ -45,7 +54,9 @@ describe('Releases App ', () => {
describe('with successful request', () => { describe('with successful request', () => {
beforeEach(() => { 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 }); vm = mountComponentWithStore(Component, { props, store });
}); });
...@@ -54,6 +65,27 @@ describe('Releases App ', () => { ...@@ -54,6 +65,27 @@ describe('Releases App ', () => {
expect(vm.$el.querySelector('.js-loading')).toBeNull(); expect(vm.$el.querySelector('.js-loading')).toBeNull();
expect(vm.$el.querySelector('.js-empty-state')).toBeNull(); expect(vm.$el.querySelector('.js-empty-state')).toBeNull();
expect(vm.$el.querySelector('.js-success-state')).not.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(); done();
}, 0); }, 0);
...@@ -62,7 +94,7 @@ describe('Releases App ', () => { ...@@ -62,7 +94,7 @@ describe('Releases App ', () => {
describe('with empty request', () => { describe('with empty request', () => {
beforeEach(() => { beforeEach(() => {
spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [] })); spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [], headers: {} }));
vm = mountComponentWithStore(Component, { props, store }); vm = mountComponentWithStore(Component, { props, store });
}); });
...@@ -71,6 +103,7 @@ describe('Releases App ', () => { ...@@ -71,6 +103,7 @@ describe('Releases App ', () => {
expect(vm.$el.querySelector('.js-loading')).toBeNull(); expect(vm.$el.querySelector('.js-loading')).toBeNull();
expect(vm.$el.querySelector('.js-empty-state')).not.toBeNull(); expect(vm.$el.querySelector('.js-empty-state')).not.toBeNull();
expect(vm.$el.querySelector('.js-success-state')).toBeNull(); expect(vm.$el.querySelector('.js-success-state')).toBeNull();
expect(vm.$el.querySelector('.gl-pagination')).toBeNull();
done(); done();
}, 0); }, 0);
......
...@@ -7,14 +7,17 @@ import { ...@@ -7,14 +7,17 @@ import {
import state from '~/releases/list/store/state'; import state from '~/releases/list/store/state';
import * as types from '~/releases/list/store/mutation_types'; import * as types from '~/releases/list/store/mutation_types';
import api from '~/api'; import api from '~/api';
import { parseIntPagination } from '~/lib/utils/common_utils';
import testAction from 'spec/helpers/vuex_action_helper'; import testAction from 'spec/helpers/vuex_action_helper';
import { releases } from '../../mock_data'; import { pageInfoHeadersWithoutPagination, releases } from '../../mock_data';
describe('Releases State actions', () => { describe('Releases State actions', () => {
let mockedState; let mockedState;
let pageInfo;
beforeEach(() => { beforeEach(() => {
mockedState = state(); mockedState = state();
pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination);
}); });
describe('requestReleases', () => { describe('requestReleases', () => {
...@@ -25,12 +28,16 @@ describe('Releases State actions', () => { ...@@ -25,12 +28,16 @@ describe('Releases State actions', () => {
describe('fetchReleases', () => { describe('fetchReleases', () => {
describe('success', () => { describe('success', () => {
it('dispatches requestReleases and receiveReleasesSuccess ', done => { it('dispatches requestReleases and receiveReleasesSuccess', done => {
spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: releases })); spyOn(api, 'releases').and.callFake((id, options) => {
expect(id).toEqual(1);
expect(options.page).toEqual('1');
return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination });
});
testAction( testAction(
fetchReleases, fetchReleases,
releases, { projectId: 1 },
mockedState, mockedState,
[], [],
[ [
...@@ -38,7 +45,31 @@ describe('Releases State actions', () => { ...@@ -38,7 +45,31 @@ describe('Releases State actions', () => {
type: 'requestReleases', 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', type: 'receiveReleasesSuccess',
}, },
], ],
...@@ -48,12 +79,12 @@ describe('Releases State actions', () => { ...@@ -48,12 +79,12 @@ describe('Releases State actions', () => {
}); });
describe('error', () => { describe('error', () => {
it('dispatches requestReleases and receiveReleasesError ', done => { it('dispatches requestReleases and receiveReleasesError', done => {
spyOn(api, 'releases').and.returnValue(Promise.reject()); spyOn(api, 'releases').and.returnValue(Promise.reject());
testAction( testAction(
fetchReleases, fetchReleases,
null, { projectId: null },
mockedState, mockedState,
[], [],
[ [
...@@ -74,9 +105,9 @@ describe('Releases State actions', () => { ...@@ -74,9 +105,9 @@ describe('Releases State actions', () => {
it('should commit RECEIVE_RELEASES_SUCCESS mutation', done => { it('should commit RECEIVE_RELEASES_SUCCESS mutation', done => {
testAction( testAction(
receiveReleasesSuccess, receiveReleasesSuccess,
releases, { data: releases, headers: pageInfoHeadersWithoutPagination },
mockedState, mockedState,
[{ type: types.RECEIVE_RELEASES_SUCCESS, payload: releases }], [{ type: types.RECEIVE_RELEASES_SUCCESS, payload: { pageInfo, data: releases } }],
[], [],
done, done,
); );
......
import state from '~/releases/list/store/state'; import state from '~/releases/list/store/state';
import mutations from '~/releases/list/store/mutations'; import mutations from '~/releases/list/store/mutations';
import * as types from '~/releases/list/store/mutation_types'; 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', () => { describe('Releases Store Mutations', () => {
let stateCopy; let stateCopy;
let pageInfo;
beforeEach(() => { beforeEach(() => {
stateCopy = state(); stateCopy = state();
pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination);
}); });
describe('REQUEST_RELEASES', () => { describe('REQUEST_RELEASES', () => {
...@@ -20,7 +23,7 @@ describe('Releases Store Mutations', () => { ...@@ -20,7 +23,7 @@ describe('Releases Store Mutations', () => {
describe('RECEIVE_RELEASES_SUCCESS', () => { describe('RECEIVE_RELEASES_SUCCESS', () => {
beforeEach(() => { beforeEach(() => {
mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, releases); mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { pageInfo, data: releases });
}); });
it('sets is loading to false', () => { it('sets is loading to false', () => {
...@@ -34,6 +37,10 @@ describe('Releases Store Mutations', () => { ...@@ -34,6 +37,10 @@ describe('Releases Store Mutations', () => {
it('sets data', () => { it('sets data', () => {
expect(stateCopy.releases).toEqual(releases); expect(stateCopy.releases).toEqual(releases);
}); });
it('sets pageInfo', () => {
expect(stateCopy.pageInfo).toEqual(pageInfo);
});
}); });
describe('RECEIVE_RELEASES_ERROR', () => { describe('RECEIVE_RELEASES_ERROR', () => {
...@@ -42,6 +49,7 @@ describe('Releases Store Mutations', () => { ...@@ -42,6 +49,7 @@ describe('Releases Store Mutations', () => {
expect(stateCopy.isLoading).toEqual(false); expect(stateCopy.isLoading).toEqual(false);
expect(stateCopy.releases).toEqual([]); expect(stateCopy.releases).toEqual([]);
expect(stateCopy.pageInfo).toEqual({});
}); });
}); });
}); });
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 = { export const release = {
name: 'Bionic Beaver', name: 'Bionic Beaver',
tag_name: '18.04', tag_name: '18.04',
......
...@@ -26,7 +26,8 @@ describe Gitlab::Ci::Config::Entry::Default do ...@@ -26,7 +26,8 @@ describe Gitlab::Ci::Config::Entry::Default do
it 'contains the expected node names' do it 'contains the expected node names' do
expect(described_class.nodes.keys) expect(described_class.nodes.keys)
.to match_array(%i[before_script image services .to match_array(%i[before_script image services
after_script cache interruptible]) after_script cache interruptible
timeout])
end end
end end
end end
......
...@@ -24,7 +24,7 @@ describe Gitlab::Ci::Config::Entry::Job do ...@@ -24,7 +24,7 @@ describe Gitlab::Ci::Config::Entry::Job do
let(:result) do let(:result) do
%i[before_script script stage type after_script cache %i[before_script script stage type after_script cache
image services only except rules needs variables artifacts image services only except rules needs variables artifacts
environment coverage retry interruptible] environment coverage retry interruptible timeout]
end end
it { is_expected.to match_array result } it { is_expected.to match_array result }
...@@ -417,21 +417,21 @@ describe Gitlab::Ci::Config::Entry::Job do ...@@ -417,21 +417,21 @@ describe Gitlab::Ci::Config::Entry::Job do
context 'when timeout value is not correct' do context 'when timeout value is not correct' do
context 'when it is higher than instance wide timeout' 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 it 'returns error about value too high' do
expect(entry).not_to be_valid expect(entry).not_to be_valid
expect(entry.errors) expect(entry.errors)
.to include "job timeout should not exceed the limit" .to include "timeout config should not exceed the limit"
end end
end end
context 'when it is not a duration' do 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 it 'returns error about wrong value' do
expect(entry).not_to be_valid 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 end
end end
......
...@@ -1375,7 +1375,7 @@ module Gitlab ...@@ -1375,7 +1375,7 @@ module Gitlab
end end
it 'raises an error for invalid number' do 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
end end
......
...@@ -852,4 +852,77 @@ describe Issuable do ...@@ -852,4 +852,77 @@ describe Issuable do
it_behaves_like 'matches_cross_reference_regex? fails fast' it_behaves_like 'matches_cross_reference_regex? fails fast'
end end
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 end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment