Merge branch 'master' into new-nav-fix-contextual-breadcrumbs

This commit is contained in:
Phil Hughes 2017-07-07 18:19:06 +01:00
commit 436047f657
100 changed files with 2013 additions and 391 deletions

View file

@ -1 +1 @@
0.14.0
0.15.0

View file

@ -386,7 +386,7 @@ gem 'vmstat', '~> 2.3.0'
gem 'sys-filesystem', '~> 1.1.6'
# Gitaly GRPC client
gem 'gitaly', '~> 0.9.0'
gem 'gitaly', '~> 0.13.0'
gem 'toml-rb', '~> 0.3.15', require: false

View file

@ -278,7 +278,7 @@ GEM
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gherkin-ruby (0.3.2)
gitaly (0.9.0)
gitaly (0.13.0)
google-protobuf (~> 3.1)
grpc (~> 1.0)
github-linguist (4.7.6)
@ -980,7 +980,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.2.0)
gitaly (~> 0.9.0)
gitaly (~> 0.13.0)
github-linguist (~> 4.7.0)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-markup (~> 1.5.1)

View file

@ -3,6 +3,7 @@ import Translate from '../vue_shared/translate';
import intervalPatternInput from './components/interval_pattern_input.vue';
import TimezoneDropdown from './components/timezone_dropdown';
import TargetBranchDropdown from './components/target_branch_dropdown';
import { setupPipelineVariableList } from './setup_pipeline_variable_list';
Vue.use(Translate);
@ -39,4 +40,6 @@ document.addEventListener('DOMContentLoaded', () => {
gl.timezoneDropdown = new TimezoneDropdown();
gl.targetBranchDropdown = new TargetBranchDropdown();
gl.pipelineScheduleFieldErrors = new gl.GlFieldErrors(formElement);
setupPipelineVariableList($('.js-pipeline-variable-list'));
});

View file

@ -0,0 +1,71 @@
function insertRow($row) {
const $rowClone = $row.clone();
$rowClone.removeAttr('data-is-persisted');
$rowClone.find('input, textarea').val('');
$row.after($rowClone);
}
function removeRow($row) {
const isPersisted = gl.utils.convertPermissionToBoolean($row.attr('data-is-persisted'));
if (isPersisted) {
$row.hide();
$row
.find('.js-destroy-input')
.val(1);
} else {
$row.remove();
}
}
function checkIfRowTouched($row) {
return $row.find('.js-user-input').toArray().some(el => $(el).val().length > 0);
}
function setupPipelineVariableList(parent = document) {
const $parent = $(parent);
$parent.on('click', '.js-row-remove-button', (e) => {
const $row = $(e.currentTarget).closest('.js-row');
removeRow($row);
e.preventDefault();
});
// Remove any empty rows except the last r
$parent.on('blur', '.js-user-input', (e) => {
const $row = $(e.currentTarget).closest('.js-row');
const isTouched = checkIfRowTouched($row);
if ($row.is(':not(:last-child)') && !isTouched) {
removeRow($row);
}
});
// Always make sure there is an empty last row
$parent.on('input', '.js-user-input', () => {
const $lastRow = $parent.find('.js-row').last();
const isTouched = checkIfRowTouched($lastRow);
if (isTouched) {
insertRow($lastRow);
}
});
// Clear out the empty last row so it
// doesn't get submitted and throw validation errors
$parent.closest('form').on('submit', () => {
const $lastRow = $parent.find('.js-row').last();
const isTouched = checkIfRowTouched($lastRow);
if (!isTouched) {
$lastRow.find('input, textarea').attr('name', '');
}
});
}
export {
setupPipelineVariableList,
insertRow,
removeRow,
};

View file

@ -153,6 +153,7 @@ $code_line_height: 1.6;
* Padding
*/
$gl-padding: 16px;
$gl-col-padding: 15px;
$gl-btn-padding: 10px;
$gl-input-padding: 10px;
$gl-vert-padding: 6px;
@ -443,6 +444,7 @@ $logs-p-color: #333;
/*
* Forms
*/
$input-height: 34px;
$input-danger-bg: #f2dede;
$input-danger-border: $red-400;
$input-group-addon-bg: #f7f8fa;
@ -574,6 +576,12 @@ $stage-hover-bg: #eaf3fc;
$stage-hover-border: #d1e7fc;
$action-icon-color: #d6d6d6;
/*
Pipeline Schedules
*/
$pipeline-variable-remove-button-width: calc(1em + #{2 * $gl-padding});
/*
Filtered Search
*/

View file

@ -74,3 +74,84 @@
margin-right: 3px;
}
}
.pipeline-variable-list {
margin-left: 0;
margin-bottom: 0;
padding-left: 0;
list-style: none;
clear: both;
}
.pipeline-variable-row {
display: flex;
align-items: flex-end;
&:not(:last-child) {
margin-bottom: $gl-btn-padding;
}
@media (max-width: $screen-sm-max) {
padding-right: $gl-col-padding;
}
&:last-child {
& .pipeline-variable-row-remove-button {
display: none;
}
@media (max-width: $screen-sm-max) {
& .pipeline-variable-value-input {
margin-right: $pipeline-variable-remove-button-width;
}
}
@media (max-width: $screen-xs-max) {
.pipeline-variable-row-body {
margin-right: $pipeline-variable-remove-button-width;
}
}
}
}
.pipeline-variable-row-body {
display: flex;
width: calc(75% - #{$gl-col-padding});
padding-left: $gl-col-padding;
@media (max-width: $screen-sm-max) {
width: 100%;
}
@media (max-width: $screen-xs-max) {
display: block;
}
}
.pipeline-variable-key-input {
margin-right: $gl-btn-padding;
@media (max-width: $screen-xs-max) {
margin-bottom: $gl-btn-padding;
}
}
.pipeline-variable-row-remove-button {
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: center;
width: $pipeline-variable-remove-button-width;
height: $input-height;
padding: 0;
background: transparent;
border: 0;
color: $gl-text-color-secondary;
@include transition(color);
&:hover,
&:focus {
outline: none;
color: $gl-text-color;
}
}

View file

@ -1,9 +1,14 @@
class Dashboard::LabelsController < Dashboard::ApplicationController
def index
labels = LabelsFinder.new(current_user).execute
respond_to do |format|
format.json { render json: LabelSerializer.new.represent_appearance(labels) }
end
end
def labels
finder_params = { project_ids: projects.select(:id) }
labels = LabelsFinder.new(current_user, finder_params).execute
GlobalLabel.build_collection(labels)
end
end

View file

@ -2,13 +2,13 @@ class Groups::MilestonesController < Groups::ApplicationController
include MilestoneActions
before_action :group_projects
before_action :milestone, only: [:show, :update, :merge_requests, :participants, :labels]
before_action :authorize_admin_milestones!, only: [:new, :create, :update]
before_action :milestone, only: [:edit, :show, :update, :merge_requests, :participants, :labels]
before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update]
def index
respond_to do |format|
format.html do
@milestone_states = GlobalMilestone.states_count(@projects)
@milestone_states = GlobalMilestone.states_count(group_projects, group)
@milestones = Kaminari.paginate_array(milestones).page(params[:page])
end
format.json do
@ -22,50 +22,42 @@ class Groups::MilestonesController < Groups::ApplicationController
end
def create
project_ids = params[:milestone][:project_ids].reject(&:blank?)
title = milestone_params[:title]
@milestone = Milestones::CreateService.new(group, current_user, milestone_params).execute
if create_milestones(project_ids)
redirect_to milestone_path(title)
if @milestone.persisted?
redirect_to milestone_path
else
render_new_with_error(project_ids.empty?)
render "new"
end
end
def show
end
def edit
render_404 if @milestone.is_legacy_group_milestone?
end
def update
@milestone.milestones.each do |milestone|
Milestones::UpdateService.new(milestone.project, current_user, milestone_params).execute(milestone)
# Keep this compatible with legacy group milestones where we have to update
# all projects milestones states at once.
if @milestone.is_legacy_group_milestone?
update_params = milestone_params.select { |key| key == "state_event" }
milestones = @milestone.milestones
else
update_params = milestone_params
milestones = [@milestone]
end
redirect_back_or_default(default: milestone_path(@milestone.title))
milestones.each do |milestone|
Milestones::UpdateService.new(milestone.parent, current_user, update_params).execute(milestone)
end
redirect_to milestone_path
end
private
def create_milestones(project_ids)
return false unless project_ids.present?
ActiveRecord::Base.transaction do
@projects.where(id: project_ids).each do |project|
Milestones::CreateService.new(project, current_user, milestone_params).execute
end
end
true
rescue ActiveRecord::ActiveRecordError => e
flash.now[:alert] = "An error occurred while creating the milestone: #{e.message}"
false
end
def render_new_with_error(empty_project_ids)
@milestone = Milestone.new(milestone_params)
@milestone.errors.add(:base, "Please select at least one project.") if empty_project_ids
render :new
end
def authorize_admin_milestones!
return render_404 unless can?(current_user, :admin_milestones, group)
end
@ -74,16 +66,31 @@ class Groups::MilestonesController < Groups::ApplicationController
params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event)
end
def milestone_path(title)
group_milestone_path(@group, title.to_slug.to_s, title: title)
def milestone_path
if @milestone.is_legacy_group_milestone?
group_milestone_path(group, @milestone.safe_title, title: @milestone.title)
else
group_milestone_path(group, @milestone.iid)
end
end
def milestones
@milestones = GroupMilestone.build_collection(@group, @projects, params)
search_params = params.merge(group_ids: group.id)
milestones = MilestonesFinder.new(search_params).execute
legacy_milestones = GroupMilestone.build_collection(group, group_projects, params)
milestones + legacy_milestones
end
def milestone
@milestone = GroupMilestone.build(@group, @projects, params[:title])
@milestone =
if params[:title]
GroupMilestone.build(group, group_projects, params[:title])
else
group.milestones.find_by_iid(params[:id])
end
render_404 unless @milestone
end
end

View file

@ -13,20 +13,16 @@ class Projects::MilestonesController < Projects::ApplicationController
respond_to :html
def index
@milestones =
case params[:state]
when 'all' then @project.milestones
when 'closed' then @project.milestones.closed
else @project.milestones.active
end
@sort = params[:sort] || 'due_date_asc'
@milestones = @milestones.sort(@sort)
@milestones = milestones.sort(@sort)
respond_to do |format|
format.html do
@project_namespace = @project.namespace.becomes(Namespace)
@milestones = @milestones.includes(:project)
# We need to show group milestones in the JSON response
# so that people can filter by and assign group milestones,
# but we don't need to show them on the project milestones page itself.
@milestones = @milestones.for_projects
@milestones = @milestones.page(params[:page])
end
format.json do
@ -51,7 +47,7 @@ class Projects::MilestonesController < Projects::ApplicationController
def create
@milestone = Milestones::CreateService.new(project, current_user, milestone_params).execute
if @milestone.save
if @milestone.valid?
redirect_to project_milestone_path(@project, @milestone)
else
render "new"
@ -86,6 +82,18 @@ class Projects::MilestonesController < Projects::ApplicationController
protected
def milestones
@milestones ||= begin
if @project.group && can?(current_user, :read_group, @project.group)
group = @project.group
end
search_params = params.merge(project_ids: @project.id, group_ids: group&.id)
MilestonesFinder.new(search_params).execute
end
end
def milestone
@milestone ||= @project.milestones.find_by!(iid: params[:id])
end

View file

@ -1,11 +1,11 @@
class Projects::PipelineSchedulesController < Projects::ApplicationController
before_action :schedule, except: [:index, :new, :create]
before_action :authorize_read_pipeline_schedule!
before_action :authorize_create_pipeline_schedule!, only: [:new, :create]
before_action :authorize_update_pipeline_schedule!, only: [:edit, :take_ownership, :update]
before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create]
before_action :authorize_admin_pipeline_schedule!, only: [:destroy]
before_action :schedule, only: [:edit, :update, :destroy, :take_ownership]
def index
@scope = params[:scope]
@all_schedules = PipelineSchedulesFinder.new(@project).execute
@ -53,7 +53,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
redirect_to pipeline_schedules_path(@project), status: 302
else
redirect_to pipeline_schedules_path(@project),
status: 302,
status: :forbidden,
alert: _("Failed to remove the pipeline schedule")
end
end
@ -66,6 +66,15 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
def schedule_params
params.require(:schedule)
.permit(:description, :cron, :cron_timezone, :ref, :active)
.permit(:description, :cron, :cron_timezone, :ref, :active,
variables_attributes: [:id, :key, :value, :_destroy] )
end
def authorize_update_pipeline_schedule!
return access_denied! unless can?(current_user, :update_pipeline_schedule, schedule)
end
def authorize_admin_pipeline_schedule!
return access_denied! unless can?(current_user, :admin_pipeline_schedule, schedule)
end
end

View file

