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';
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) {
......
......@@ -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);
},
},
};
......
......@@ -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;
},
},
......
......@@ -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);
......
......@@ -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);
}
}
},
......
<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>
......
......@@ -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);
......
......@@ -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;
},
/**
......
......@@ -2,4 +2,5 @@ export default () => ({
isLoading: false,
hasError: false,
releases: [],
pageInfo: {},
});
This diff is collapsed.
......@@ -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
......
......@@ -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
......
......@@ -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]
......
......@@ -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?
......
......@@ -12,6 +12,7 @@
# group_id: integer
# project_id: integer
# milestone_title: string
# release_tag: string
# author_id: integer
# assignee_id: integer
# search: string
......
......@@ -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
......
......@@ -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) }
......
---
title: Replacing incorrect icon for Retry in Pipeline list page
title: Replacing incorrect icon in security dashboard.
merge_request: 20510
author:
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
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"
......
......@@ -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:
......
......@@ -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`) |
```
......
......@@ -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
......
......@@ -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
......
......@@ -42,3 +42,5 @@ module Gitlab
end
end
end
Gitlab::Analytics::CycleAnalytics::DataCollector.prepend_if_ee('EE::Gitlab::Analytics::CycleAnalytics::DataCollector')
......@@ -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
......
......@@ -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
......
......@@ -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,
......
# 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
# 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
......
......@@ -38,6 +38,7 @@ describe('issue_comment_form component', () => {
},
store,
sync: false,
attachToDocument: true,
});
};
......
......@@ -37,6 +37,8 @@ describe('DiscussionActions', () => {
shouldShowJumpToNextDiscussion: true,
...props,
},
sync: false,
attachToDocument: true,
});
};
......
......@@ -7,6 +7,7 @@ describe('JumpToNextDiscussionButton', () => {
beforeEach(() => {
wrapper = shallowMount(JumpToNextDiscussionButton, {
sync: false,
attachToDocument: true,
});
});
......
......@@ -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);
});
});
......
......@@ -36,6 +36,7 @@ describe('DiscussionNotes', () => {
'avatar-badge': '<span class="avatar-badge-slot-content" />',
},
sync: false,
attachToDocument: true,
});
};
......
......@@ -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);
});
});
});
});
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);
......
......@@ -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,
);
......
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({});
});
});
});
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',
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
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