@ -147,9 +147,17 @@ class IssuableFinder
@milestones =
if milestones?
scope = Milestone.where(project_id: projects)
if project?
group_id = project.group&.id
project_id = project.id
end
scope.where(title: params[:milestone_title])
group_id = group.id if group
search_params =
{ title: params[:milestone_title], project_ids: project_id, group_ids: group_id }
MilestonesFinder.new(search_params).execute
else
Milestone.none
end
@ -331,11 +339,6 @@ class IssuableFinder
items = items.left_joins_milestones.where('milestones.start_date <= NOW()')
else
items = items.with_milestone(params[:milestone_title])
items_projects = projects(items)
if items_projects
items = items.where(milestones: { project_id: items_projects })
end
end
end

View file

@ -1,12 +1,55 @@
class MilestonesFinder
def execute(projects, params)
milestones = Milestone.of_projects(projects)
milestones = milestones.reorder("due_date ASC")
# Search for milestones
#
# params - Hash
# project_ids: Array of project ids or single project id.
# group_ids: Array of group ids or single group id.
# order - Orders by field default due date asc.
# title - filter by title.
# state - filters by state.
case params[:state]
when 'closed' then milestones.closed
when 'all' then milestones
else milestones.active
class MilestonesFinder
attr_reader :params, :project_ids, :group_ids
def initialize(params = {})
@project_ids = Array(params[:project_ids])
@group_ids = Array(params[:group_ids])
@params = params
end
def execute
return Milestone.none if project_ids.empty? && group_ids.empty?
items = Milestone.all
items = by_groups_and_projects(items)
items = by_title(items)
items = by_state(items)
order(items)
end
private
def by_groups_and_projects(items)
items.for_projects_and_groups(project_ids, group_ids)
end
def by_title(items)
if params[:title]
items.where(title: params[:title])
else
items
end
end
def by_state(items)
Milestone.filter_by_state(items, params[:state])
end
def order(items)
if params.has_key?(:order)
items.reorder(params[:order])
else
items.reorder('due_date ASC')
end
end
end

View file

@ -54,8 +54,10 @@ module MilestonesHelper
def milestone_class_for_state(param, check, match_blank_param = false)
if match_blank_param
'active' if param.blank? || param == check
elsif param == check
'active'
else
'active' if param == check
check
end
end
@ -147,4 +149,14 @@ module MilestonesHelper
labels_dashboard_milestone_path(milestone, title: milestone.title, format: :json)
end
end
def group_milestone_route(milestone, params = {})
params = nil if params.empty?
if milestone.is_legacy_group_milestone?
group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: params)
else
group_milestone_path(@group, milestone.iid, milestone: params)
end
end
end

View file

@ -203,6 +203,7 @@ module Ci
variables += project.group.secret_variables_for(ref, project).map(&:to_runner_variable) if project.group
variables += secret_variables(environment: environment)
variables += trigger_request.user_variables if trigger_request
variables += pipeline.pipeline_schedule.job_variables if pipeline.pipeline_schedule
variables += persisted_environment_variables if environment
variables

View file

@ -9,17 +9,21 @@ module Ci
belongs_to :owner, class_name: 'User'
has_one :last_pipeline, -> { order(id: :desc) }, class_name: 'Ci::Pipeline'
has_many :pipelines
has_many :variables, class_name: 'Ci::PipelineScheduleVariable'
validates :cron, unless: :importing?, cron: true, presence: { unless: :importing? }
validates :cron_timezone, cron_timezone: true, presence: { unless: :importing? }
validates :ref, presence: { unless: :importing? }
validates :description, presence: true
validates :variables, variable_duplicates: true
before_save :set_next_run_at
scope :active, -> { where(active: true) }
scope :inactive, -> { where(active: false) }
accepts_nested_attributes_for :variables, allow_destroy: true
def owned_by?(current_user)
owner == current_user
end
@ -56,5 +60,9 @@ module Ci
Gitlab::Ci::CronParser.new(worker_cron, worker_time_zone)
.next_time_from(next_run_at)
end
def job_variables
variables&.map(&:to_runner_variable) || []
end
end
end

View file

@ -0,0 +1,8 @@
module Ci
class PipelineScheduleVariable < ActiveRecord::Base
extend Ci::Model
include HasVariable
belongs_to :pipeline_schedule
end
end

View file

@ -8,7 +8,8 @@ module InternalId
def set_iid
if iid.blank?
records = project.send(self.class.name.tableize)
parent = project || group
records = parent.send(self.class.name.tableize)
records = records.with_deleted if self.paranoid?
max_iid = records.maximum(:iid)

View file

@ -30,6 +30,7 @@ module Issuable
belongs_to :updated_by, class_name: "User"
belongs_to :last_edited_by, class_name: 'User'
belongs_to :milestone
has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do # rubocop:disable Cop/ActiveRecordDependent
def authors_loaded?
# We check first if we're loaded to not load unnecessarily.

View file

@ -70,6 +70,22 @@ module Milestoneish
due_date && due_date.past?
end
def is_group_milestone?
false
end
def is_project_milestone?
false
end
def is_legacy_group_milestone?
false
end
def is_dashboard_milestone?
false
end
private
def count_issues_by_state(user)

View file

@ -2,4 +2,8 @@ class DashboardMilestone < GlobalMilestone
def issues_finder_params
{ authorized_only: true }
end
def is_dashboard_milestone?
true
end
end

View file

@ -2,7 +2,7 @@ class GlobalLabel
attr_accessor :title, :labels
alias_attribute :name, :title
delegate :color, :description, to: :@first_label
delegate :color, :text_color, :description, to: :@first_label
def for_display
@first_label

View file

@ -2,6 +2,7 @@ class GlobalMilestone
include Milestoneish
EPOCH = DateTime.parse('1970-01-01')
STATE_COUNT_HASH = { opened: 0, closed: 0, all: 0 }.freeze
attr_accessor :title, :milestones
alias_attribute :name, :title
@ -11,7 +12,10 @@ class GlobalMilestone
end
def self.build_collection(projects, params)
child_milestones = MilestonesFinder.new.execute(projects, params)
params =
{ project_ids: projects.map(&:id), state: params[:state] }
child_milestones = MilestonesFinder.new(params).execute
milestones = child_milestones.select(:id, :title).group_by(&:title).map do |title, grouped|
milestones_relation = Milestone.where(id: grouped.map(&:id))
@ -28,13 +32,42 @@ class GlobalMilestone
new(title, child_milestones)
end
def self.states_count(projects)
relation = MilestonesFinder.new.execute(projects, state: 'all')
milestones_by_state_and_title = relation.reorder(nil).group(:state, :title).count
def self.states_count(projects, group = nil)
legacy_group_milestones_count = legacy_group_milestone_states_count(projects)
group_milestones_count = group_milestones_states_count(group)
opened = count_by_state(milestones_by_state_and_title, 'active')
closed = count_by_state(milestones_by_state_and_title, 'closed')
all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count
legacy_group_milestones_count.merge(group_milestones_count) do |k, legacy_group_milestones_count, group_milestones_count|
legacy_group_milestones_count + group_milestones_count
end
end
def self.group_milestones_states_count(group)
return STATE_COUNT_HASH unless group
params = { group_ids: [group.id], state: 'all', order: nil }
relation = MilestonesFinder.new(params).execute
grouped_by_state = relation.group(:state).count
{
opened: grouped_by_state['active'] || 0,
closed: grouped_by_state['closed'] || 0,
all: grouped_by_state.values.sum
}
end
# Counts the legacy group milestones which must be grouped by title
def self.legacy_group_milestone_states_count(projects)
return STATE_COUNT_HASH unless projects
params = { project_ids: projects.map(&:id), state: 'all', order: nil }
relation = MilestonesFinder.new(params).execute
project_milestones_by_state_and_title = relation.group(:state, :title).count
opened = count_by_state(project_milestones_by_state_and_title, 'active')
closed = count_by_state(project_milestones_by_state_and_title, 'closed')
all = project_milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count
{
opened: opened,

View file

@ -18,6 +18,7 @@ class Group < Namespace
has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent
has_many :milestones
has_many :project_group_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :shared_projects, through: :project_group_links, source: :project
has_many :notification_settings, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent

View file

@ -16,4 +16,8 @@ class GroupMilestone < GlobalMilestone
def issues_finder_params
{ group_id: group.id }
end
def is_legacy_group_milestone?
true
end
end

View file

@ -18,17 +18,32 @@ class Milestone < ActiveRecord::Base
cache_markdown_field :description
belongs_to :project
belongs_to :group
has_many :issues
has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
has_many :merge_requests
has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
scope :of_projects, ->(ids) { where(project_id: ids) }
scope :of_groups, ->(ids) { where(group_id: ids) }
scope :active, -> { with_state(:active) }
scope :closed, -> { with_state(:closed) }
scope :of_projects, ->(ids) { where(project_id: ids) }
scope :for_projects, -> { where(group: nil).includes(:project) }
validates :title, presence: true, uniqueness: { scope: :project_id }
validates :project, presence: true
scope :for_projects_and_groups, -> (project_ids, group_ids) do
conditions = []
conditions << arel_table[:project_id].in(project_ids) if project_ids.compact.any?
conditions << arel_table[:group_id].in(group_ids) if group_ids.compact.any?
where(conditions.reduce(:or))
end
validates :group, presence: true, unless: :project
validates :project, presence: true, unless: :group
validate :uniqueness_of_title, if: :title_changed?
validate :milestone_type_check
validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
strip_attributes :title
@ -63,6 +78,14 @@ class Milestone < ActiveRecord::Base
where(t[:title].matches(pattern).or(t[:description].matches(pattern)))
end
def filter_by_state(milestones, state)
case state
when 'closed' then milestones.closed
when 'all' then milestones
else milestones.active
end
end
end
def self.reference_prefix
@ -138,6 +161,8 @@ class Milestone < ActiveRecord::Base
# Milestone.first.to_reference(same_namespace_project) # => "gitlab-ce%1"
#
def to_reference(from_project = nil, format: :iid, full: false)
return if is_group_milestone?
format_reference = milestone_format_reference(format)
reference = "#{self.class.reference_prefix}#{format_reference}"
@ -152,6 +177,10 @@ class Milestone < ActiveRecord::Base
id
end
def for_display
self
end
def can_be_closed?
active? && issues.opened.count.zero?
end
@ -164,8 +193,45 @@ class Milestone < ActiveRecord::Base
write_attribute(:title, sanitize_title(value)) if value.present?
end
def safe_title
title.to_slug.normalize.to_s
end
def parent
group || project
end
def is_group_milestone?
group_id.present?
end
def is_project_milestone?
project_id.present?
end
private
# Milestone titles must be unique across project milestones and group milestones
def uniqueness_of_title
if project
relation = Milestone.for_projects_and_groups([project_id], [project.group&.id])
elsif group
project_ids = group.projects.map(&:id)
relation = Milestone.for_projects_and_groups(project_ids, [group.id])
end
title_exists = relation.find_by_title(title)
errors.add(:title, "already being used for another group or project milestone.") if title_exists
end
# Milestone should be either a project milestone or a group milestone
def milestone_type_check
if group_id && project_id
field = project_id_changed? ? :project_id : :group_id
errors.add(field, "milestone should belong either to a project or a group.")
end
end
def milestone_format_reference(format = :iid)
raise ArgumentError, 'Unknown format' unless [:iid, :name].include?(format)

View file

@ -1,4 +1,14 @@
module Ci
class PipelineSchedulePolicy < PipelinePolicy
alias_method :pipeline_schedule, :subject
condition(:owner_of_schedule) do
can?(:developer_access) && pipeline_schedule.owned_by?(@user)
end
rule { can?(:master_access) | owner_of_schedule }.policy do
enable :update_pipeline_schedule
enable :admin_pipeline_schedule
end
end
end

View file

@ -162,7 +162,6 @@ class ProjectPolicy < BasePolicy
enable :create_pipeline
enable :update_pipeline
enable :create_pipeline_schedule
enable :update_pipeline_schedule
enable :create_merge_request
enable :create_wiki
enable :push_code
@ -188,7 +187,6 @@ class ProjectPolicy < BasePolicy
enable :admin_build
enable :admin_container_image
enable :admin_pipeline
enable :admin_pipeline_schedule
enable :admin_environment
enable :admin_deployment
enable :admin_pages

View file

@ -1,5 +1,6 @@
class LabelEntity < Grape::Entity
expose :id
expose :id, if: ->(label, _) { !label.is_a?(GlobalLabel) }
expose :title
expose :color
expose :description

View file

@ -2,8 +2,11 @@ class IssuableBaseService < BaseService
private
def create_milestone_note(issuable)
milestone = issuable.milestone
return if milestone && milestone.is_group_milestone?
SystemNoteService.change_milestone(
issuable, issuable.project, current_user, issuable.milestone)
issuable, issuable.project, current_user, milestone)
end
def create_labels_note(issuable, old_labels)
@ -89,10 +92,12 @@ class IssuableBaseService < BaseService
milestone_id = params[:milestone_id]
return unless milestone_id
if milestone_id == IssuableFinder::NONE ||
project.milestones.find_by(id: milestone_id).nil?
params[:milestone_id] = ''
end
params[:milestone_id] = '' if milestone_id == IssuableFinder::NONE
milestone =
Milestone.for_projects_and_groups([project.id], [project.group&.id]).find_by_id(milestone_id)
params[:milestone_id] = '' unless milestone
end
def filter_labels

View file

@ -61,8 +61,18 @@ module Issues
end
def cloneable_milestone_id
@new_project.milestones
.find_by(title: @old_issue.milestone.try(:title)).try(:id)
title = @old_issue.milestone&.title
return unless title
if @new_project.group && can?(current_user, :read_group, @new_project.group)
group_id = @new_project.group.id
end
params =
{ title: title, project_ids: @new_project.id, group_ids: group_id }
milestones = MilestonesFinder.new(params).execute
milestones.first&.id
end
def rewrite_notes

View file

@ -1,4 +1,10 @@
module Milestones
class BaseService < ::BaseService
# Parent can either a group or a project
attr_accessor :parent, :current_user, :params
def initialize(parent, user, params = {})
@parent, @current_user, @params = parent, user, params.dup
end
end
end

View file

@ -1,7 +1,7 @@
module Milestones
class CloseService < Milestones::BaseService
def execute(milestone)
if milestone.close
if milestone.close && milestone.is_project_milestone?
event_service.close_milestone(milestone, current_user)
end

View file

@ -1,9 +1,9 @@
module Milestones
class CreateService < Milestones::BaseService
def execute
milestone = project.milestones.new(params)
milestone = parent.milestones.new(params)
if milestone.save
if milestone.save && milestone.is_project_milestone?
event_service.open_milestone(milestone, current_user)
end

View file

@ -1,7 +1,7 @@
module Milestones
class ReopenService < Milestones::BaseService
def execute(milestone)
if milestone.activate
if milestone.activate && milestone.is_project_milestone?
event_service.reopen_milestone(milestone, current_user)
end

View file

@ -5,9 +5,9 @@ module Milestones
case state
when 'activate'
Milestones::ReopenService.new(project, current_user, {}).execute(milestone)
Milestones::ReopenService.new(parent, current_user, {}).execute(milestone)
when 'close'
Milestones::CloseService.new(project, current_user, {}).execute(milestone)
Milestones::CloseService.new(parent, current_user, {}).execute(milestone)
end
if params.present?

View file

@ -0,0 +1,13 @@
# VariableDuplicatesValidator
#
# This validtor is designed for especially the following condition
# - Use `accepts_nested_attributes_for :xxx` in a parent model
# - Use `validates :xxx, uniqueness: { scope: :xxx_id }` in a child model
class VariableDuplicatesValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
duplicates = value.reject(&:marked_for_destruction?).group_by(&:key).select { |_, v| v.many? }.map(&:first)
if duplicates.any?
record.errors.add(attribute, "Duplicate variables: #{duplicates.join(", ")}")
end
end
end

View file

@ -0,0 +1,27 @@
= form_for [@group, @milestone], html: { class: 'form-horizontal milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
.row
= form_errors(@milestone)
.col-md-6
.form-group
= f.label :title, "Title", class: "control-label"
.col-sm-10
= f.text_field :title, maxlength: 255, class: "form-control", required: true, autofocus: true
.form-group.milestone-description
= f.label :description, "Description", class: "control-label"
.col-sm-10
= render layout: 'projects/md_preview', locals: { url: '' } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...'
.clearfix
.error-alert
= render "shared/milestones/form_dates", f: f
.form-actions
- if @milestone.new_record?
= f.submit 'Create milestone', class: "btn-create btn"
= link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel"
- else
= f.submit 'Update milestone', class: "btn-create btn"
= link_to "Cancel", group_milestone_path(@group, @milestone), class: "btn btn-cancel"

View file

@ -1,5 +1,6 @@
= render 'shared/milestones/milestone',
milestone_path: group_milestone_path(@group, milestone.safe_title, title: milestone.title),
milestone_path: group_milestone_route(milestone),
issues_path: issues_group_path(@group, milestone_title: milestone.title),
merge_requests_path: merge_requests_group_path(@group, milestone_title: milestone.title),
milestone: milestone

View file

@ -0,0 +1,7 @@
- page_title "Milestones"
- render "header_title"
%h3.page-title
Edit Milestone
= render "form"

View file

@ -12,11 +12,6 @@
- if can?(current_user, :admin_milestones, @group)
= link_to "New milestone", new_group_milestone_path(@group), class: "btn btn-new"
.row-content-block
Only milestones from
%strong= @group.name
group are listed here.
.milestones
%ul.content-list
- if @milestones.blank?

View file

@ -4,40 +4,4 @@
%h3.page-title
New Milestone
%p.light
This will create milestone in every selected project
%hr
= form_for @milestone, url: group_milestones_path(@group), html: { class: 'form-horizontal milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
.row
- if @milestone.errors.any?
#error_explanation
.alert.alert-danger
%ul
- @milestone.errors.full_messages.each do |msg|
%li
= msg
.col-md-6
.form-group
= f.label :title, "Title", class: "control-label"
.col-sm-10
= f.text_field :title, maxlength: 255, class: "form-control", required: true, autofocus: true
.form-group.milestone-description
= f.label :description, "Description", class: "control-label"
.col-sm-10
= render layout: 'projects/md_preview', locals: { url: '' } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...'
.clearfix
.error-alert
.form-group
= f.label :projects, "Projects", class: "control-label"
.col-sm-10
= f.collection_select :project_ids, @group.projects.non_archived, :id, :name,
{ selected: @group.projects.non_archived.pluck(:id) }, required: true, multiple: true, class: 'select2'
= render "shared/milestones/form_dates", f: f
.form-actions
= f.submit 'Create milestone', class: "btn-create btn"
= link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel"
= render "form"

View file

@ -1,4 +1,4 @@
= render "header_title"
= render 'shared/milestones/top', milestone: @milestone, group: @group
= render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true
= render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true if @milestone.is_legacy_group_milestone?
= render 'shared/milestones/sidebar', milestone: @milestone, affix_offset: 102

View file

@ -22,6 +22,14 @@
= f.label :ref, _('Target Branch'), class: 'label-light'
= dropdown_tag(_("Select target branch"), options: { toggle_class: 'btn js-target-branch-dropdown', dropdown_class: 'git-revision-dropdown', title: _("Select target branch"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } )
= f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true
.form-group
.col-md-9
%label.label-light
#{ s_('PipelineSchedules|Variables') }
%ul.js-pipeline-variable-list.pipeline-variable-list
- @schedule.variables.each do |variable|
= render 'variable_row', id: variable.id, key: variable.key, value: variable.value
= render 'variable_row'
.form-group
.col-md-9
= f.label :active, s_('PipelineSchedules|Activated'), class: 'label-light'

View file

@ -26,7 +26,7 @@
= pipeline_schedule.owner&.name
%td
.pull-right.btn-group
- if can?(current_user, :update_pipeline_schedule, @project) && !pipeline_schedule.owned_by?(current_user)
- if can?(current_user, :update_pipeline_schedule, pipeline_schedule)
= link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('PipelineSchedules|Take ownership'), class: 'btn' do
= s_('PipelineSchedules|Take ownership')
- if can?(current_user, :update_pipeline_schedule, pipeline_schedule)

View file

@ -0,0 +1,17 @@
- id = local_assigns.fetch(:id, nil)
- key = local_assigns.fetch(:key, "")
- value = local_assigns.fetch(:value, "")
%li.js-row.pipeline-variable-row{ data: { is_persisted: "#{!id.nil?}" } }
.pipeline-variable-row-body
%input{ type: "hidden", name: "schedule[variables_attributes][][id]", value: id }
%input.js-destroy-input{ type: "hidden", name: "schedule[variables_attributes][][_destroy]" }
%input.js-user-input.pipeline-variable-key-input.form-control{ type: "text",
name: "schedule[variables_attributes][][key]",
value: key,
placeholder: s_('PipelineSchedules|Input variable key') }
%textarea.js-user-input.pipeline-variable-value-input.form-control{ rows: 1,
name: "schedule[variables_attributes][][value]",
placeholder: s_('PipelineSchedules|Input variable value') }
= value
%button.js-row-remove-button.pipeline-variable-row-remove-button{ 'aria-label': s_('PipelineSchedules|Remove variable row') }
%i.fa.fa-minus-circle{ 'aria-hidden': "true" }

View file

@ -1,10 +1,15 @@
- dashboard = local_assigns[:dashboard]
- custom_dom_id = dom_id(@project ? milestone : milestone.milestones.first)
- custom_dom_id = dom_id(milestone.try(:milestones) ? milestone.milestones.first : milestone)
%li{ class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: custom_dom_id }
.row
.col-sm-6
%strong= link_to truncate(milestone.title, length: 100), milestone_path
- if milestone.is_group_milestone?
%span - Group Milestone
- else
%span - Project Milestone
.col-sm-6
.pull-right.light #{milestone.percent_complete(current_user)}% complete
.row
@ -13,26 +18,32 @@
&middot;
= link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path
.col-sm-6= milestone_progress_bar(milestone)
- if milestone.is_a?(GlobalMilestone)
- if milestone.is_a?(GlobalMilestone) || milestone.is_group_milestone?
.row
.col-sm-6
.expiration= render('shared/milestone_expired', milestone: milestone)
.projects
- milestone.milestones.each do |milestone|
= link_to milestone_path(milestone) do
%span.label.label-gray
= dashboard ? milestone.project.name_with_namespace : milestone.project.name
- if milestone.is_legacy_group_milestone?
.expiration= render('shared/milestone_expired', milestone: milestone)
.projects
- milestone.milestones.each do |milestone|
= link_to milestone_path(milestone) do
%span.label.label-gray
= dashboard ? milestone.project.name_with_namespace : milestone.project.name
- if @group
.col-sm-6
.col-sm-6.milestone-actions
- if can?(current_user, :admin_milestones, @group)
- if milestone.is_group_milestone?
= link_to edit_group_milestone_path(@group, milestone.id), class: "btn btn-xs btn-grouped" do
Edit
\
- if milestone.closed?
= link_to 'Reopen Milestone', group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: {state_event: :activate }), method: :put, class: "btn btn-xs btn-grouped btn-reopen"
= link_to 'Reopen Milestone', group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-xs btn-grouped btn-reopen"
- else
= link_to 'Close Milestone', group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: {state_event: :close }), method: :put, class: "btn btn-xs btn-close"
= link_to 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-xs btn-grouped btn-close"
- if @project
.row
.col-sm-6= render('shared/milestone_expired', milestone: milestone)
.col-sm-6
= render('shared/milestone_expired', milestone: milestone)
.col-sm-6.milestone-actions
- if can?(current_user, :admin_milestone, milestone.project) and milestone.active?
= link_to edit_project_milestone_path(milestone.project, milestone), class: "btn btn-xs btn-grouped" do

View file

@ -22,39 +22,55 @@
- if group
.pull-right
- if can?(current_user, :admin_milestones, group)
- if milestone.is_group_milestone?
= link_to edit_group_milestone_path(group, milestone.iid), class: "btn btn btn-grouped" do
Edit
- if milestone.active?
= link_to 'Close Milestone', group_milestone_path(group, milestone.safe_title, title: milestone.title, milestone: {state_event: :close }), method: :put, class: "btn btn-grouped btn-close"
= link_to 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-grouped btn-close"
- else
= link_to 'Reopen Milestone', group_milestone_path(group, milestone.safe_title, title: milestone.title, milestone: {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen"
= link_to 'Reopen Milestone', group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen"
.detail-page-description.milestone-detail
%h2.title
= markdown_field(milestone, :title)
- if @milestone.is_group_milestone? && @milestone.description.present?
%div
.description
.wiki
= markdown_field(@milestone, :description)
- if milestone.complete?(current_user) && milestone.active?
.alert.alert-success.prepend-top-default
- close_msg = group ? 'You may close the milestone now.' : 'Navigate to the project to close the milestone.'
%span All issues for this milestone are closed. #{close_msg}
.table-holder
%table.table
%thead
%tr
%th Project
%th Open issues
%th State
%th Due date
- milestone.milestones.each do |ms|
%tr
%td
- project_name = group ? ms.project.name : ms.project.name_with_namespace
= link_to project_name, project_milestone_path(ms.project, ms)
%td
= ms.issues_visible_to_user(current_user).opened.count
%td
- if ms.closed?
Closed
- else
Open
%td
= ms.expires_at
- if @milestone.is_legacy_group_milestone? || @milestone.is_dashboard_milestone?
.table-holder
%table.table
%thead
%tr
%th Project
%th Open issues
%th State
%th Due date
- milestone.milestones.each do |ms|
%tr
%td
- project_name = group ? ms.project.name : ms.project.name_with_namespace
= link_to project_name, project_milestone_path(ms.project, ms)
%td
= ms.issues_visible_to_user(current_user).opened.count
%td
- if ms.closed?
Closed
- else
Open
%td
= ms.expires_at
- elsif @milestone.is_group_milestone?
%br
View
= link_to 'Issues', issues_group_path(@group, milestone_title: milestone.title)
or
= link_to 'Merge Requests', merge_requests_group_path(@group, milestone_title: milestone.title)
in this milestone

View file

@ -0,0 +1,4 @@
---
title: Fix dashboard labels dropdown
merge_request: 12708
author:

View file

@ -0,0 +1,4 @@
---
title: Add variables to pipelines schedules
merge_request: 12372
author:

View file

@ -0,0 +1,4 @@
---
title: Add native group milestones
merge_request:
author:

View file

@ -12,7 +12,7 @@ scope(path: 'groups/*group_id',
end
resource :avatar, only: [:destroy]
resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :update, :new, :create] do
resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :edit, :update, :new, :create] do
member do
get :merge_requests
get :participants

View file

@ -0,0 +1,25 @@
class CreateCiPipelineScheduleVariables < ActiveRecord::Migration
DOWNTIME = false
def up
create_table :ci_pipeline_schedule_variables do |t|
t.string :key, null: false
t.text :value
t.text :encrypted_value
t.string :encrypted_value_salt
t.string :encrypted_value_iv
t.integer :pipeline_schedule_id, null: false
t.timestamps_with_timezone null: true
end
add_index :ci_pipeline_schedule_variables,
[:pipeline_schedule_id, :key],
name: "index_ci_pipeline_schedule_variables_on_schedule_id_and_key",
unique: true
end
def down
drop_table :ci_pipeline_schedule_variables
end
end

View file

@ -0,0 +1,15 @@
class AddForeignKeyToCiPipelineScheduleVariables < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_foreign_key(:ci_pipeline_schedule_variables, :ci_pipeline_schedules, column: :pipeline_schedule_id)
end
def down
remove_foreign_key(:ci_pipeline_schedule_variables, column: :pipeline_schedule_id)
end
end

View file

@ -0,0 +1,18 @@
class AddGroupIdToMilestones < ActiveRecord::Migration
DOWNTIME = false
def up
change_column_null :milestones, :project_id, true
add_column :milestones, :group_id, :integer
end
def down
# We cannot rollback project_id not null constraint if there are records
# with null values.
execute "DELETE from milestones WHERE project_id IS NULL"
remove_column :milestones, :group_id
change_column :milestones, :project_id, :integer, null: false
end
end

View file

@ -0,0 +1,19 @@
class AddGroupMilestoneIdIndexes < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
DOWNTIME = false
def up
add_concurrent_foreign_key :milestones, :namespaces, column: :group_id, on_delete: :cascade
add_concurrent_index :milestones, :group_id
end
def down
remove_foreign_key :milestones, column: :group_id
remove_concurrent_index :milestones, :group_id
end
end

View file

@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170703102400) do
ActiveRecord::Schema.define(version: 20170724184243) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -253,6 +253,19 @@ ActiveRecord::Schema.define(version: 20170703102400) do
add_index "ci_builds", ["updated_at"], name: "index_ci_builds_on_updated_at", using: :btree
add_index "ci_builds", ["user_id"], name: "index_ci_builds_on_user_id", using: :btree
create_table "ci_pipeline_schedule_variables", force: :cascade do |t|
t.string "key", null: false
t.text "value"
t.text "encrypted_value"
t.string "encrypted_value_salt"
t.string "encrypted_value_iv"
t.integer "pipeline_schedule_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_index "ci_pipeline_schedule_variables", ["pipeline_schedule_id", "key"], name: "index_ci_pipeline_schedule_variables_on_schedule_id_and_key", unique: true, using: :btree
create_table "ci_group_variables", force: :cascade do |t|
t.string "key", null: false
t.text "value"
@ -829,7 +842,7 @@ ActiveRecord::Schema.define(version: 20170703102400) do
create_table "milestones", force: :cascade do |t|
t.string "title", null: false
t.integer "project_id", null: false
t.integer "project_id"
t.text "description"
t.date "due_date"
t.datetime "created_at"
@ -840,10 +853,12 @@ ActiveRecord::Schema.define(version: 20170703102400) do
t.text "description_html"
t.date "start_date"
t.integer "cached_markdown_version"
t.integer "group_id"
end
add_index "milestones", ["description"], name: "index_milestones_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"}
add_index "milestones", ["due_date"], name: "index_milestones_on_due_date", using: :btree
add_index "milestones", ["group_id"], name: "index_milestones_on_group_id", using: :btree
add_index "milestones", ["project_id", "iid"], name: "index_milestones_on_project_id_and_iid", unique: true, using: :btree
add_index "milestones", ["title"], name: "index_milestones_on_title", using: :btree
add_index "milestones", ["title"], name: "index_milestones_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"}
@ -1564,6 +1579,7 @@ ActiveRecord::Schema.define(version: 20170703102400) do
add_foreign_key "ci_builds", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_a2141b1522", on_delete: :nullify
add_foreign_key "ci_builds", "ci_stages", column: "stage_id", name: "fk_3a9eaa254d", on_delete: :cascade
add_foreign_key "ci_builds", "projects", name: "fk_befce0568a", on_delete: :cascade
add_foreign_key "ci_pipeline_schedule_variables", "ci_pipeline_schedules", column: "pipeline_schedule_id", name: "fk_41c35fda51", on_delete: :cascade
add_foreign_key "ci_group_variables", "namespaces", column: "group_id", name: "fk_33ae4d58d8", on_delete: :cascade
add_foreign_key "ci_pipeline_schedules", "projects", name: "fk_8ead60fcc4", on_delete: :cascade
add_foreign_key "ci_pipeline_schedules", "users", column: "owner_id", name: "fk_9ea99f58d2", on_delete: :nullify
@ -1601,6 +1617,7 @@ ActiveRecord::Schema.define(version: 20170703102400) do
add_foreign_key "merge_requests", "projects", column: "target_project_id", name: "fk_a6963e8447", on_delete: :cascade
add_foreign_key "merge_requests_closing_issues", "issues", on_delete: :cascade
add_foreign_key "merge_requests_closing_issues", "merge_requests", on_delete: :cascade
add_foreign_key "milestones", "namespaces", column: "group_id", name: "fk_95650a40d4", on_delete: :cascade
add_foreign_key "milestones", "projects", name: "fk_9bd0a0c791", on_delete: :cascade
add_foreign_key "notes", "projects", name: "fk_99e097b079", on_delete: :cascade
add_foreign_key "oauth_openid_requests", "oauth_access_grants", column: "access_grant_id", name: "fk_oauth_openid_requests_oauth_access_grants_access_grant_id"

View file

@ -9,7 +9,7 @@ and a list of **user-defined variables**.
The variables can be overwritten and they take precedence over each other in
this order:
1. [Trigger variables][triggers] (take precedence over all)
1. [Trigger variables][triggers] or [scheduled pipeline variables](../../user/project/pipelines/schedules.md#making-use-of-scheduled-pipeline-variables) (take precedence over all)
1. Project-level [secret variables](#secret-variables) or [protected secret variables](#protected-secret-variables)
1. Group-level [secret variables](#secret-variables) or [protected secret variables](#protected-secret-variables)
1. YAML-defined [job-level variables](../yaml/README.md#job-variables)

View file

@ -21,14 +21,11 @@ Once you fill in all the details, hit the **Create milestone** button.
>**Note:**
You need [Master permissions](../../permissions.md) in order to create a milestone.
You can create a milestone for several projects in the same group simultaneously.
On the group's **Issues ➔ Milestones** page, you will be able to see the status
of that milestone across all of the selected projects. To create a new milestone
for selected projects in the group, click the **New milestone** button. The
form is the same as when creating a milestone for a specific project with the
addition of the selection of the projects you want to inherit this milestone.
You can create a milestone for a group that will be shared across group projects.
On the group's **Issues ➔ Milestones** page, you will be able to see the state
of that milestone and the issues/merge requests count that it shares across the group projects. To create a new milestone click the **New milestone** button. The form is the same as when creating a milestone for a specific project which you can find in the previous item.
![Creating a group milestone](img/milestone_group_create.png)
In addition to that you will be able to filter issues or merge requests by group milestones in all projects that belongs to the milestone group.
## Special milestone filters

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View file

@ -31,6 +31,15 @@ is installed on.
![Schedules list](img/pipeline_schedules_list.png)
### Making use of scheduled pipeline variables
> [Introduced][ce-12328] in GitLab 9.4.
You can pass any number of arbitrary variables and they will be available in
GitLab CI so that they can be used in your `.gitlab-ci.yml` file.
![Scheduled pipeline variables](img/pipeline_schedule_variables.png)
## Using only and except
To configure that a job can be executed only when the pipeline has been
@ -79,4 +88,5 @@ don't have admin access to the server, ask your administrator.
[ce-10533]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10533
[ce-10853]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10853
[ce-12328]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12328
[settings]: https://about.gitlab.com/gitlab-com/settings/#cron-jobs

View file

@ -22,12 +22,12 @@ Feature: Group Milestones
Then I should see group milestone with descriptions and expiry date
And I should see group milestone with all issues and MRs assigned to that milestone
Scenario: Create multiple milestones with one form
Scenario: Create group milestones
Given I visit group "Owned" milestones page
And I click new milestone button
And I fill milestone name
When I press create mileston button
Then milestone in each project should be created
Then group milestone should be created
Scenario: I should see Issues listed with labels
Given Group has projects with milestones

View file

@ -54,14 +54,9 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps
click_button "Create milestone"
end
step 'milestone in each project should be created' do
step 'group milestone should be created' do
group = Group.find_by(name: 'Owned')
expect(page).to have_content "Milestone v2.9.0"
expect(group.projects).to be_present
group.projects.each do |project|
expect(page).to have_content project.name
end
expect(page).to have_content group.milestones.find_by_title('v2.9.0').title
end
step 'I should see the "bug" label' do

View file

@ -255,7 +255,7 @@ module API
class ProjectEntity < Grape::Entity
expose :id, :iid
expose(:project_id) { |entity| entity.project.id }
expose(:project_id) { |entity| entity&.project.try(:id) }
expose :title, :description
expose :state, :created_at, :updated_at
end
@ -267,7 +267,12 @@ module API
expose :deleted_file?, as: :deleted_file
end
class Milestone < ProjectEntity
class Milestone < Grape::Entity
expose :id, :iid
expose(:project_id) { |entity| entity&.project_id }
expose(:group_id) { |entity| entity&.group_id }
expose :title, :description
expose :state, :created_at, :updated_at
expose :due_date
expose :start_date
end

View file

@ -74,9 +74,10 @@ module API
optional :active, type: Boolean, desc: 'The activation of pipeline schedule'
end
put ':id/pipeline_schedules/:pipeline_schedule_id' do
authorize! :update_pipeline_schedule, user_project
authorize! :read_pipeline_schedule, user_project
not_found!('PipelineSchedule') unless pipeline_schedule
authorize! :update_pipeline_schedule, pipeline_schedule
if pipeline_schedule.update(declared_params(include_missing: false))
present pipeline_schedule, with: Entities::PipelineScheduleDetails
@ -92,9 +93,10 @@ module API
requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id'
end
post ':id/pipeline_schedules/:pipeline_schedule_id/take_ownership' do
authorize! :update_pipeline_schedule, user_project
authorize! :read_pipeline_schedule, user_project
not_found!('PipelineSchedule') unless pipeline_schedule
authorize! :update_pipeline_schedule, pipeline_schedule
if pipeline_schedule.own!(current_user)
present pipeline_schedule, with: Entities::PipelineScheduleDetails
@ -110,9 +112,10 @@ module API
requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id'
end
delete ':id/pipeline_schedules/:pipeline_schedule_id' do
authorize! :admin_pipeline_schedule, user_project
authorize! :read_pipeline_schedule, user_project
not_found!('PipelineSchedule') unless pipeline_schedule
authorize! :admin_pipeline_schedule, pipeline_schedule
status :accepted
present pipeline_schedule.destroy, with: Entities::PipelineScheduleDetails

View file

@ -568,11 +568,17 @@ module Gitlab
# Return total commits count accessible from passed ref
def commit_count(ref)
walker = Rugged::Walker.new(rugged)
walker.sorting(Rugged::SORT_TOPO | Rugged::SORT_REVERSE)
oid = rugged.rev_parse_oid(ref)
walker.push(oid)
walker.count
gitaly_migrate(:commit_count) do |is_enabled|
if is_enabled
gitaly_commit_client.commit_count(ref)
else
walker = Rugged::Walker.new(rugged)
walker.sorting(Rugged::SORT_TOPO | Rugged::SORT_REVERSE)
oid = rugged.rev_parse_oid(ref)
walker.push(oid)
walker.count
end
end
end
# Sets HEAD to the commit specified by +ref+; +ref+ can be a branch or

View file

@ -56,6 +56,15 @@ module Gitlab
entry
end
def commit_count(ref)
request = Gitaly::CountCommitsRequest.new(
repository: @gitaly_repo,
revision: ref
)
GitalyClient.call(@repository.storage, :commit_service, :count_commits, request).count
end
private
def commit_diff_request_params(commit, options = {})

View file

@ -619,6 +619,12 @@ msgstr ""
msgid "PipelineSchedules|Inactive"
msgstr ""
msgid "PipelineSchedules|Input variable key"
msgstr ""
msgid "PipelineSchedules|Input variable value"
msgstr ""
msgid "PipelineSchedules|Next Run"
msgstr ""
@ -628,12 +634,18 @@ msgstr ""
msgid "PipelineSchedules|Provide a short description for this pipeline"
msgstr ""
msgid "PipelineSchedules|Remove variable row"
msgstr ""
msgid "PipelineSchedules|Take ownership"
msgstr ""
msgid "PipelineSchedules|Target"
msgstr ""
msgid "PipelineSchedules|Variables"
msgstr ""
msgid "PipelineSheduleIntervalPattern|Custom"
msgstr ""
@ -1056,6 +1068,12 @@ msgstr ""
msgid "Withdraw Access Request"
msgstr ""
msgid ""
"You are going to remove %{group_name}.\n"
"Removed groups CANNOT be restored!\n"
"Are you ABSOLUTELY sure?"
msgstr ""
msgid ""
"You are going to remove %{project_name_with_namespace}.\n"
"Removed project CANNOT be restored!\n"

View file

@ -8,8 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-06-28 13:32+0200\n"
"PO-Revision-Date: 2017-06-28 13:32+0200\n"
"POT-Creation-Date: 2017-07-05 08:50-0500\n"
"PO-Revision-Date: 2017-07-05 08:50-0500\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
@ -620,6 +620,12 @@ msgstr ""
msgid "PipelineSchedules|Inactive"
msgstr ""
msgid "PipelineSchedules|Input variable key"
msgstr ""
msgid "PipelineSchedules|Input variable value"
msgstr ""
msgid "PipelineSchedules|Next Run"
msgstr ""
@ -629,12 +635,18 @@ msgstr ""
msgid "PipelineSchedules|Provide a short description for this pipeline"
msgstr ""
msgid "PipelineSchedules|Remove variable row"
msgstr ""
msgid "PipelineSchedules|Take ownership"
msgstr ""
msgid "PipelineSchedules|Target"
msgstr ""
msgid "PipelineSchedules|Variables"
msgstr ""
msgid "PipelineSheduleIntervalPattern|Custom"
msgstr ""
@ -1057,6 +1069,12 @@ msgstr ""
msgid "Withdraw Access Request"
msgstr ""
msgid ""
"You are going to remove %{group_name}.\n"
"Removed groups CANNOT be restored!\n"
"Are you ABSOLUTELY sure?"
msgstr ""
msgid ""
"You are going to remove %{project_name_with_namespace}.\n"
"Removed project CANNOT be restored!\n"

View file

@ -0,0 +1,25 @@
require 'spec_helper'
describe Dashboard::LabelsController do
let(:project) { create(:empty_project) }
let(:user) { create(:user) }
let!(:label) { create(:label, project: project) }
before do
sign_in(user)
project.add_reporter(user)
end
describe "#index" do
let!(:unrelated_label) { create(:label, project: create(:empty_project, :public)) }
it 'returns global labels for projects the user has a relationship with' do
get :index, format: :json
expect(json_response).to be_kind_of(Array)
expect(json_response.size).to eq(1)
expect(json_response[0]["id"]).to be_nil
expect(json_response[0]["title"]).to eq(label.title)
end
end
end

View file

@ -2,8 +2,8 @@ require 'spec_helper'
describe Groups::MilestonesController do
let(:group) { create(:group) }
let(:project) { create(:empty_project, group: group) }
let(:project2) { create(:empty_project, group: group) }
let!(:project) { create(:empty_project, group: group) }
let!(:project2) { create(:empty_project, group: group) }
let(:user) { create(:user) }
let(:title) { '肯定不是中文的问题' }
let(:milestone) do
@ -17,24 +17,67 @@ describe Groups::MilestonesController do
end
let(:milestone_path) { group_milestone_path(group, milestone.safe_title, title: milestone.title) }
let(:milestone_params) do
{
title: title,
start_date: Date.today,
due_date: 1.month.from_now.to_date
}
end
before do
sign_in(user)
group.add_owner(user)
project.team << [user, :master]
end
describe "#index" do
describe '#index' do
it 'shows group milestones page' do
get :index, group_id: group.to_param
expect(response).to have_http_status(200)
end
it 'shows group milestones JSON' do
get :index, group_id: group.to_param, format: :json
context 'as JSON' do
let!(:milestone) { create(:milestone, group: group, title: 'group milestone') }
let!(:legacy_milestone1) { create(:milestone, project: project, title: 'legacy') }
let!(:legacy_milestone2) { create(:milestone, project: project2, title: 'legacy') }
expect(response).to have_http_status(200)
expect(response.content_type).to eq 'application/json'
it 'lists legacy group milestones and group milestones' do
get :index, group_id: group.to_param, format: :json
milestones = JSON.parse(response.body)
expect(milestones.count).to eq(2)
expect(milestones.first["title"]).to eq("group milestone")
expect(milestones.second["title"]).to eq("legacy")
expect(response).to have_http_status(200)
expect(response.content_type).to eq 'application/json'
end
end
end
describe '#show' do
let(:milestone1) { create(:milestone, project: project, title: 'legacy') }
let(:milestone2) { create(:milestone, project: project, title: 'legacy') }
let(:group_milestone) { create(:milestone, group: group) }
context 'when there is a title parameter' do
it 'searchs for a legacy group milestone' do
expect(GlobalMilestone).to receive(:build)
expect(Milestone).not_to receive(:find_by_iid)
get :show, group_id: group.to_param, id: title, title: milestone1.safe_title
end
end
context 'when there is not a title parameter' do
it 'searchs for a group milestone' do
expect(GlobalMilestone).not_to receive(:build)
expect(Milestone).to receive(:find_by_iid)
get :show, group_id: group.to_param, id: group_milestone.id
end
end
end
@ -44,16 +87,57 @@ describe Groups::MilestonesController do
it "creates group milestone with Chinese title" do
post :create,
group_id: group.to_param,
milestone: { project_ids: [project.id, project2.id], title: title }
milestone: milestone_params
expect(response).to redirect_to(group_milestone_path(group, title.to_slug.to_s, title: title))
expect(Milestone.where(title: title).count).to eq(2)
milestone = Milestone.find_by_title(title)
expect(response).to redirect_to(group_milestone_path(group, milestone.iid))
expect(milestone.group_id).to eq(group.id)
expect(milestone.due_date).to eq(milestone_params[:due_date])
expect(milestone.start_date).to eq(milestone_params[:start_date])
end
end
describe "#update" do
let(:milestone) { create(:milestone, group: group) }
it "updates group milestone" do
milestone_params[:title] = "title changed"
put :update,
id: milestone.iid,
group_id: group.to_param,
milestone: milestone_params
milestone.reload
expect(response).to redirect_to(group_milestone_path(group, milestone.iid))
expect(milestone.title).to eq("title changed")
end
it "redirects to new when there are no project ids" do
post :create, group_id: group.to_param, milestone: { title: title, project_ids: [""] }
expect(response).to render_template :new
expect(assigns(:milestone).errors).not_to be_nil
context "legacy group milestones" do
let!(:milestone1) { create(:milestone, project: project, title: 'legacy milestone', description: "old description") }
let!(:milestone2) { create(:milestone, project: project2, title: 'legacy milestone', description: "old description") }
it "updates only group milestones state" do
milestone_params[:title] = "title changed"
milestone_params[:description] = "description changed"
milestone_params[:state_event] = "close"
put :update,
id: milestone1.title.to_slug.to_s,
group_id: group.to_param,
milestone: milestone_params,
title: milestone1.title
expect(response).to redirect_to(group_milestone_path(group, milestone1.safe_title, title: milestone1.title))
[milestone1, milestone2].each do |milestone|
milestone.reload
expect(milestone.title).to eq("legacy milestone")
expect(milestone.description).to eq("old description")
expect(milestone.state).to eq("closed")
end
end
end
end
@ -156,7 +240,7 @@ describe Groups::MilestonesController do
it 'does not 404' do
post :create,
group_id: group.to_param,
milestone: { project_ids: [project.id, project2.id], title: title }
milestone: { title: title }
expect(response).not_to have_http_status(404)
end
@ -164,7 +248,7 @@ describe Groups::MilestonesController do
it 'does not redirect to the correct casing' do
post :create,
group_id: group.to_param,
milestone: { project_ids: [project.id, project2.id], title: title }
milestone: { title: title }
expect(response).not_to have_http_status(301)
end
@ -176,7 +260,7 @@ describe Groups::MilestonesController do
it 'returns not found' do
post :create,
group_id: redirect_route.path,
milestone: { project_ids: [project.id, project2.id], title: title }
milestone: { title: title }
expect(response).to have_http_status(404)
end

View file

@ -31,6 +31,40 @@ describe Projects::MilestonesController do
end
end
describe "#index" do
context "as html" do
before do
get :index, namespace_id: project.namespace.id, project_id: project.id
end
it "queries only projects milestones" do
milestones = assigns(:milestones)
expect(milestones.count).to eq(1)
expect(milestones.where(project_id: nil)).to be_empty
end
end
context "as json" do
let!(:group) { create(:group, :public) }
let!(:group_milestone) { create(:milestone, group: group) }
let!(:group_member) { create(:group_member, group: group, user: user) }
before do
project.update(namespace: group)
get :index, namespace_id: project.namespace.id, project_id: project.id, format: :json
end
it "queries projects milestones and groups milestones" do
milestones = assigns(:milestones)
expect(milestones.count).to eq(2)
expect(milestones.where(project_id: nil).first).to eq(group_milestone)
expect(milestones.where(group_id: nil).first).to eq(milestone)
end
end
end
describe "#destroy" do
it "removes milestone" do
expect(issue.milestone_id).to eq(milestone.id)

View file

@ -1,6 +1,8 @@
require 'spec_helper'
describe Projects::PipelineSchedulesController do
include AccessMatchersForController
set(:project) { create(:empty_project, :public) }
let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) }
@ -17,6 +19,14 @@ describe Projects::PipelineSchedulesController do
expect(response).to render_template(:index)
end
it 'avoids N + 1 queries' do
control_count = ActiveRecord::QueryRecorder.new { visit_pipelines_schedules }.count
create_list(:ci_pipeline_schedule, 2, project: project)
expect { visit_pipelines_schedules }.not_to exceed_query_limit(control_count)
end
context 'when the scope is set to active' do
let(:scope) { 'active' }
@ -36,20 +46,321 @@ describe Projects::PipelineSchedulesController do
end
end
describe 'GET edit' do
let(:user) { create(:user) }
describe 'GET #new' do
set(:user) { create(:user) }
before do
project.add_master(user)
project.add_developer(user)
sign_in(user)
end
it 'loads the pipeline schedule' do
get :edit, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id
it 'initializes a pipeline schedule model' do
get :new, namespace_id: project.namespace.to_param, project_id: project
expect(response).to have_http_status(:ok)
expect(assigns(:schedule)).to eq(pipeline_schedule)
expect(assigns(:schedule)).to be_a_new(Ci::PipelineSchedule)
end
end
describe 'POST #create' do
describe 'functionality' do
set(:user) { create(:user) }
before do
project.add_developer(user)
sign_in(user)
end
let(:basic_param) do
attributes_for(:ci_pipeline_schedule)
end
context 'when variables_attributes has one variable' do
let(:schedule) do
basic_param.merge({
variables_attributes: [{ key: 'AAA', value: 'AAA123' }]
})
end
it 'creates a new schedule' do
expect { go }
.to change { Ci::PipelineSchedule.count }.by(1)
.and change { Ci::PipelineScheduleVariable.count }.by(1)
expect(response).to have_http_status(:found)
Ci::PipelineScheduleVariable.last.tap do |v|
expect(v.key).to eq("AAA")
expect(v.value).to eq("AAA123")
end
end
end
context 'when variables_attributes has two variables and duplicted' do
let(:schedule) do
basic_param.merge({
variables_attributes: [{ key: 'AAA', value: 'AAA123' }, { key: 'AAA', value: 'BBB123' }]
})
end
it 'returns an error that the keys of variable are duplicated' do
expect { go }
.to change { Ci::PipelineSchedule.count }.by(0)
.and change { Ci::PipelineScheduleVariable.count }.by(0)
expect(assigns(:schedule).errors['variables']).not_to be_empty
end
end
end
describe 'security' do
let(:schedule) { attributes_for(:ci_pipeline_schedule) }
it { expect { go }.to be_allowed_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:master).of(project) }
it { expect { go }.to be_allowed_for(:developer).of(project) }
it { expect { go }.to be_denied_for(:reporter).of(project) }
it { expect { go }.to be_denied_for(:guest).of(project) }
it { expect { go }.to be_denied_for(:user) }
it { expect { go }.to be_denied_for(:external) }
it { expect { go }.to be_denied_for(:visitor) }
end
def go
post :create, namespace_id: project.namespace.to_param, project_id: project, schedule: schedule
end
end
describe 'PUT #update' do
describe 'functionality' do
set(:user) { create(:user) }
let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: user) }
before do
project.add_developer(user)
sign_in(user)
end
context 'when a pipeline schedule has no variables' do
let(:basic_param) do
{ description: 'updated_desc', cron: '0 1 * * *', cron_timezone: 'UTC', ref: 'patch-x', active: true }
end
context 'when params include one variable' do
let(:schedule) do
basic_param.merge({
variables_attributes: [{ key: 'AAA', value: 'AAA123' }]
})
end
it 'inserts new variable to the pipeline schedule' do
expect { go }.to change { Ci::PipelineScheduleVariable.count }.by(1)
pipeline_schedule.reload
expect(response).to have_http_status(:found)
expect(pipeline_schedule.variables.last.key).to eq('AAA')
expect(pipeline_schedule.variables.last.value).to eq('AAA123')
end
end
context 'when params include two duplicated variables' do
let(:schedule) do
basic_param.merge({
variables_attributes: [{ key: 'AAA', value: 'AAA123' }, { key: 'AAA', value: 'BBB123' }]
})
end
it 'returns an error that variables are duplciated' do
go
expect(assigns(:schedule).errors['variables']).not_to be_empty
end
end
end
context 'when a pipeline schedule has one variable' do
let(:basic_param) do
{ description: 'updated_desc', cron: '0 1 * * *', cron_timezone: 'UTC', ref: 'patch-x', active: true }
end
let!(:pipeline_schedule_variable) do
create(:ci_pipeline_schedule_variable,
key: 'CCC', pipeline_schedule: pipeline_schedule)
end
context 'when adds a new variable' do
let(:schedule) do
basic_param.merge({
variables_attributes: [{ key: 'AAA', value: 'AAA123' }]
})
end
it 'adds the new variable' do
expect { go }.to change { Ci::PipelineScheduleVariable.count }.by(1)
pipeline_schedule.reload
expect(pipeline_schedule.variables.last.key).to eq('AAA')
end
end
context 'when adds a new duplicated variable' do
let(:schedule) do
basic_param.merge({
variables_attributes: [{ key: 'CCC', value: 'AAA123' }]
})
end
it 'returns an error' do
expect { go }.not_to change { Ci::PipelineScheduleVariable.count }
pipeline_schedule.reload
expect(assigns(:schedule).errors['variables']).not_to be_empty
end
end
context 'when updates a variable' do
let(:schedule) do
basic_param.merge({
variables_attributes: [{ id: pipeline_schedule_variable.id, value: 'new_value' }]
})
end
it 'updates the variable' do
expect { go }.not_to change { Ci::PipelineScheduleVariable.count }
pipeline_schedule_variable.reload
expect(pipeline_schedule_variable.value).to eq('new_value')
end
end
context 'when deletes a variable' do
let(:schedule) do
basic_param.merge({
variables_attributes: [{ id: pipeline_schedule_variable.id, _destroy: true }]
})
end
it 'delete the existsed variable' do
expect { go }.to change { Ci::PipelineScheduleVariable.count }.by(-1)
end
end
context 'when deletes and creates a same key simultaneously' do
let(:schedule) do
basic_param.merge({
variables_attributes: [{ id: pipeline_schedule_variable.id, _destroy: true },
{ key: 'CCC', value: 'CCC123' }]
})
end
it 'updates the variable' do
expect { go }.not_to change { Ci::PipelineScheduleVariable.count }
pipeline_schedule.reload
expect(pipeline_schedule.variables.last.key).to eq('CCC')
expect(pipeline_schedule.variables.last.value).to eq('CCC123')
end
end
end
end
describe 'security' do
let(:schedule) { { description: 'updated_desc' } }
it { expect { go }.to be_allowed_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:master).of(project) }
it { expect { go }.to be_allowed_for(:developer).of(project).own(pipeline_schedule) }
it { expect { go }.to be_denied_for(:reporter).of(project) }
it { expect { go }.to be_denied_for(:guest).of(project) }
it { expect { go }.to be_denied_for(:user) }
it { expect { go }.to be_denied_for(:external) }
it { expect { go }.to be_denied_for(:visitor) }
context 'when a developer created a pipeline schedule' do
let(:developer_1) { create(:user) }
let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: developer_1) }
before do
project.add_developer(developer_1)
end
it { expect { go }.to be_allowed_for(developer_1) }
it { expect { go }.to be_denied_for(:developer).of(project) }
it { expect { go }.to be_allowed_for(:master).of(project) }
end
context 'when a master created a pipeline schedule' do
let(:master_1) { create(:user) }
let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: master_1) }
before do
project.add_master(master_1)
end
it { expect { go }.to be_allowed_for(master_1) }
it { expect { go }.to be_allowed_for(:master).of(project) }
it { expect { go }.to be_denied_for(:developer).of(project) }
end
end
def go
put :update, namespace_id: project.namespace.to_param,
project_id: project, id: pipeline_schedule,
schedule: schedule
end
end
describe 'GET #edit' do
describe 'functionality' do
let(:user) { create(:user) }
before do
project.add_master(user)
sign_in(user)
end
it 'loads the pipeline schedule' do
get :edit, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id
expect(response).to have_http_status(:ok)
expect(assigns(:schedule)).to eq(pipeline_schedule)
end
end
describe 'security' do
it { expect { go }.to be_allowed_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:master).of(project) }
it { expect { go }.to be_allowed_for(:developer).of(project).own(pipeline_schedule) }
it { expect { go }.to be_denied_for(:reporter).of(project) }
it { expect { go }.to be_denied_for(:guest).of(project) }
it { expect { go }.to be_denied_for(:user) }
it { expect { go }.to be_denied_for(:external) }
it { expect { go }.to be_denied_for(:visitor) }
end
def go
get :edit, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id
end
end
describe 'GET #take_ownership' do
describe 'security' do
it { expect { go }.to be_allowed_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:master).of(project) }
it { expect { go }.to be_allowed_for(:developer).of(project).own(pipeline_schedule) }
it { expect { go }.to be_denied_for(:reporter).of(project) }
it { expect { go }.to be_denied_for(:guest).of(project) }
it { expect { go }.to be_denied_for(:user) }
it { expect { go }.to be_denied_for(:external) }
it { expect { go }.to be_denied_for(:visitor) }
end
def go
post :take_ownership, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id
end
end
@ -65,7 +376,7 @@ describe Projects::PipelineSchedulesController do
end
it 'does not delete the pipeline schedule' do
expect(response).not_to have_http_status(:ok)
expect(response).to have_http_status(:not_found)
end
end
@ -84,57 +395,4 @@ describe Projects::PipelineSchedulesController do
end
end
end
describe 'security' do
include AccessMatchersForController
describe 'GET edit' do
it { expect { go }.to be_allowed_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:master).of(project) }
it { expect { go }.to be_allowed_for(:developer).of(project) }
it { expect { go }.to be_denied_for(:reporter).of(project) }
it { expect { go }.to be_denied_for(:guest).of(project) }
it { expect { go }.to be_denied_for(:user) }
it { expect { go }.to be_denied_for(:external) }
it { expect { go }.to be_denied_for(:visitor) }
def go
get :edit, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id
end
end
describe 'GET take_ownership' do
it { expect { go }.to be_allowed_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:master).of(project) }
it { expect { go }.to be_allowed_for(:developer).of(project) }
it { expect { go }.to be_denied_for(:reporter).of(project) }
it { expect { go }.to be_denied_for(:guest).of(project) }
it { expect { go }.to be_denied_for(:user) }
it { expect { go }.to be_denied_for(:external) }
it { expect { go }.to be_denied_for(:visitor) }
def go
post :take_ownership, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id
end
end
describe 'PUT update' do
it { expect { go }.to be_allowed_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:master).of(project) }
it { expect { go }.to be_allowed_for(:developer).of(project) }
it { expect { go }.to be_denied_for(:reporter).of(project) }
it { expect { go }.to be_denied_for(:guest).of(project) }
it { expect { go }.to be_denied_for(:user) }
it { expect { go }.to be_denied_for(:external) }
it { expect { go }.to be_denied_for(:visitor) }
def go
put :update, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id,
schedule: { description: 'a' }
end
end
end
end

View file

@ -0,0 +1,8 @@
FactoryGirl.define do
factory :ci_pipeline_schedule_variable, class: Ci::PipelineScheduleVariable do
sequence(:key) { |n| "VARIABLE_#{n}" }
value 'VARIABLE_VALUE'
pipeline_schedule factory: :ci_pipeline_schedule
end
end

View file

@ -1,7 +1,13 @@
FactoryGirl.define do
factory :milestone do
title
project factory: :empty_project
transient do
project nil
group nil
project_id nil
group_id nil
end
trait :active do
state "active"
@ -11,6 +17,20 @@ FactoryGirl.define do
state "closed"
end
after(:build) do |milestone, evaluator|
if evaluator.group
milestone.group = evaluator.group
elsif evaluator.group_id
milestone.group_id = evaluator.group_id
elsif evaluator.project
milestone.project = evaluator.project
elsif evaluator.project_id
milestone.project_id = evaluator.project_id
else
milestone.project = create(:empty_project)
end
end
factory :active_milestone, traits: [:active]
factory :closed_milestone, traits: [:closed]
end

View file

@ -33,4 +33,32 @@ feature 'Group milestones', :feature, :js do
expect(find('.start_date')).to have_content(Date.today.at_beginning_of_month.strftime('%b %-d, %Y'))
end
end
context 'milestones list' do
let!(:other_project) { create(:project_empty_repo, group: group) }
let!(:active_group_milestone) { create(:milestone, group: group, state: 'active') }
let!(:active_project_milestone1) { create(:milestone, project: project, state: 'active', title: 'v1.0') }
let!(:active_project_milestone2) { create(:milestone, project: other_project, state: 'active', title: 'v1.0') }
let!(:closed_group_milestone) { create(:milestone, group: group, state: 'closed') }
let!(:closed_project_milestone1) { create(:milestone, project: project, state: 'closed', title: 'v2.0') }
let!(:closed_project_milestone2) { create(:milestone, project: other_project, state: 'closed', title: 'v2.0') }
before do
visit group_milestones_path(group)
end
it 'counts milestones correctly' do
expect(find('.top-area .active .badge').text).to eq("2")
expect(find('.top-area .closed .badge').text).to eq("2")
expect(find('.top-area .all .badge').text).to eq("4")
end
it 'lists legacy group milestones and group milestones' do
legacy_milestone = GroupMilestone.build_collection(group, group.projects, { state: 'active' }).first
expect(page).to have_selector("#milestone_#{active_group_milestone.id}", count: 1)
expect(page).to have_selector("#milestone_#{legacy_milestone.milestones.first.id}", count: 1)
end
end
end

View file

@ -1,10 +1,12 @@
require 'rails_helper'
feature 'Milestone', feature: true do
let(:project) { create(:empty_project, :public) }
let(:group) { create(:group, :public) }
let(:project) { create(:empty_project, :public, namespace: group) }
let(:user) { create(:user) }
before do
create(:group_member, group: group, user: user)
project.team << [user, :master]
gitlab_sign_in(user)
end
@ -37,8 +39,8 @@ feature 'Milestone', feature: true do
end
end
feature 'Open a milestone with an existing title' do
scenario 'displays validation message' do
feature 'Open a project milestone with an existing title' do
scenario 'displays validation message when there is a project milestone with same title' do
milestone = create(:milestone, project: project, title: 8.7)
visit new_project_milestone_path(project)
@ -47,7 +49,20 @@ feature 'Milestone', feature: true do
end
find('input[name="commit"]').click
expect(find('.alert-danger')).to have_content('Title has already been taken')
expect(find('.alert-danger')).to have_content('already being used for another group or project milestone.')
end
scenario 'displays validation message when there is a group milestone with same title' do
milestone = create(:milestone, project_id: nil, group: project.group, title: 8.7)
visit new_group_milestone_path(project.group)
page.within '.milestone-form' do
fill_in "milestone_title", with: milestone.title
end
find('input[name="commit"]').click
expect(find('.alert-danger')).to have_content('already being used for another group or project milestone.')
end
end
end

View file

@ -1,6 +1,6 @@
require 'spec_helper'
feature 'Pipeline Schedules', :feature do
feature 'Pipeline Schedules', :feature, js: true do
include PipelineSchedulesHelper
let!(:project) { create(:project) }
@ -11,27 +11,20 @@ feature 'Pipeline Schedules', :feature do
before do
project.add_master(user)
gitlab_sign_in(user)
visit_page
end
describe 'GET /projects/pipeline_schedules' do
let(:visit_page) { visit_pipelines_schedules }
it 'avoids N + 1 queries' do
control_count = ActiveRecord::QueryRecorder.new { visit_pipelines_schedules }.count
create_list(:ci_pipeline_schedule, 2, project: project)
expect { visit_pipelines_schedules }.not_to exceed_query_limit(control_count)
before do
visit_pipelines_schedules
end
describe 'The view' do
it 'displays the required information description' do
page.within('.pipeline-schedule-table-row') do
expect(page).to have_content('pipeline schedule')
expect(page).to have_content(pipeline_schedule.real_next_run.strftime('%b %d, %Y'))
expect(find(".next-run-cell time")['data-original-title'])
.to include(pipeline_schedule.real_next_run.strftime('%b %-d, %Y'))
expect(page).to have_link('master')
expect(page).to have_link("##{pipeline.id}")
end
@ -62,7 +55,7 @@ feature 'Pipeline Schedules', :feature do
it 'deletes the pipeline' do
click_link 'Delete'
expect(page).not_to have_content('pipeline schedule')
expect(page).not_to have_css(".pipeline-schedule-table-row")
end
end
@ -78,8 +71,10 @@ feature 'Pipeline Schedules', :feature do
end
end
describe 'POST /projects/pipeline_schedules/new', js: true do
let(:visit_page) { visit_new_pipeline_schedule }
describe 'POST /projects/pipeline_schedules/new' do
before do
visit_new_pipeline_schedule
end
it 'sets defaults for timezone and target branch' do
expect(page).to have_button('master')
@ -100,8 +95,8 @@ feature 'Pipeline Schedules', :feature do
end
end
describe 'PATCH /projects/pipelines_schedules/:id/edit', js: true do
let(:visit_page) do
describe 'PATCH /projects/pipelines_schedules/:id/edit' do
before do
edit_pipeline_schedule
end
@ -134,6 +129,72 @@ feature 'Pipeline Schedules', :feature do
end
end
context 'when user creates a new pipeline schedule with variables' do
background do
visit_pipelines_schedules
click_link 'New schedule'
fill_in_schedule_form
all('[name="schedule[variables_attributes][][key]"]')[0].set('AAA')
all('[name="schedule[variables_attributes][][value]"]')[0].set('AAA123')
all('[name="schedule[variables_attributes][][key]"]')[1].set('BBB')
all('[name="schedule[variables_attributes][][value]"]')[1].set('BBB123')
save_pipeline_schedule
end
scenario 'user sees the new variable in edit window' do
find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
page.within('.pipeline-variable-list') do
expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-key-input").value).to eq('AAA')
expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-value-input").value).to eq('AAA123')
expect(find(".pipeline-variable-row:nth-child(2) .pipeline-variable-key-input").value).to eq('BBB')
expect(find(".pipeline-variable-row:nth-child(2) .pipeline-variable-value-input").value).to eq('BBB123')
end
end
end
context 'when user edits a variable of a pipeline schedule' do
background do
create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule|
create(:ci_pipeline_schedule_variable, key: 'AAA', value: 'AAA123', pipeline_schedule: pipeline_schedule)
end
visit_pipelines_schedules
find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
all('[name="schedule[variables_attributes][][key]"]')[0].set('foo')
all('[name="schedule[variables_attributes][][value]"]')[0].set('bar')
click_button 'Save pipeline schedule'
end
scenario 'user sees the updated variable in edit window' do
find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
page.within('.pipeline-variable-list') do
expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-key-input").value).to eq('foo')
expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-value-input").value).to eq('bar')
end
end
end
context 'when user removes a variable of a pipeline schedule' do
background do
create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule|
create(:ci_pipeline_schedule_variable, key: 'AAA', value: 'AAA123', pipeline_schedule: pipeline_schedule)
end
visit_pipelines_schedules
find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
find('.pipeline-variable-list .pipeline-variable-row-remove-button').click
click_button 'Save pipeline schedule'
end
scenario 'user does not see the removed variable in edit window' do
find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
page.within('.pipeline-variable-list') do
expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-key-input").value).to eq('')
expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-value-input").value).to eq('')
end
end
end
def visit_new_pipeline_schedule
visit new_project_pipeline_schedule_path(project, pipeline_schedule)
end

View file

@ -59,6 +59,23 @@ describe IssuesFinder do
end
end
context 'filtering by group milestone' do
let!(:group) { create(:group, :public) }
let(:group_milestone) { create(:milestone, group: group) }
let!(:group_member) { create(:group_member, group: group, user: user) }
let(:params) { { milestone_title: group_milestone.title } }
before do
project2.update(namespace: group)
issue2.update(milestone: group_milestone)
issue3.update(milestone: group_milestone)
end
it 'returns issues assigned to that group milestone' do
expect(issues).to contain_exactly(issue2, issue3)
end
end
context 'filtering by no milestone' do
let(:params) { { milestone_title: Milestone::None.title } }

View file

@ -47,6 +47,25 @@ describe MergeRequestsFinder do
expect(merge_requests).to contain_exactly(merge_request1)
end
context 'filtering by group milestone' do
let!(:group) { create(:group, :public) }
let(:group_milestone) { create(:milestone, group: group) }
let!(:group_member) { create(:group_member, group: group, user: user) }
let(:params) { { milestone_title: group_milestone.title } }
before do
project2.update(namespace: group)
merge_request2.update(milestone: group_milestone)
merge_request3.update(milestone: group_milestone)
end
it 'returns issues assigned to that group milestone' do
merge_requests = described_class.new(user, params).execute
expect(merge_requests).to contain_exactly(merge_request2, merge_request3)
end
end
context 'with created_after and created_before params' do
let(:project4) { create(:empty_project, forked_from_project: project1) }

View file

@ -0,0 +1,90 @@
require 'spec_helper'
describe MilestonesFinder do
let(:group) { create(:group) }
let(:project_1) { create(:empty_project, namespace: group) }
let(:project_2) { create(:empty_project, namespace: group) }
let!(:milestone_1) { create(:milestone, group: group, title: 'one test', due_date: Date.today) }
let!(:milestone_2) { create(:milestone, group: group) }
let!(:milestone_3) { create(:milestone, project: project_1, state: 'active', due_date: Date.tomorrow) }
let!(:milestone_4) { create(:milestone, project: project_2, state: 'active') }
it 'it returns milestones for projects' do
result = described_class.new(project_ids: [project_1.id, project_2.id], state: 'all').execute
expect(result).to contain_exactly(milestone_3, milestone_4)
end
it 'returns milestones for groups' do
result = described_class.new(group_ids: group.id, state: 'all').execute
expect(result).to contain_exactly(milestone_1, milestone_2)
end
it 'returns milestones for groups and projects' do
result = described_class.new(project_ids: [project_1.id, project_2.id], group_ids: group.id, state: 'all').execute
expect(result).to contain_exactly(milestone_1, milestone_2, milestone_3, milestone_4)
end
context 'with filters' do
let(:params) do
{
project_ids: [project_1.id, project_2.id],
group_ids: group.id,
state: 'all'
}
end
before do
milestone_1.close
milestone_3.close
end
it 'filters by active state' do
params[:state] = 'active'
result = described_class.new(params).execute
expect(result).to contain_exactly(milestone_2, milestone_4)
end
it 'filters by closed state' do
params[:state] = 'closed'
result = described_class.new(params).execute
expect(result).to contain_exactly(milestone_1, milestone_3)
end
it 'filters by title' do
result = described_class.new(params.merge(title: 'one test')).execute
expect(result.to_a).to contain_exactly(milestone_1)
end
end
context 'with order' do
let(:params) do
{
project_ids: [project_1.id, project_2.id],
group_ids: group.id,
state: 'all'
}
end
it "default orders by due date" do
result = described_class.new(params).execute
expect(result.first).to eq(milestone_1)
expect(result.second).to eq(milestone_3)
end
it "orders by parameter" do
result = described_class.new(params.merge(order: 'id DESC')).execute
expect(result.first).to eq(milestone_4)
expect(result.second).to eq(milestone_3)
expect(result.third).to eq(milestone_2)
expect(result.fourth).to eq(milestone_1)
end
end
end

View file

@ -22,7 +22,8 @@
"properties": {
"id": { "type": "integer" },
"iid": { "type": "integer" },
"project_id": { "type": "integer" },
"project_id": { "type": ["integer", "null"] },
"group_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"state": { "type": "string" },

View file

@ -53,7 +53,8 @@
"properties": {
"id": { "type": "integer" },
"iid": { "type": "integer" },
"project_id": { "type": "integer" },
"project_id": { "type": ["integer", "null"] },
"group_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"state": { "type": "string" },

View file

@ -22,7 +22,8 @@
"properties": {
"id": { "type": "integer" },
"iid": { "type": "integer" },
"project_id": { "type": "integer" },
"project_id": { "type": ["integer", "null"] },
"group_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"state": { "type": "string" },

View file

@ -53,7 +53,8 @@
"properties": {
"id": { "type": "integer" },
"iid": { "type": "integer" },
"project_id": { "type": "integer" },
"project_id": { "type": ["integer", "null"] },
"group_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"state": { "type": "string" },

View file

@ -0,0 +1,145 @@
import {
setupPipelineVariableList,
insertRow,
removeRow,
} from '~/pipeline_schedules/setup_pipeline_variable_list';
describe('Pipeline Variable List', () => {
let $markup;
describe('insertRow', () => {
it('should insert another row', () => {
$markup = $(`<div>
<li class="js-row">
<input>
<textarea></textarea>
</li>
</div>`);
insertRow($markup.find('.js-row'));
expect($markup.find('.js-row').length).toBe(2);
});
it('should clear `data-is-persisted` on cloned row', () => {
$markup = $(`<div>
<li class="js-row" data-is-persisted="true"></li>
</div>`);
insertRow($markup.find('.js-row'));
const $lastRow = $markup.find('.js-row').last();
expect($lastRow.attr('data-is-persisted')).toBe(undefined);
});
it('should clear inputs on cloned row', () => {
$markup = $(`<div>
<li class="js-row">
<input value="foo">
<textarea>bar</textarea>
</li>
</div>`);
insertRow($markup.find('.js-row'));
const $lastRow = $markup.find('.js-row').last();
expect($lastRow.find('input').val()).toBe('');
expect($lastRow.find('textarea').val()).toBe('');
});
});
describe('removeRow', () => {
it('should remove dynamic row', () => {
$markup = $(`<div>
<li class="js-row">
<input>
<textarea></textarea>
</li>
</div>`);
removeRow($markup.find('.js-row'));
expect($markup.find('.js-row').length).toBe(0);
});
it('should hide and mark to destroy with already persisted rows', () => {
$markup = $(`<div>
<li class="js-row" data-is-persisted="true">
<input class="js-destroy-input">
</li>
</div>`);
const $row = $markup.find('.js-row');
removeRow($row);
expect($row.find('.js-destroy-input').val()).toBe('1');
expect($markup.find('.js-row').length).toBe(1);
});
});
describe('setupPipelineVariableList', () => {
beforeEach(() => {
$markup = $(`<form>
<li class="js-row">
<input class="js-user-input" name="schedule[variables_attributes][][key]">
<textarea class="js-user-input" name="schedule[variables_attributes][][value]"></textarea>
<button class="js-row-remove-button"></button>
<button class="js-row-add-button"></button>
</li>
</form>`);
setupPipelineVariableList($markup);
});
it('should remove the row when clicking the remove button', () => {
$markup.find('.js-row-remove-button').trigger('click');
expect($markup.find('.js-row').length).toBe(0);
});
it('should add another row when editing the last rows key input', () => {
const $row = $markup.find('.js-row');
$row.find('input.js-user-input')
.val('foo')
.trigger('input');
expect($markup.find('.js-row').length).toBe(2);
});
it('should add another row when editing the last rows value textarea', () => {
const $row = $markup.find('.js-row');
$row.find('textarea.js-user-input')
.val('foo')
.trigger('input');
expect($markup.find('.js-row').length).toBe(2);
});
it('should remove empty row after blurring', () => {
const $row = $markup.find('.js-row');
$row.find('input.js-user-input')
.val('foo')
.trigger('input');
expect($markup.find('.js-row').length).toBe(2);
$row.find('input.js-user-input')
.val('')
.trigger('input')
.trigger('blur');
expect($markup.find('.js-row').length).toBe(1);
});
it('should clear out the `name` attribute on the inputs for the last empty row on form submission (avoid BE validation)', () => {
const $row = $markup.find('.js-row');
expect($row.find('input').attr('name')).toBe('schedule[variables_attributes][][key]');
expect($row.find('textarea').attr('name')).toBe('schedule[variables_attributes][][value]');
$markup.filter('form').submit();
expect($row.find('input').attr('name')).toBe('');
expect($row.find('textarea').attr('name')).toBe('');
});
});
});

View file

@ -3,6 +3,20 @@ require "spec_helper"
describe Gitlab::Git::Repository, seed_helper: true do
include Gitlab::EncodingHelper
shared_examples 'wrapping gRPC errors' do |gitaly_client_class, gitaly_client_method|
it 'wraps gRPC not found error' do
expect_any_instance_of(gitaly_client_class).to receive(gitaly_client_method)
.and_raise(GRPC::NotFound)
expect { subject }.to raise_error(Gitlab::Git::Repository::NoRepository)
end
it 'wraps gRPC unknown error' do
expect_any_instance_of(gitaly_client_class).to receive(gitaly_client_method)
.and_raise(GRPC::Unknown)
expect { subject }.to raise_error(Gitlab::Git::CommandError)
end
end
let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH) }
describe "Respond to" do
@ -35,16 +49,8 @@ describe Gitlab::Git::Repository, seed_helper: true do
repository.root_ref
end
it 'wraps GRPC not found' do
expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:default_branch_name)
.and_raise(GRPC::NotFound)
expect { repository.root_ref }.to raise_error(Gitlab::Git::Repository::NoRepository)
end
it 'wraps GRPC exceptions' do
expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:default_branch_name)
.and_raise(GRPC::Unknown)
expect { repository.root_ref }.to raise_error(Gitlab::Git::CommandError)
it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::Ref, :default_branch_name do
subject { repository.root_ref }
end
end
@ -130,17 +136,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
subject
end
it 'wraps GRPC not found' do
expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:branch_names)
.and_raise(GRPC::NotFound)
expect { subject }.to raise_error(Gitlab::Git::Repository::NoRepository)
end
it 'wraps GRPC other exceptions' do
expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:branch_names)
.and_raise(GRPC::Unknown)
expect { subject }.to raise_error(Gitlab::Git::CommandError)
end
it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::Ref, :branch_names
end
describe '#tag_names' do
@ -168,17 +164,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
subject
end
it 'wraps GRPC not found' do
expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:tag_names)
.and_raise(GRPC::NotFound)
expect { subject }.to raise_error(Gitlab::Git::Repository::NoRepository)
end
it 'wraps GRPC exceptions' do
expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:tag_names)
.and_raise(GRPC::Unknown)
expect { subject }.to raise_error(Gitlab::Git::CommandError)
end
it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::Ref, :tag_names
end
shared_examples 'archive check' do |extenstion|
@ -438,8 +424,21 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
describe '#commit_count' do
it { expect(repository.commit_count("master")).to eq(25) }
it { expect(repository.commit_count("feature")).to eq(9) }
shared_examples 'counting commits' do
it { expect(repository.commit_count("master")).to eq(25) }
it { expect(repository.commit_count("feature")).to eq(9) }
end
context 'when Gitaly commit_count feature is enabled' do
it_behaves_like 'counting commits'
it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::Commit, :commit_count do
subject { repository.commit_count('master') }
end
end
context 'when Gitaly commit_count feature is disabled', skip_gitaly_mock: true do
it_behaves_like 'counting commits'
end
end
describe "#reset" do
@ -1298,16 +1297,8 @@ describe Gitlab::Git::Repository, seed_helper: true do
@repo.local_branches
end
it 'wraps GRPC not found' do
expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:local_branches)
.and_raise(GRPC::NotFound)
expect { @repo.local_branches }.to raise_error(Gitlab::Git::Repository::NoRepository)
end
it 'wraps GRPC exceptions' do
expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:local_branches)
.and_raise(GRPC::Unknown)
expect { @repo.local_branches }.to raise_error(Gitlab::Git::CommandError)
it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::Ref, :local_branches do
subject { @repo.local_branches }
end
end

View file

@ -45,6 +45,7 @@ label:
- merge_requests
- priorities
milestone:
- group
- project
- issues
- labels
@ -133,8 +134,11 @@ pipeline_schedules:
- owner
- pipelines
- last_pipeline
- variables
pipeline_schedule:
- pipelines
pipeline_schedule_variables:
- pipeline_schedule
deploy_keys:
- user
- deploy_keys_projects

View file

@ -82,6 +82,7 @@ Milestone:
- id
- title
- project_id
- group_id
- description
- due_date
- start_date

View file

@ -1427,6 +1427,23 @@ describe Ci::Build, :models do
it { is_expected.to include(predefined_trigger_variable) }
end
context 'when a job was triggered by a pipeline schedule' do
let(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) }
let!(:pipeline_schedule_variable) do
create(:ci_pipeline_schedule_variable,
key: 'SCHEDULE_VARIABLE_KEY',
pipeline_schedule: pipeline_schedule)
end
before do
pipeline_schedule.pipelines << pipeline
pipeline_schedule.reload
end
it { is_expected.to include(pipeline_schedule_variable.to_runner_variable) }
end
context 'when yaml_variables are undefined' do
before do
build.yaml_variables = nil

View file

@ -5,6 +5,7 @@ describe Ci::PipelineSchedule, models: true do
it { is_expected.to belong_to(:owner) }
it { is_expected.to have_many(:pipelines) }
it { is_expected.to have_many(:variables) }
it { is_expected.to respond_to(:ref) }
it { is_expected.to respond_to(:cron) }
@ -117,4 +118,20 @@ describe Ci::PipelineSchedule, models: true do
end
end
end
describe '#job_variables' do
let!(:pipeline_schedule) { create(:ci_pipeline_schedule) }
let!(:pipeline_schedule_variables) do
create_list(:ci_pipeline_schedule_variable, 2, pipeline_schedule: pipeline_schedule)
end
subject { pipeline_schedule.job_variables }
before do
pipeline_schedule.reload
end
it { is_expected.to contain_exactly(*pipeline_schedule_variables.map(&:to_runner_variable)) }
end
end

View file

@ -0,0 +1,7 @@
require 'spec_helper'
describe Ci::PipelineScheduleVariable, models: true do
subject { build(:ci_pipeline_schedule_variable) }
it { is_expected.to include_module(HasVariable) }
end

View file

@ -6,9 +6,6 @@ describe Milestone, models: true do
allow(subject).to receive(:set_iid).and_return(false)
end
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_presence_of(:project) }
describe 'start_date' do
it 'adds an error when start_date is greated then due_date' do
milestone = build(:milestone, start_date: Date.tomorrow, due_date: Date.yesterday)
@ -37,17 +34,42 @@ describe Milestone, models: true do
end
end
describe "unique milestone title per project" do
it "does not accept the same title in a project twice" do
new_milestone = Milestone.new(project: milestone.project, title: milestone.title)
expect(new_milestone).not_to be_valid
describe "unique milestone title" do
context "per project" do
it "does not accept the same title in a project twice" do
new_milestone = Milestone.new(project: milestone.project, title: milestone.title)
expect(new_milestone).not_to be_valid
end
it "accepts the same title in another project" do
project = create(:empty_project)
new_milestone = Milestone.new(project: project, title: milestone.title)
expect(new_milestone).to be_valid
end
end
it "accepts the same title in another project" do
project = build(:empty_project)
new_milestone = Milestone.new(project: project, title: milestone.title)
context "per group" do
let(:group) { create(:group) }
let(:milestone) { create(:milestone, group: group) }
expect(new_milestone).to be_valid
before do
project.update(group: group)
end
it "does not accept the same title in a group twice" do
new_milestone = Milestone.new(group: group, title: milestone.title)
expect(new_milestone).not_to be_valid
end
it "does not accept the same title of a child project milestone" do
create(:milestone, project: group.projects.first)
new_milestone = Milestone.new(group: group, title: milestone.title)
expect(new_milestone).not_to be_valid
end
end
end

View file

@ -279,6 +279,8 @@ describe API::PipelineSchedules do
end
context 'authenticated user with invalid permissions' do
let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: master) }
it 'does not delete pipeline_schedule' do
delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer)

View file

@ -37,9 +37,6 @@ describe Issues::MoveService, services: true do
describe '#execute' do
shared_context 'issue move executed' do
let!(:milestone2) do
create(:milestone, project_id: new_project.id, title: 'v9.0')
end
let!(:award_emoji) { create(:award_emoji, awardable: old_issue) }
let!(:new_issue) { move_service.execute(old_issue, new_project) }
@ -48,6 +45,63 @@ describe Issues::MoveService, services: true do
context 'issue movable' do
include_context 'user can move issue'
context 'move to new milestone' do
let(:new_issue) { move_service.execute(old_issue, new_project) }
context 'project milestone' do
let!(:milestone2) do
create(:milestone, project_id: new_project.id, title: 'v9.0')
end
it 'assigns milestone to new issue' do
expect(new_issue.reload.milestone.title).to eq 'v9.0'
expect(new_issue.reload.milestone).to eq(milestone2)
end
end
context 'group milestones' do
let!(:group) { create(:group, :private) }
let!(:group_milestone_1) do
create(:milestone, group_id: group.id, title: 'v9.0_group')
end
before do
old_issue.update(milestone: group_milestone_1)
old_project.update(namespace: group)
new_project.update(namespace: group)
group.add_users([user], GroupMember::DEVELOPER)
end
context 'when moving to a project of the same group' do
it 'keeps the same group milestone' do
expect(new_issue.reload.project).to eq(new_project)
expect(new_issue.reload.milestone).to eq(group_milestone_1)
end
end
context 'when moving to a project of a different group' do
let!(:group_2) { create(:group, :private) }
let!(:group_milestone_2) do
create(:milestone, group_id: group_2.id, title: 'v9.0_group')
end
before do
old_issue.update(milestone: group_milestone_1)
new_project.update(namespace: group_2)
group_2.add_users([user], GroupMember::DEVELOPER)
end
it 'assigns to new group milestone of same title' do
expect(new_issue.reload.project).to eq(new_project)
expect(new_issue.reload.milestone).to eq(group_milestone_2)
end
end
end
end
context 'generic issue' do
include_context 'issue move executed'
@ -55,11 +109,6 @@ describe Issues::MoveService, services: true do
expect(new_issue.project).to eq new_project
end
it 'assigns milestone to new issue' do
expect(new_issue.reload.milestone.title).to eq 'v9.0'
expect(new_issue.reload.milestone).to eq(milestone2)
end
it 'assign labels to new issue' do
expected_label_titles = new_issue.reload.labels.map(&:title)
expect(expected_label_titles).to include 'label1'

View file

@ -253,13 +253,13 @@ describe Issues::UpdateService, services: true do
end
context 'when the milestone change' do
before do
update_issue(milestone: create(:milestone))
end
it 'marks todos as done' do
update_issue(milestone: create(:milestone))
expect(todo.reload.done?).to eq true
end
it_behaves_like 'system notes for milestones'
end
context 'when the labels change' do

View file

@ -296,13 +296,13 @@ describe MergeRequests::UpdateService, services: true do
end
context 'when the milestone change' do
before do
update_merge_request({ milestone: create(:milestone) })
end
it 'marks pending todos as done' do
update_merge_request({ milestone: create(:milestone) })
expect(pending_todo.reload).to be_done
end
it_behaves_like 'system notes for milestones'
end
context 'when the labels change' do

View file

@ -5,3 +5,34 @@ shared_examples 'cache counters invalidator' do
described_class.new(project, user, {}).execute(merge_request)
end
end
shared_examples 'system notes for milestones' do
def update_issuable(opts)
issuable = try(:issue) || try(:merge_request)
described_class.new(project, user, opts).execute(issuable)
end
context 'group milestones' do
let(:group) { create(:group) }
let(:group_milestone) { create(:milestone, group: group) }
before do
project.update(namespace: group)
create(:group_member, group: group, user: user)
end
it 'does not create system note' do
expect do
update_issuable(milestone: group_milestone)
end.not_to change { Note.system.count }
end
end
context 'project milestones' do
it 'creates system note' do
expect do
update_issuable(milestone: create(:milestone))
end.to change { Note.system.count }.by(1)
end
end
end

View file

@ -50,9 +50,24 @@ module AccessMatchersForController
"be #{type} for #{role}. Expected: #{expected.join(',')} Got: #{result}"
end
def update_owner(objects, user)
return unless objects
objects.each do |object|
if object.respond_to?(:owner)
object.update_attribute(:owner, user)
elsif object.respond_to?(:user)
object.update_attribute(:user, user)
else
raise ArgumentError, "cannot own this object #{object}"
end
end
end
matcher :be_allowed_for do |role|
match do |action|
emulate_user(role, @membership)
user = emulate_user(role, @membership)
update_owner(@objects, user)
action.call
EXPECTED_STATUS_CODE_ALLOWED.include?(response.status)
@ -62,13 +77,18 @@ module AccessMatchersForController
@membership = membership
end
chain :own do |*objects|
@objects = objects
end
description { description_for(role, 'allowed', EXPECTED_STATUS_CODE_ALLOWED, response.status) }
supports_block_expectations
end
matcher :be_denied_for do |role|
match do |action|
emulate_user(role, @membership)
user = emulate_user(role, @membership)
update_owner(@objects, user)
action.call
EXPECTED_STATUS_CODE_DENIED.include?(response.status)
@ -78,6 +98,10 @@ module AccessMatchersForController
@membership = membership
end
chain :own do |*objects|
@objects = objects
end
description { description_for(role, 'denied', EXPECTED_STATUS_CODE_DENIED, response.status) }
supports_block_expectations
end