提交 8df3997a 编写于 作者: Z Zeger-Jan van de Weg 提交者: Kamil Trzciński

Add Pipeline Schedules that supersedes experimental Trigger Schedule

上级 8a0cde81
......@@ -65,6 +65,7 @@ class GlFieldError {
this.state = {
valid: false,
empty: true,
submitted: false,
};
this.initFieldValidation();
......@@ -108,9 +109,10 @@ class GlFieldError {
const currentValue = this.accessCurrentValue();
this.state.valid = false;
this.state.empty = currentValue === '';
this.state.submitted = true;
this.renderValidity();
this.form.focusOnFirstInvalid.apply(this.form);
// For UX, wait til after first invalid submission to check each keyup
this.inputElement.off('keyup.fieldValidator')
.on('keyup.fieldValidator', this.updateValidity.bind(this));
......
......@@ -37,6 +37,15 @@ class GlFieldErrors {
}
}
/* Public method for triggering validity updates manually */
updateFormValidityState() {
this.state.inputs.forEach((field) => {
if (field.state.submitted) {
field.updateValidity();
}
});
}
focusOnFirstInvalid () {
const firstInvalid = this.state.inputs.filter((input) => !input.inputDomElement.validity.valid)[0];
firstInvalid.inputElement.focus();
......
import Vue from 'vue';
const inputNameAttribute = 'schedule[cron]';
export default {
props: {
initialCronInterval: {
type: String,
required: false,
default: '',
},
},
data() {
return {
inputNameAttribute,
cronInterval: this.initialCronInterval,
cronIntervalPresets: {
everyDay: '0 4 * * *',
everyWeek: '0 4 * * 0',
everyMonth: '0 4 1 * *',
},
cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron',
customInputEnabled: false,
};
},
computed: {
showUnsetWarning() {
return this.cronInterval === '';
},
intervalIsPreset() {
return _.contains(this.cronIntervalPresets, this.cronInterval);
},
// The text input is editable when there's a custom interval, or when it's
// a preset interval and the user clicks the 'custom' radio button
isEditable() {
return !!(this.customInputEnabled || !this.intervalIsPreset);
},
},
methods: {
toggleCustomInput(shouldEnable) {
this.customInputEnabled = shouldEnable;
if (shouldEnable) {
// We need to change the value so other radios don't remain selected
// because the model (cronInterval) hasn't changed. The server trims it.
this.cronInterval = `${this.cronInterval} `;
}
},
},
created() {
if (this.intervalIsPreset) {
this.enableCustomInput = false;
}
},
watch: {
cronInterval() {
// updates field validation state when model changes, as
// glFieldError only updates on input.
Vue.nextTick(() => {
gl.pipelineScheduleFieldErrors.updateFormValidityState();
});
},
},
template: `
<div class="interval-pattern-form-group">
<input
id="custom"
class="label-light"
type="radio"
:name="inputNameAttribute"
:value="cronInterval"
:checked="isEditable"
@click="toggleCustomInput(true)"
/>
<label for="custom">
Custom
</label>
<span class="cron-syntax-link-wrap">
(<a :href="cronSyntaxUrl" target="_blank">Cron syntax</a>)
</span>
<input
id="every-day"
class="label-light"
type="radio"
v-model="cronInterval"
:name="inputNameAttribute"
:value="cronIntervalPresets.everyDay"
@click="toggleCustomInput(false)"
/>
<label class="label-light" for="every-day">
Every day (at 4:00am)
</label>
<input
id="every-week"
class="label-light"
type="radio"
v-model="cronInterval"
:name="inputNameAttribute"
:value="cronIntervalPresets.everyWeek"
@click="toggleCustomInput(false)"
/>
<label class="label-light" for="every-week">
Every week (Sundays at 4:00am)
</label>
<input
id="every-month"
class="label-light"
type="radio"
v-model="cronInterval"
:name="inputNameAttribute"
:value="cronIntervalPresets.everyMonth"
@click="toggleCustomInput(false)"
/>
<label class="label-light" for="every-month">
Every month (on the 1st at 4:00am)
</label>
<div class="cron-interval-input-wrapper col-md-6">
<input
id="schedule_cron"
class="form-control inline cron-interval-input"
type="text"
placeholder="Define a custom pattern with cron syntax"
required="true"
v-model="cronInterval"
:name="inputNameAttribute"
:disabled="!isEditable"
/>
</div>
<span class="cron-unset-status col-md-3" v-if="showUnsetWarning">
Schedule not yet set
</span>
</div>
`,
};
import Cookies from 'js-cookie';
import illustrationSvg from '../icons/intro_illustration.svg';
const cookieKey = 'pipeline_schedules_callout_dismissed';
export default {
data() {
return {
illustrationSvg,
calloutDismissed: Cookies.get(cookieKey) === 'true',
};
},
methods: {
dismissCallout() {
this.calloutDismissed = true;
Cookies.set(cookieKey, this.calloutDismissed, { expires: 365 });
},
},
template: `
<div v-if="!calloutDismissed" class="pipeline-schedules-user-callout user-callout">
<div class="bordered-box landing content-block">
<button
id="dismiss-callout-btn"
class="btn btn-default close"
@click="dismissCallout">
<i class="fa fa-times"></i>
</button>
<div class="svg-container" v-html="illustrationSvg"></div>
<div class="user-callout-copy">
<h4>Scheduling Pipelines</h4>
<p>
The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags.
Those scheduled pipelines will inherit limited project access based on their associated user.
</p>
<p> Learn more in the
<!-- FIXME -->
<a href="random.com">pipeline schedules documentation</a>.
</p>
</div>
</div>
</div>
`,
};
export default class TargetBranchDropdown {
constructor() {
this.$dropdown = $('.js-target-branch-dropdown');
this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
this.$input = $('#schedule_ref');
this.initialValue = this.$input.val();
this.initDropdown();
}
initDropdown() {
this.$dropdown.glDropdown({
data: this.formatBranchesList(),
filterable: true,
selectable: true,
toggleLabel: item => item.name,
search: {
fields: ['name'],
},
clicked: cfg => this.updateInputValue(cfg),
text: item => item.name,
});
this.setDropdownToggle();
}
formatBranchesList() {
return this.$dropdown.data('data')
.map(val => ({ name: val }));
}
setDropdownToggle() {
if (this.initialValue) {
this.$dropdownToggle.text(this.initialValue);
}
}
updateInputValue({ selectedObj, e }) {
e.preventDefault();
this.$input.val(selectedObj.name);
gl.pipelineScheduleFieldErrors.updateFormValidityState();
}
}
/* eslint-disable class-methods-use-this */
export default class TimezoneDropdown {
constructor() {
this.$dropdown = $('.js-timezone-dropdown');
this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
this.$input = $('#schedule_cron_timezone');
this.timezoneData = this.$dropdown.data('data');
this.initialValue = this.$input.val();
this.initDropdown();
}
initDropdown() {
this.$dropdown.glDropdown({
data: this.timezoneData,
filterable: true,
selectable: true,
toggleLabel: item => item.name,
search: {
fields: ['name'],
},
clicked: cfg => this.updateInputValue(cfg),
text: item => this.formatTimezone(item),
});
this.setDropdownToggle();
}
formatUtcOffset(offset) {
let prefix = '';
if (offset > 0) {
prefix = '+';
} else if (offset < 0) {
prefix = '-';
}
return `${prefix} ${Math.abs(offset / 3600)}`;
}
formatTimezone(item) {
return `[UTC ${this.formatUtcOffset(item.offset)}] ${item.name}`;
}
setDropdownToggle() {
if (this.initialValue) {
this.$dropdownToggle.text(this.initialValue);
}
}
updateInputValue({ selectedObj, e }) {
e.preventDefault();
this.$input.val(selectedObj.identifier);
gl.pipelineScheduleFieldErrors.updateFormValidityState();
}
}
<svg width="140" height="102" viewBox="0 0 140 102" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>illustration</title><defs><rect id="a" width="12.033" height="40.197" rx="3"/><rect id="b" width="12.033" height="40.197" rx="3"/></defs><g fill="none" fill-rule="evenodd"><g transform="translate(-.446)"><path d="M91.747 35.675v-6.039a2.996 2.996 0 0 0-2.999-3.005H54.635a2.997 2.997 0 0 0-2.999 3.005v6.039H40.092a3.007 3.007 0 0 0-2.996 3.005v34.187a2.995 2.995 0 0 0 2.996 3.005h11.544V79.9a2.996 2.996 0 0 0 2.999 3.005h34.113a2.997 2.997 0 0 0 2.999-3.005v-4.03h11.544a3.007 3.007 0 0 0 2.996-3.004V38.68a2.995 2.995 0 0 0-2.996-3.005H91.747z" stroke="#B5A7DD" stroke-width="2"/><rect stroke="#E5E5E5" stroke-width="2" fill="#FFF" x="21.556" y="38.69" width="98.27" height="34.167" rx="3"/><path d="M121.325 38.19c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zM121.325 71.854a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038z" fill="#E5E5E5"/><g transform="translate(110.3 35.675)"><use fill="#FFF" xlink:href="#a"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="9.547" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.099" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="30.65" rx="1.504" ry="1.507"/></g><path d="M6.008 38.19c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zM6.008 71.854a1.004 1.004 0 0 1 0 2.006H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039z" fill="#E5E5E5"/><g transform="translate(19.05 35.675)"><use fill="#FFF" xlink:href="#b"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="10.049" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.601" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="31.153" rx="1.504" ry="1.507"/></g><g transform="translate(47.096)"><g transform="translate(7.05)"><ellipse fill="#FC8A51" cx="17.548" cy="5.025" rx="4.512" ry="4.522"/><rect stroke="#B5A7DD" stroke-width="2" fill="#FFF" x="13.036" y="4.02" width="9.025" height="20.099" rx="1.5"/><rect stroke="#FDE5D8" stroke-width="2" fill="#FFF" y="4.02" width="35.096" height="4.02" rx="2.01"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.512" y="18.089" width="26.072" height="17.084" rx="1.5"/></g><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(-45 43.117 35.117)" x="38.168" y="31.416" width="9.899" height="7.403" rx="3.702"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="25" ry="25"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="21" ry="21"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="43.05" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.305" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 74.422)" x="23.677" y="73.653" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 35.51)" x="23.844" y="34.742" width="2.616" height="1.538" rx=".769"/><path d="M13.362 42.502c-.124-.543.198-.854.74-.69l2.321.704c.533.161.643.592.235.972l-.22.206 7.06 7.572a1.002 1.002 0 1 1-1.467 1.368l-7.06-7.573-.118.11c-.402.375-.826.248-.952-.304l-.54-2.365zM21.606 67.576c-.408.38-.84.255-.968-.295l-.551-2.363c-.127-.542.191-.852.725-.69l.288.089 3.027-9.901a1.002 1.002 0 1 1 1.918.586l-3.027 9.901.154.047c.525.16.627.592.213.977l-1.779 1.65z" fill="#FC8A51"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25.099" cy="54.768" rx="2.507" ry="2.512"/></g></g><path d="M52.697 96.966a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zM86.29 96.966c0-.55.444-.996 1.002-.996.554 0 1.003.454 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044c0-.55.444-.996 1.002-.996.554 0 1.003.453 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038z" fill="#E5E5E5"/></g></svg>
\ No newline at end of file
import Vue from 'vue';
import IntervalPatternInput from './components/interval_pattern_input';
import TimezoneDropdown from './components/timezone_dropdown';
import TargetBranchDropdown from './components/target_branch_dropdown';
document.addEventListener('DOMContentLoaded', () => {
const IntervalPatternInputComponent = Vue.extend(IntervalPatternInput);
const intervalPatternMount = document.getElementById('interval-pattern-input');
const initialCronInterval = intervalPatternMount ? intervalPatternMount.dataset.initialInterval : '';
new IntervalPatternInputComponent({
propsData: {
initialCronInterval,
},
}).$mount(intervalPatternMount);
const formElement = document.getElementById('new-pipeline-schedule-form');
gl.timezoneDropdown = new TimezoneDropdown();
gl.targetBranchDropdown = new TargetBranchDropdown();
gl.pipelineScheduleFieldErrors = new gl.GlFieldErrors(formElement);
});
import Vue from 'vue';
import PipelineSchedulesCallout from './components/pipeline_schedules_callout';
const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout);
document.addEventListener('DOMContentLoaded', () => {
new PipelineSchedulesCalloutComponent()
.$mount('#scheduling-pipelines-callout');
});
.js-pipeline-schedule-form {
.dropdown-select,
.dropdown-menu-toggle {
width: 100%!important;
}
.gl-field-error {
margin: 10px 0 0;
}
}
.interval-pattern-form-group {
label {
margin-right: 10px;
font-size: 12px;
&[for='custom'] {
margin-right: 0;
}
}
.cron-interval-input-wrapper {
padding-left: 0;
}
.cron-interval-input {
margin: 10px 10px 0 0;
}
.cron-syntax-link-wrap {
margin-right: 10px;
font-size: 12px;
}
.cron-unset-status {
padding-top: 16px;
margin-left: -16px;
color: $gl-text-color-secondary;
font-size: 12px;
font-weight: 600;
}
}
.pipeline-schedule-table-row {
.branch-name-cell {
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.next-run-cell {
color: $gl-text-color-secondary;
}
a {
color: $text-color;
}
}
.pipeline-schedules-user-callout {
.bordered-box.content-block {
border: 1px solid $border-color;
background-color: transparent;
padding: 16px;
}
#dismiss-callout-btn {
color: $gl-text-color;
}
}
class Projects::PipelineSchedulesController < Projects::ApplicationController
before_action :authorize_read_pipeline_schedule!
before_action :authorize_create_pipeline_schedule!, only: [:new, :create, :edit, :take_ownership, :update]
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
@schedules = PipelineSchedulesFinder.new(@project).execute(scope: params[:scope])
.includes(:last_pipeline)
end
def new
@schedule = project.pipeline_schedules.new
end
def create
@schedule = Ci::CreatePipelineScheduleService
.new(@project, current_user, schedule_params)
.execute
if @schedule.persisted?
redirect_to pipeline_schedules_path(@project)
else
render :new
end
end
def edit
end
def update
if schedule.update(schedule_params)
redirect_to namespace_project_pipeline_schedules_path(@project.namespace.becomes(Namespace), @project)
else
render :edit
end
end
def take_ownership
if schedule.update(owner: current_user)
redirect_to pipeline_schedules_path(@project)
else
redirect_to pipeline_schedules_path(@project), alert: "Failed to change the owner"
end
end
def destroy
if schedule.destroy
redirect_to pipeline_schedules_path(@project)
else
redirect_to pipeline_schedules_path(@project), alert: "Failed to remove the pipeline schedule"
end
end
private
def schedule
@schedule ||= project.pipeline_schedules.find(params[:id])
end
def schedule_params
params.require(:schedule)
.permit(:description, :cron, :cron_timezone, :ref, :active)
end
end
class PipelineSchedulesFinder
attr_reader :project, :pipeline_schedules
def initialize(project)
@project = project
@pipeline_schedules = project.pipeline_schedules
end
def execute(scope: nil)
scoped_schedules =
case scope
when 'active'
pipeline_schedules.active
when 'inactive'
pipeline_schedules.inactive
else
pipeline_schedules
end
scoped_schedules.order(id: :desc)
end
end
......@@ -221,6 +221,26 @@ module GitlabRoutingHelper
end
end
# Pipeline Schedules
def pipeline_schedules_path(project, *args)
namespace_project_pipeline_schedules_path(project.namespace, project, *args)
end
def pipeline_schedule_path(schedule, *args)
project = schedule.project
namespace_project_pipeline_schedule_path(project.namespace, project, schedule, *args)
end
def edit_pipeline_schedule_path(schedule)
project = schedule.project
edit_namespace_project_pipeline_schedule_path(project.namespace, project, schedule)
end
def take_ownership_pipeline_schedule_path(schedule, *args)
project = schedule.project
take_ownership_namespace_project_pipeline_schedule_path(project.namespace, project, schedule, *args)
end
# Settings
def project_settings_integrations_path(project, *args)
namespace_project_settings_integrations_path(project.namespace, project, *args)
......
module PipelineSchedulesHelper
def timezone_data
ActiveSupport::TimeZone.all.map do |timezone|
{
name: timezone.name,
offset: timezone.utc_offset,
identifier: timezone.tzinfo.identifier
}
end
end
end
......@@ -9,6 +9,7 @@ module Ci
belongs_to :project
belongs_to :user
belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule'
has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id'
has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id'
......
module Ci
class TriggerSchedule < ActiveRecord::Base
class PipelineSchedule < ActiveRecord::Base
extend Ci::Model
include Importable
acts_as_paranoid
belongs_to :project
belongs_to :trigger
belongs_to :owner, class_name: 'User'
has_one :last_pipeline, -> { order(id: :desc) }, class_name: 'Ci::Pipeline'
has_many :pipelines
validates :trigger, presence: { unless: :importing? }
validates :cron, unless: :importing_or_inactive?, cron: true, presence: { unless: :importing_or_inactive? }
validates :cron_timezone, cron_timezone: true, presence: { unless: :importing_or_inactive? }
validates :ref, presence: { unless: :importing_or_inactive? }
validates :description, presence: true
before_save :set_next_run_at
scope :active, -> { where(active: true) }
scope :inactive, -> { where(active: false) }
def owned_by?(current_user)
owner == current_user
end
def inactive?
!active?
end
def importing_or_inactive?
importing? || !active?
importing? || inactive?
end
def set_next_run_at
......@@ -32,7 +43,7 @@ module Ci
end
def real_next_run(
worker_cron: Settings.cron_jobs['trigger_schedule_worker']['cron'],
worker_cron: Settings.cron_jobs['pipeline_schedule_worker']['cron'],
worker_time_zone: Time.zone.name)
Gitlab::Ci::CronParser.new(worker_cron, worker_time_zone)
.next_time_from(next_run_at)
......
......@@ -8,14 +8,11 @@ module Ci
belongs_to :owner, class_name: "User"
has_many :trigger_requests
has_one :trigger_schedule, dependent: :destroy
validates :token, presence: true, uniqueness: true
before_validation :set_default_values
accepts_nested_attributes_for :trigger_schedule
def set_default_values
self.token = SecureRandom.hex(15) if self.token.blank?
end
......@@ -39,9 +36,5 @@ module Ci
def can_access_project?
self.owner_id.blank? || Ability.allowed?(self.owner, :create_build, project)
end
def trigger_schedule
super || build_trigger_schedule(project: project)
end
end
end
......@@ -178,6 +178,7 @@ class Project < ActiveRecord::Base
has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger'
has_many :environments, dependent: :destroy
has_many :deployments, dependent: :destroy
has_many :pipeline_schedules, dependent: :destroy, class_name: 'Ci::PipelineSchedule'
has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
......
module Ci
class PipelineSchedulePolicy < PipelinePolicy
end
end
......@@ -46,6 +46,7 @@ class ProjectPolicy < BasePolicy
if project.public_builds?
can! :read_pipeline
can! :read_pipeline_schedule
can! :read_build
end
end
......@@ -63,6 +64,7 @@ class ProjectPolicy < BasePolicy
can! :read_build
can! :read_container_image
can! :read_pipeline
can! :read_pipeline_schedule
can! :read_environment
can! :read_deployment
can! :read_merge_request
......@@ -83,6 +85,8 @@ class ProjectPolicy < BasePolicy
can! :update_build
can! :create_pipeline
can! :update_pipeline
can! :create_pipeline_schedule
can! :update_pipeline_schedule
can! :create_merge_request
can! :create_wiki
can! :push_code
......@@ -108,6 +112,7 @@ class ProjectPolicy < BasePolicy
can! :admin_build
can! :admin_container_image
can! :admin_pipeline
can! :admin_pipeline_schedule
can! :admin_environment
can! :admin_deployment
can! :admin_pages
......@@ -120,6 +125,7 @@ class ProjectPolicy < BasePolicy
can! :fork_project
can! :read_commit_status
can! :read_pipeline
can! :read_pipeline_schedule
can! :read_container_image
can! :build_download_code
can! :build_read_container_image
......@@ -198,6 +204,7 @@ class ProjectPolicy < BasePolicy
unless project.feature_available?(:builds, user) && repository_enabled
cannot!(*named_abilities(:build))
cannot!(*named_abilities(:pipeline))
cannot!(*named_abilities(:pipeline_schedule))
cannot!(*named_abilities(:environment))
cannot!(*named_abilities(:deployment))
end
......@@ -277,6 +284,7 @@ class ProjectPolicy < BasePolicy
can! :read_merge_request
can! :read_note
can! :read_pipeline
can! :read_pipeline_schedule
can! :read_commit_status
can! :read_container_image
can! :download_code
......
module Ci
class CreatePipelineScheduleService < BaseService
def execute
project.pipeline_schedules.create(pipeline_schedule_params)
end
private
def pipeline_schedule_params
params.merge(owner: current_user)
end
end
end
......@@ -2,7 +2,7 @@ module Ci
class CreatePipelineService < BaseService
attr_reader :pipeline
def execute(ignore_skip_ci: false, save_on_errors: true, trigger_request: nil)
def execute(ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil)
@pipeline = Ci::Pipeline.new(
project: project,
ref: ref,
......@@ -10,7 +10,8 @@ module Ci
before_sha: before_sha,
tag: tag?,
trigger_requests: Array(trigger_request),
user: current_user
user: current_user,
pipeline_schedule: schedule
)
unless project.builds_enabled?
......
......@@ -5,9 +5,8 @@ module Ci
pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: ref).
execute(ignore_skip_ci: true, trigger_request: trigger_request)
if pipeline.persisted?
trigger_request
end
trigger_request if pipeline.persisted?
end
end
end
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('schedule_form')
= form_for [@project.namespace.becomes(Namespace), @project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "form-horizontal js-pipeline-schedule-form" } do |f|
= form_errors(@schedule)
.form-group
.col-md-6
= f.label :description, 'Description', class: 'label-light'
= f.text_field :description, class: 'form-control', required: true, autofocus: true, placeholder: 'Provide a short description for this pipeline'
.form-group
.col-md-12
= f.label :cron, 'Interval Pattern', class: 'label-light'
#interval-pattern-input{ data: { initial_interval: @schedule.cron } }
.form-group
.col-md-6
= f.label :cron_timezone, 'Cron Timezone', class: 'label-light'
= dropdown_tag("Select a timezone", options: { toggle_class: 'btn js-timezone-dropdown', title: "Select a timezone", filter: true, placeholder: "Filter", data: { data: timezone_data } } )
= f.text_field :cron_timezone, value: @schedule.cron_timezone, id: 'schedule_cron_timezone', class: 'hidden', name: 'schedule[cron_timezone]', required: true
.form-group
.col-md-6
= f.label :ref, 'Target Branch', class: 'label-light'
= dropdown_tag("Select target branch", options: { toggle_class: 'btn js-target-branch-dropdown', title: "Select target branch", filter: true, placeholder: "Filter", data: { data: @project.repository.branch_names } } )
= f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true
.form-group
.col-md-6
= f.label :active, 'Activated', class: 'label-light'
%div
= f.check_box :active, required: false, value: @schedule.active?
active
.footer-block.row-content-block
= f.submit 'Save pipeline schedule', class: 'btn btn-create', tabindex: 3
= link_to 'Cancel', pipeline_schedules_path(@project), class: 'btn btn-cancel'
- if pipeline_schedule
%tr.pipeline-schedule-table-row
%td
= pipeline_schedule.description
%td.branch-name-cell
= icon('code-fork')
= link_to pipeline_schedule.ref, namespace_project_commits_path(@project.namespace, @project, pipeline_schedule.ref), class: "branch-name"
%td
- if pipeline_schedule.last_pipeline
.status-icon-container{ class: "ci-status-icon-#{pipeline_schedule.last_pipeline.status}" }
= link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline_schedule.last_pipeline.id) do
= ci_icon_for_status(pipeline_schedule.last_pipeline.status)
- else
None
%td.next-run-cell
- if pipeline_schedule.active?
= time_ago_with_tooltip(pipeline_schedule.next_run_at)
- else
Inactive
%td
- if pipeline_schedule.owner
= image_tag avatar_icon(pipeline_schedule.owner, 20), class: "avatar s20"
= link_to user_path(pipeline_schedule.owner) do
= pipeline_schedule.owner&.name
%td
.pull-right.btn-group
- if can?(current_user, :update_pipeline_schedule, @project) && !pipeline_schedule.owned_by?(current_user)
= link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: 'Take Ownership', class: 'btn' do
Take ownership
- if can?(current_user, :update_pipeline_schedule, pipeline_schedule)
= link_to edit_pipeline_schedule_path(pipeline_schedule), title: 'Edit', class: 'btn' do
= icon('pencil')
- if can?(current_user, :admin_pipeline_schedule, pipeline_schedule)
= link_to pipeline_schedule_path(pipeline_schedule), title: 'Delete', method: :delete, class: 'btn btn-remove', data: { confirm: "Are you sure you want to cancel this pipeline?" } do
= icon('trash')
.table-holder
%table.table.ci-table
%thead
%tr
%th Description
%th Target
%th Last Pipeline
%th Next Run
%th Owner
%th
= render partial: "pipeline_schedule", collection: @schedules
%ul.nav-links
%li{ class: active_when(scope.nil?) }>
= link_to schedule_path_proc.call(nil) do
All
%span.badge.js-totalbuilds-count
= number_with_delimiter(all_schedules.count(:id))
%li{ class: active_when(scope == 'active') }>
= link_to schedule_path_proc.call('active') do
Active
%span.badge
= number_with_delimiter(all_schedules.active.count(:id))
%li{ class: active_when(scope == 'inactive') }>
= link_to schedule_path_proc.call('inactive') do
Inactive
%span.badge
= number_with_delimiter(all_schedules.inactive.count(:id))
- page_title "Edit", @schedule.description, "Pipeline Schedule"
%h3.page-title
Edit Pipeline Schedule #{@schedule.id}
%hr
= render "form"
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('schedules_index')
- @no_container = true
- page_title "Pipeline Schedules"
= render "projects/pipelines/head"
%div{ class: container_class }
#scheduling-pipelines-callout
.top-area
- schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) }
= render "tabs", schedule_path_proc: schedule_path_proc, all_schedules: @all_schedules, scope: @scope
.nav-controls
= link_to new_namespace_project_pipeline_schedule_path(@project.namespace, @project), class: 'btn btn-create' do
%span New Schedule
- if @schedules.present?
%ul.content-list
= render partial: "table"
- else
.light-well
.nothing-here-block No schedules
- page_title "New Pipeline Schedule"
%h3.page-title
Schedule a new pipeline
%hr
= render "form"
......@@ -15,6 +15,12 @@
%span
Jobs
- if project_nav_tab? :pipelines
= nav_link(controller: :pipeline_schedules) do
= link_to pipeline_schedules_path(@project), title: 'Schedules', class: 'shortcuts-builds' do
%span
Schedules
- if project_nav_tab? :environments
= nav_link(controller: :environments) do
= link_to project_environments_path(@project), title: 'Environments', class: 'shortcuts-environments' do
......
......@@ -8,26 +8,4 @@
.form-group
= f.label :key, "Description", class: "label-light"
= f.text_field :description, class: "form-control", required: true, title: 'Trigger description is required.', placeholder: "Trigger description"
- if @trigger.persisted?
%hr
= f.fields_for :trigger_schedule do |schedule_fields|
= schedule_fields.hidden_field :id
.form-group
.checkbox
= schedule_fields.label :active do
= schedule_fields.check_box :active
%strong Schedule trigger (experimental)
.help-block
If checked, this trigger will be executed periodically according to cron and timezone.
= link_to icon('question-circle'), help_page_path('ci/triggers/README', anchor: 'using-scheduled-triggers')
.form-group
= schedule_fields.label :cron, "Cron", class: "label-light"
= schedule_fields.text_field :cron, class: "form-control", title: 'Cron specification is required.', placeholder: "0 1 * * *"
.form-group
= schedule_fields.label :cron, "Timezone", class: "label-light"
= schedule_fields.text_field :cron_timezone, class: "form-control", title: 'Timezone is required.', placeholder: "UTC"
.form-group
= schedule_fields.label :ref, "Branch or tag", class: "label-light"
= schedule_fields.text_field :ref, class: "form-control", title: 'Branch or tag is required.', placeholder: "master"
.help-block Existing branch name, tag
= f.submit btn_text, class: "btn btn-save"
......@@ -22,8 +22,6 @@
%th
%strong Last used
%th
%strong Next run at
%th
= render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger
- else
%p.settings-message.text-center.append-bottom-default
......
......@@ -29,12 +29,6 @@
- else
Never
%td
- if trigger.trigger_schedule&.active?
= trigger.trigger_schedule.real_next_run
- else
Never
%td.text-right.trigger-actions
- take_ownership_confirmation = "By taking ownership you will bind this trigger to your user account. With this the trigger will have access to all your projects as if it was you. Are you sure?"
- revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?"
......
class PipelineScheduleWorker
include Sidekiq::Worker
include CronjobQueue
def perform
Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now).find_each do |schedule|
begin
Ci::CreatePipelineService.new(schedule.project,
schedule.owner,
ref: schedule.ref)
.execute(save_on_errors: false, schedule: schedule)
rescue => e
Rails.logger.error "#{schedule.id}: Failed to create a scheduled pipeline: #{e.message}"
ensure
schedule.schedule_next_run!
end
end
end
end
class TriggerScheduleWorker
include Sidekiq::Worker
include CronjobQueue
def perform
Ci::TriggerSchedule.active.where("next_run_at < ?", Time.now).find_each do |trigger_schedule|
begin
Ci::CreateTriggerRequestService.new.execute(trigger_schedule.project,
trigger_schedule.trigger,
trigger_schedule.ref)
rescue => e
Rails.logger.error "#{trigger_schedule.id}: Failed to trigger_schedule job: #{e.message}"
ensure
trigger_schedule.schedule_next_run!
end
end
end
end
---
title: Pipeline schedules got a new and improved UI
merge_request: 10853
author:
......@@ -181,7 +181,7 @@ production: &base
stuck_ci_jobs_worker:
cron: "0 * * * *"
# Execute scheduled triggers
trigger_schedule_worker:
pipeline_schedule_worker:
cron: "0 */12 * * *"
# Remove expired build artifacts
expire_build_artifacts_worker:
......
......@@ -323,9 +323,9 @@ Settings['cron_jobs'] ||= Settingslogic.new({})
Settings.cron_jobs['stuck_ci_jobs_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['stuck_ci_jobs_worker']['cron'] ||= '0 * * * *'
Settings.cron_jobs['stuck_ci_jobs_worker']['job_class'] = 'StuckCiJobsWorker'
Settings.cron_jobs['trigger_schedule_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['trigger_schedule_worker']['cron'] ||= '0 */12 * * *'
Settings.cron_jobs['trigger_schedule_worker']['job_class'] = 'TriggerScheduleWorker'
Settings.cron_jobs['pipeline_schedule_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['pipeline_schedule_worker']['cron'] ||= '0 */12 * * *'
Settings.cron_jobs['pipeline_schedule_worker']['job_class'] = 'PipelineScheduleWorker'
Settings.cron_jobs['expire_build_artifacts_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['expire_build_artifacts_worker']['cron'] ||= '50 * * * *'
Settings.cron_jobs['expire_build_artifacts_worker']['job_class'] = 'ExpireBuildArtifactsWorker'
......
......@@ -128,6 +128,12 @@ constraints(ProjectUrlConstrainer.new) do
end
end
resources :pipeline_schedules, except: [:show] do
member do
post :take_ownership
end
end
resources :environments, except: [:destroy] do
member do
post :stop
......
......@@ -54,6 +54,8 @@ var config = {
protected_branches: './protected_branches/protected_branches_bundle.js',
protected_tags: './protected_tags',
sidebar: './sidebar/sidebar_bundle.js',
schedule_form: './pipeline_schedules/pipeline_schedule_form_bundle.js',
schedules_index: './pipeline_schedules/pipeline_schedules_index_bundle.js',
snippet: './snippet/snippet_bundle.js',
sketch_viewer: './blob/sketch_viewer.js',
stl_viewer: './blob/stl_viewer.js',
......
class CreatePipelineSchedulesTable < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
create_table :ci_pipeline_schedules do |t|
t.string :description
t.string :ref
t.string :cron
t.string :cron_timezone
t.datetime :next_run_at
t.integer :project_id
t.integer :owner_id
t.boolean :active, default: true
t.datetime :deleted_at
t.timestamps
end
add_index(:ci_pipeline_schedules, :project_id)
add_index(:ci_pipeline_schedules, [:next_run_at, :active])
end
def down
drop_table :ci_pipeline_schedules
end
end
class RemoveForeighKeyCiTriggerSchedules < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
remove_foreign_key :ci_trigger_schedules, column: :trigger_id
end
def down
# no op, the foreign key should not have been here
end
end
class AddPipelineScheduleIdToPipelines < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
add_column :ci_pipelines, :pipeline_schedule_id, :integer
end
end
class AddIndexToPipelinePipelineScheduleId < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
unless index_exists?(:ci_pipelines, :pipeline_schedule_id)
add_concurrent_index(:ci_pipelines, :pipeline_schedule_id)
end
end
def down
if index_exists?(:ci_pipelines, :pipeline_schedule_id)
remove_concurrent_index(:ci_pipelines, :pipeline_schedule_id)
end
end
end
class AddForeignKeyToPipelineSchedules < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_foreign_key :ci_pipeline_schedules, :projects, column: :project_id
end
def down
remove_foreign_key :ci_pipeline_schedules, :projects
end
end
class AddForeignKeyPipelineSchedulesAndPipelines < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
on_delete =
if Gitlab::Database.mysql?
:nullify
else
'SET NULL'
end
add_concurrent_foreign_key :ci_pipelines, :ci_pipeline_schedules,
column: :pipeline_schedule_id, on_delete: on_delete
end
def down
remove_foreign_key :ci_pipelines, column: :pipeline_schedule_id
end
end
class MigrateTriggerSchedulesToPipelineSchedules < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
connection.execute <<-SQL
INSERT INTO ci_pipeline_schedules (
project_id,
created_at,
updated_at,
deleted_at,
cron,
cron_timezone,
next_run_at,
ref,
active,
owner_id,
description
)
SELECT
ci_trigger_schedules.project_id,
ci_trigger_schedules.created_at,
ci_trigger_schedules.updated_at,
ci_trigger_schedules.deleted_at,
ci_trigger_schedules.cron,
ci_trigger_schedules.cron_timezone,
ci_trigger_schedules.next_run_at,
ci_trigger_schedules.ref,
ci_trigger_schedules.active,
ci_triggers.owner_id,
ci_triggers.description
FROM ci_trigger_schedules
INNER JOIN ci_triggers ON ci_trigger_schedules.trigger_id=ci_triggers.id;
SQL
end
def down
# no op as the data has been removed
end
end
class DropCiTriggerSchedulesTable < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
drop_table :ci_trigger_schedules
end
def down
create_table "ci_trigger_schedules", force: :cascade do |t|
t.integer "project_id"
t.integer "trigger_id", null: false
t.datetime "deleted_at"
t.datetime "created_at"
t.datetime "updated_at"
t.string "cron"
t.string "cron_timezone"
t.datetime "next_run_at"
t.string "ref"
t.boolean "active"
end
add_index "ci_trigger_schedules", %w(active next_run_at), name: "index_ci_trigger_schedules_on_active_and_next_run_at", using: :btree
add_index "ci_trigger_schedules", ["project_id"], name: "index_ci_trigger_schedules_on_project_id", using: :btree
add_index "ci_trigger_schedules", ["next_run_at"], name: "index_ci_trigger_schedules_on_next_run_at"
add_concurrent_foreign_key "ci_trigger_schedules", "ci_triggers", column: :trigger_id, on_delete: :cascade
end
end
......@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170504102911) do
ActiveRecord::Schema.define(version: 20170506185517) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
......@@ -95,6 +95,7 @@ ActiveRecord::Schema.define(version: 20170504102911) do
t.string "enabled_git_access_protocol"
t.boolean "domain_blacklist_enabled", default: false
t.text "domain_blacklist"
t.boolean "usage_ping_enabled", default: true, null: false
t.boolean "koding_enabled"
t.string "koding_url"
t.text "sign_in_text_html"
......@@ -113,14 +114,13 @@ ActiveRecord::Schema.define(version: 20170504102911) do
t.string "plantuml_url"
t.boolean "plantuml_enabled"
t.integer "terminal_max_session_time", default: 0, null: false
t.string "default_artifacts_expire_in", default: "0", null: false
t.integer "unique_ips_limit_per_user"
t.integer "unique_ips_limit_time_window"
t.boolean "unique_ips_limit_enabled", default: false, null: false
t.string "default_artifacts_expire_in", default: "0", null: false
t.string "uuid"
t.decimal "polling_interval_multiplier", default: 1.0, null: false
t.integer "cached_markdown_version"
t.boolean "usage_ping_enabled", default: true, null: false
t.string "uuid"
t.boolean "clientside_sentry_enabled", default: false, null: false
t.string "clientside_sentry_dsn"
end
......@@ -246,6 +246,23 @@ ActiveRecord::Schema.define(version: 20170504102911) 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_schedules", force: :cascade do |t|
t.string "description"
t.string "ref"
t.string "cron"
t.string "cron_timezone"
t.datetime "next_run_at"
t.integer "project_id"
t.integer "owner_id"
t.boolean "active", default: true
t.datetime "deleted_at"
t.datetime "created_at"
t.datetime "updated_at"
end
add_index "ci_pipeline_schedules", ["next_run_at", "active"], name: "index_ci_pipeline_schedules_on_next_run_at_and_active", using: :btree
add_index "ci_pipeline_schedules", ["project_id"], name: "index_ci_pipeline_schedules_on_project_id", using: :btree
create_table "ci_pipelines", force: :cascade do |t|
t.string "ref"
t.string "sha"
......@@ -263,8 +280,10 @@ ActiveRecord::Schema.define(version: 20170504102911) do
t.integer "user_id"
t.integer "lock_version"
t.integer "auto_canceled_by_id"
t.integer "pipeline_schedule_id"
end
add_index "ci_pipelines", ["pipeline_schedule_id"], name: "index_ci_pipelines_on_pipeline_schedule_id", using: :btree
add_index "ci_pipelines", ["project_id", "ref", "status"], name: "index_ci_pipelines_on_project_id_and_ref_and_status", using: :btree
add_index "ci_pipelines", ["project_id", "sha"], name: "index_ci_pipelines_on_project_id_and_sha", using: :btree
add_index "ci_pipelines", ["project_id"], name: "index_ci_pipelines_on_project_id", using: :btree
......@@ -313,23 +332,6 @@ ActiveRecord::Schema.define(version: 20170504102911) do
add_index "ci_trigger_requests", ["commit_id"], name: "index_ci_trigger_requests_on_commit_id", using: :btree
create_table "ci_trigger_schedules", force: :cascade do |t|
t.integer "project_id"
t.integer "trigger_id", null: false
t.datetime "deleted_at"
t.datetime "created_at"
t.datetime "updated_at"
t.string "cron"
t.string "cron_timezone"
t.datetime "next_run_at"
t.string "ref"
t.boolean "active"
end
add_index "ci_trigger_schedules", ["active", "next_run_at"], name: "index_ci_trigger_schedules_on_active_and_next_run_at", using: :btree
add_index "ci_trigger_schedules", ["next_run_at"], name: "index_ci_trigger_schedules_on_next_run_at", using: :btree
add_index "ci_trigger_schedules", ["project_id"], name: "index_ci_trigger_schedules_on_project_id", using: :btree
create_table "ci_triggers", force: :cascade do |t|
t.string "token"
t.datetime "deleted_at"
......@@ -981,8 +983,8 @@ ActiveRecord::Schema.define(version: 20170504102911) do
t.boolean "lfs_enabled"
t.text "description_html"
t.boolean "only_allow_merge_if_all_discussions_are_resolved"
t.integer "auto_cancel_pending_pipelines", default: 0, null: false
t.boolean "printing_merge_request_link_enabled", default: true, null: false
t.integer "auto_cancel_pending_pipelines", default: 0, null: false
t.string "import_jid"
t.integer "cached_markdown_version"
t.datetime "last_repository_updated_at"
......@@ -1349,11 +1351,11 @@ ActiveRecord::Schema.define(version: 20170504102911) do
t.string "incoming_email_token"
t.string "organization"
t.boolean "authorized_projects_populated"
t.boolean "require_two_factor_authentication_from_group", default: false, null: false
t.integer "two_factor_grace_period", default: 48, null: false
t.boolean "ghost"
t.date "last_activity_on"
t.boolean "notified_of_own_activity"
t.boolean "require_two_factor_authentication_from_group", default: false, null: false
t.integer "two_factor_grace_period", default: 48, null: false
t.string "preferred_language"
end
......@@ -1408,13 +1410,14 @@ ActiveRecord::Schema.define(version: 20170504102911) do
add_foreign_key "boards", "projects"
add_foreign_key "chat_teams", "namespaces", on_delete: :cascade
add_foreign_key "ci_builds", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_a2141b1522", on_delete: :nullify
add_foreign_key "ci_pipeline_schedules", "projects", name: "fk_8ead60fcc4", on_delete: :cascade
add_foreign_key "ci_pipelines", "ci_pipeline_schedules", column: "pipeline_schedule_id", name: "fk_3d34ab2e06", on_delete: :nullify
add_foreign_key "ci_pipelines", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_262d4c2d19", on_delete: :nullify
add_foreign_key "ci_trigger_requests", "ci_triggers", column: "trigger_id", name: "fk_b8ec8b7245", on_delete: :cascade
add_foreign_key "ci_trigger_schedules", "ci_triggers", column: "trigger_id", name: "fk_90a406cc94", on_delete: :cascade
add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade
add_foreign_key "container_repositories", "projects"
add_foreign_key "issue_assignees", "issues", on_delete: :cascade
add_foreign_key "issue_assignees", "users", on_delete: :cascade
add_foreign_key "container_repositories", "projects"
add_foreign_key "issue_metrics", "issues", on_delete: :cascade
add_foreign_key "label_priorities", "labels", on_delete: :cascade
add_foreign_key "label_priorities", "projects", on_delete: :cascade
......
# Pipeline Schedules
> **Note**:
- This feature was introduced in 9.1 as [Trigger Schedule][ce-105533]
- In 9.2, the feature was [renamed to Pipeline Schedule][ce-10853]
Pipeline schedules can be used to run pipelines only once, or for example every
month on the 22nd for a certain branch.
## Using Pipeline Schedules
In order to schedule pipelines, navigate to your their pages **Pipelines ➔ Schedules**
and click the **New Schedule** button.
![New Schedule Form](img/pipeline_schedules_new_form.png)
After entering the form, hit **Save Schedule** for the changes to have effect.
You can check a next execution date of the scheduled trigger, which is automatically calculated by a server.
## Taking ownership
![Schedules list](img/pipeline_schedules_list.png)
Pipelines are executed as a user, which owns a schedule. This influences what
projects and other resources the pipeline has access to. If a user does not own
a pipeline, you can take ownership by clicking the **Take ownership** button.
The next time a pipeline is scheduled, your credentials will be used.
> **Notes**:
- Those pipelines won't be executed precicely. Because schedules are handled by
Sidekiq, which runs according to its interval. For exmaple, if you set a schedule to
create a pipeline every minute (`* * * * *`) and the Sidekiq worker performs 00:00
and 12:00 o'clock every day (`0 */12 * * *`), only 2 pipelines will be created per day.
To change the Sidekiq worker's frequency, you have to edit the `trigger_schedule_worker_cron`
value in your `gitlab.rb` and restart GitLab. The Sidekiq worker's configuration
on GiLab.com is able to be looked up at [here](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/gitlab.yml.example#L185).
- Cron notation is parsed by [Rufus-Scheduler](https://github.com/jmettraux/rufus-scheduler).
[ce-10533]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10533
[ce-10853]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10853
......@@ -216,42 +216,4 @@ You can add the following webhook to another project in order to trigger a job:
https://gitlab.example.com/api/v4/projects/9/ref/master/trigger/pipeline?token=TOKEN&variables[UPLOAD_TO_S3]=true
```
### Using cron to trigger nightly jobs
Whether you craft a script or just run cURL directly, you can trigger jobs
in conjunction with cron. The example below triggers a job on the `master`
branch of project with ID `9` every night at `00:30`:
```bash
30 0 * * * curl --request POST --form token=TOKEN --form ref=master https://gitlab.example.com/api/v4/projects/9/trigger/pipeline
```
[ci-229]: https://gitlab.com/gitlab-org/gitlab-ci/merge_requests/229
## Using scheduled triggers
> [Introduced][ci-10533] in GitLab CE 9.1 as experimental.
In order to schedule a trigger, navigate to your project's **Settings ➔ CI/CD Pipelines ➔ Triggers** and edit an existing trigger token.
![Triggers Schedule edit](img/trigger_schedule_edit.png)
To set up a scheduled trigger:
1. Check the **Schedule trigger (experimental)** checkbox
1. Enter a cron value for the frequency of the trigger ([learn more about cron notation](http://www.nncron.ru/help/EN/working/cron-format.htm))
1. Enter the timezone of the cron trigger ([see a list of timezones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones))
1. Enter the branch or tag that the trigger will target
1. Hit **Save trigger** for the changes to take effect
![Triggers Schedule create](img/trigger_schedule_create.png)
You can check a next execution date of the scheduled trigger, which is automatically calculated by a server.
![Triggers Schedule create](img/trigger_schedule_updated_next_run_at.png)
> **Notes**:
- Those triggers won't be executed precicely. Because scheduled triggers are handled by Sidekiq, which runs according to its interval. For exmaple, if you set a trigger to be executed every minute (`* * * * *`) and the Sidekiq worker performs 00:00 and 12:00 o'clock every day (`0 */12 * * *`), then your trigger will be executed only 00:00 and 12:00 o'clock every day. To change the Sidekiq worker's frequency, you have to edit the `trigger_schedule_worker` value in `config/gitlab.yml` and restart GitLab. The Sidekiq worker's configuration on GiLab.com is able to be looked up at [here](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/gitlab.yml.example#L185).
- Cron notation is parsed by [Rufus-Scheduler](https://github.com/jmettraux/rufus-scheduler).
[ci-10533]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10533
......@@ -27,7 +27,8 @@ with all their related data and be moved into a new GitLab instance.
| GitLab version | Import/Export version |
| -------- | -------- |
| 8.17.0 to current | 0.1.6 |
| 9.2.0 to current | 0.1.7 |
| 8.17.0 | 0.1.6 |
| 8.13.0 | 0.1.5 |
| 8.12.0 | 0.1.4 |
| 8.10.3 | 0.1.3 |
......
......@@ -3,7 +3,7 @@ module Gitlab
extend self
# For every version update, the version history in import_export.md has to be kept up to date.
VERSION = '0.1.6'.freeze
VERSION = '0.1.7'.freeze
FILENAME_LIMIT = 50
def export_path(relative_path:)
......
......@@ -39,8 +39,8 @@ project_tree:
- :author
- :events
- :statuses
- triggers:
- :trigger_schedule
- :triggers
- :pipeline_schedules
- :services
- :hooks
- protected_branches:
......@@ -116,4 +116,4 @@ methods:
merge_requests:
- :diff_head_sha
project:
- :description_html
\ No newline at end of file
- :description_html
......@@ -5,7 +5,7 @@ module Gitlab
pipelines: 'Ci::Pipeline',
statuses: 'commit_status',
triggers: 'Ci::Trigger',
trigger_schedule: 'Ci::TriggerSchedule',
pipeline_schedules: 'Ci::PipelineSchedule',
builds: 'Ci::Build',
hooks: 'ProjectHook',
merge_access_levels: 'ProtectedBranch::MergeAccessLevel',
......
......@@ -23,6 +23,7 @@ module Gitlab
ci_pipelines: ::Ci::Pipeline.count,
ci_runners: ::Ci::Runner.count,
ci_triggers: ::Ci::Trigger.count,
ci_pipeline_schedules: ::Ci::PipelineSchedule.count,
deploy_keys: DeployKey.count,
deployments: Deployment.count,
environments: Environment.count,
......
require 'spec_helper'
describe Projects::PipelineSchedulesController do
set(:project) { create(:empty_project, :public) }
let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) }
describe 'GET #index' do
let(:scope) { nil }
let!(:inactive_pipeline_schedule) do
create(:ci_pipeline_schedule, :inactive, project: project)
end
it 'renders the index view' do
visit_pipelines_schedules
expect(response).to have_http_status(:ok)
expect(response).to render_template(:index)
end
context 'when the scope is set to active' do
let(:scope) { 'active' }
before do
visit_pipelines_schedules
end
it 'only shows active pipeline schedules' do
expect(response).to have_http_status(:ok)
expect(assigns(:schedules)).to include(pipeline_schedule)
expect(assigns(:schedules)).not_to include(inactive_pipeline_schedule)
end
end
def visit_pipelines_schedules
get :index, namespace_id: project.namespace.to_param, project_id: project, scope: scope
end
end
describe 'GET edit' 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 'DELETE #destroy' do
set(:user) { create(:user) }
context 'when a developer makes the request' do
before do
project.add_developer(user)
sign_in(user)
delete :destroy, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id
end
it 'does not delete the pipeline schedule' do
expect(response).not_to have_http_status(:ok)
end
end
context 'when a master makes the request' do
before do
project.add_master(user)
sign_in(user)
end
it 'destroys the pipeline schedule' do
expect do
delete :destroy, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id
end.to change { project.pipeline_schedules.count }.by(-1)
expect(response).to have_http_status(302)
end
end
end
end
FactoryGirl.define do
factory :ci_trigger_schedule, class: Ci::TriggerSchedule do
trigger factory: :ci_trigger_for_trigger_schedule
factory :ci_pipeline_schedule, class: Ci::PipelineSchedule do
cron '0 1 * * *'
cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE
ref 'master'
active true
after(:build) do |trigger_schedule, evaluator|
trigger_schedule.project ||= trigger_schedule.trigger.project
end
description "pipeline schedule"
project factory: :empty_project
trait :nightly do
cron '0 1 * * *'
......@@ -24,5 +21,9 @@ FactoryGirl.define do
cron '0 1 22 * *'
cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE
end
trait :inactive do
active false
end
end
end
require 'spec_helper'
feature 'Pipeline Schedules', :feature do
include PipelineSchedulesHelper
include WaitForAjax
let!(:project) { create(:project) }
let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) }
let(:scope) { nil }
let!(:user) { create(:user) }
before do
project.add_master(user)
login_as(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)
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_link('master')
expect(page).to have_content('None')
end
end
it 'creates a new scheduled pipeline' do
click_link 'New Schedule'
expect(page).to have_content('Schedule a new pipeline')
end
it 'changes ownership of the pipeline' do
click_link 'Take ownership'
page.within('.pipeline-schedule-table-row') do
expect(page).not_to have_content('No owner')
expect(page).to have_link('John Doe')
end
end
it 'edits the pipeline' do
page.within('.pipeline-schedule-table-row') do
click_link 'Edit'
end
expect(page).to have_content('Edit Pipeline Schedule')
end
it 'deletes the pipeline' do
click_link 'Delete'
expect(page).not_to have_content('pipeline schedule')
end
end
end
describe 'POST /projects/pipeline_schedules/new', js: true do
let(:visit_page) { visit_new_pipeline_schedule }
it 'it creates a new scheduled pipeline' do
fill_in_schedule_form
save_pipeline_schedule
expect(page).to have_content('my fancy description')
end
it 'it prevents an invalid form from being submitted' do
save_pipeline_schedule
expect(page).to have_content('This field is required')
end
end
describe 'PATCH /projects/pipelines_schedules/:id/edit', js: true do
let(:visit_page) do
edit_pipeline_schedule
end
it 'it displays existing properties' do
description = find_field('schedule_description').value
expect(description).to eq('pipeline schedule')
expect(page).to have_button('master')
expect(page).to have_button('UTC')
end
it 'edits the scheduled pipeline' do
fill_in 'schedule_description', with: 'my brand new description'
save_pipeline_schedule
expect(page).to have_content('my brand new description')
end
end
def visit_new_pipeline_schedule
visit new_namespace_project_pipeline_schedule_path(project.namespace, project, pipeline_schedule)
end
def edit_pipeline_schedule
visit edit_namespace_project_pipeline_schedule_path(project.namespace, project, pipeline_schedule)
end
def visit_pipelines_schedules
visit namespace_project_pipeline_schedules_path(project.namespace, project, scope: scope)
end
def select_timezone
click_button 'Select a timezone'
click_link 'American Samoa'
end
def select_target_branch
click_button 'Select target branch'
click_link 'master'
end
def save_pipeline_schedule
click_button 'Save pipeline schedule'
end
def fill_in_schedule_form
fill_in 'schedule_description', with: 'my fancy description'
fill_in 'schedule_cron', with: '* 1 2 3 4'
select_timezone
select_target_branch
end
end
......@@ -3,7 +3,7 @@ require 'spec_helper'
describe "Internal Project Access", feature: true do
include AccessMatchers
let(:project) { create(:project, :internal) }
set(:project) { create(:project, :internal) }
describe "Project should be internal" do
describe '#internal?' do
......@@ -437,6 +437,20 @@ describe "Internal Project Access", feature: true do
end
end
describe "GET /:project_path/pipeline_schedules" do
subject { namespace_project_pipeline_schedules_path(project.namespace, project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
it { is_expected.to be_allowed_for(:master).of(project) }
it { is_expected.to be_allowed_for(:developer).of(project) }
it { is_expected.to be_allowed_for(:reporter).of(project) }
it { is_expected.to be_allowed_for(:guest).of(project) }
it { is_expected.to be_allowed_for(:user) }
it { is_expected.to be_denied_for(:external) }
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/environments" do
subject { namespace_project_environments_path(project.namespace, project) }
......
......@@ -3,7 +3,7 @@ require 'spec_helper'
describe "Private Project Access", feature: true do
include AccessMatchers
let(:project) { create(:project, :private, public_builds: false) }
set(:project) { create(:project, :private, public_builds: false) }
describe "Project should be private" do
describe '#private?' do
......@@ -478,6 +478,48 @@ describe "Private Project Access", feature: true do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/pipeline_schedules" do
subject { namespace_project_pipeline_schedules_path(project.namespace, project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
it { is_expected.to be_allowed_for(:master).of(project) }
it { is_expected.to be_allowed_for(:developer).of(project) }
it { is_expected.to be_allowed_for(:reporter).of(project) }
it { is_expected.to be_denied_for(:guest).of(project) }
it { is_expected.to be_denied_for(:user) }
it { is_expected.to be_denied_for(:external) }
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/pipeline_schedules/new" do
subject { new_namespace_project_pipeline_schedule_path(project.namespace, project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
it { is_expected.to be_allowed_for(:master).of(project) }
it { is_expected.to be_allowed_for(:developer).of(project) }
it { is_expected.to be_denied_for(:reporter).of(project) }
it { is_expected.to be_denied_for(:guest).of(project) }
it { is_expected.to be_denied_for(:user) }
it { is_expected.to be_denied_for(:external) }
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/environments/new" do
subject { new_namespace_project_pipeline_schedule_path(project.namespace, project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
it { is_expected.to be_allowed_for(:master).of(project) }
it { is_expected.to be_allowed_for(:developer).of(project) }
it { is_expected.to be_denied_for(:reporter).of(project) }
it { is_expected.to be_denied_for(:guest).of(project) }
it { is_expected.to be_denied_for(:user) }
it { is_expected.to be_denied_for(:external) }
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/container_registry" do
let(:container_repository) { create(:container_repository) }
......
......@@ -3,7 +3,7 @@ require 'spec_helper'
describe "Public Project Access", feature: true do
include AccessMatchers
let(:project) { create(:project, :public) }
set(:project) { create(:project, :public) }
describe "Project should be public" do
describe '#public?' do
......@@ -257,6 +257,20 @@ describe "Public Project Access", feature: true do
end
end
describe "GET /:project_path/pipeline_schedules" do
subject { namespace_project_pipeline_schedules_path(project.namespace, project) }
it { is_expected.to be_allowed_for(:admin) }
it { is_expected.to be_allowed_for(:owner).of(project) }
it { is_expected.to be_allowed_for(:master).of(project) }
it { is_expected.to be_allowed_for(:developer).of(project) }
it { is_expected.to be_allowed_for(:reporter).of(project) }
it { is_expected.to be_allowed_for(:guest).of(project) }
it { is_expected.to be_allowed_for(:user) }
it { is_expected.to be_allowed_for(:external) }
it { is_expected.to be_allowed_for(:visitor) }
end
describe "GET /:project_path/environments" do
subject { namespace_project_environments_path(project.namespace, project) }
......
......@@ -77,77 +77,6 @@ feature 'Triggers', feature: true, js: true do
expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.'
expect(page.find('.triggers-list')).to have_content new_trigger_title
end
context 'scheduled triggers' do
let!(:trigger) do
create(:ci_trigger, owner: user, project: @project, description: trigger_title)
end
context 'enabling schedule' do
before do
visit edit_namespace_project_trigger_path(@project.namespace, @project, trigger)
end
scenario 'do fill form with valid data and save' do
find('#trigger_trigger_schedule_attributes_active').click
fill_in 'trigger_trigger_schedule_attributes_cron', with: '1 * * * *'
fill_in 'trigger_trigger_schedule_attributes_cron_timezone', with: 'UTC'
fill_in 'trigger_trigger_schedule_attributes_ref', with: 'master'
click_button 'Save trigger'
expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.'
end
scenario 'do not fill form with valid data and save' do
find('#trigger_trigger_schedule_attributes_active').click
click_button 'Save trigger'
expect(page).to have_content 'The form contains the following errors'
end
context 'when GitLab time_zone is ActiveSupport::TimeZone format' do
before do
allow(Time).to receive(:zone)
.and_return(ActiveSupport::TimeZone['Eastern Time (US & Canada)'])
end
scenario 'do fill form with valid data and save' do
find('#trigger_trigger_schedule_attributes_active').click
fill_in 'trigger_trigger_schedule_attributes_cron', with: '1 * * * *'
fill_in 'trigger_trigger_schedule_attributes_cron_timezone', with: 'UTC'
fill_in 'trigger_trigger_schedule_attributes_ref', with: 'master'
click_button 'Save trigger'
expect(page.find('.flash-notice'))
.to have_content 'Trigger was successfully updated.'
end
end
end
context 'disabling schedule' do
before do
trigger.create_trigger_schedule(
project: trigger.project,
active: true,
ref: 'master',
cron: '1 * * * *',
cron_timezone: 'UTC')
visit edit_namespace_project_trigger_path(@project.namespace, @project, trigger)
end
scenario 'disable and save form' do
find('#trigger_trigger_schedule_attributes_active').click
click_button 'Save trigger'
expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.'
visit edit_namespace_project_trigger_path(@project.namespace, @project, trigger)
checkbox = find_field('trigger_trigger_schedule_attributes_active')
expect(checkbox).not_to be_checked
end
end
end
end
describe 'trigger "Take ownership" workflow' do
......
require 'spec_helper'
describe PipelineSchedulesFinder do
let(:project) { create(:empty_project) }
let!(:active_schedule) { create(:ci_pipeline_schedule, project: project) }
let!(:inactive_schedule) { create(:ci_pipeline_schedule, :inactive, project: project) }
subject { described_class.new(project).execute(params) }
describe "#execute" do
context 'when the scope is nil' do
let(:params) { { scope: nil } }
it 'selects all pipeline pipeline schedules' do
expect(subject.count).to be(2)
expect(subject).to include(active_schedule, inactive_schedule)
end
end
context 'when the scope is active' do
let(:params) { { scope: 'active' } }
it 'selects only active pipelines' do
expect(subject.count).to be(1)
expect(subject).to include(active_schedule)
expect(subject).not_to include(inactive_schedule)
end
end
context 'when the scope is inactve' do
let(:params) { { scope: 'inactive' } }
it 'selects only inactive pipelines' do
expect(subject.count).to be(1)
expect(subject).not_to include(active_schedule)
expect(subject).to include(inactive_schedule)
end
end
end
end
import Vue from 'vue';
import IntervalPatternInput from '~/pipeline_schedules/components/interval_pattern_input';
const IntervalPatternInputComponent = Vue.extend(IntervalPatternInput);
const inputNameAttribute = 'schedule[cron]';
const cronIntervalPresets = {
everyDay: '0 4 * * *',
everyWeek: '0 4 * * 0',
everyMonth: '0 4 1 * *',
};
window.gl = window.gl || {};
window.gl.pipelineScheduleFieldErrors = {
updateFormValidityState: () => {},
};
describe('Interval Pattern Input Component', function () {
describe('when prop initialCronInterval is passed (edit)', function () {
describe('when prop initialCronInterval is custom', function () {
beforeEach(function () {
this.initialCronInterval = '1 2 3 4 5';
this.intervalPatternComponent = new IntervalPatternInputComponent({
propsData: {
initialCronInterval: this.initialCronInterval,
},
}).$mount();
});
it('is initialized as a Vue component', function () {
expect(this.intervalPatternComponent).toBeDefined();
});
it('prop initialCronInterval is set', function () {
expect(this.intervalPatternComponent.initialCronInterval).toBe(this.initialCronInterval);
});
it('sets showUnsetWarning to false', function (done) {
Vue.nextTick(() => {
expect(this.intervalPatternComponent.showUnsetWarning).toBe(false);
done();
});
});
it('does not render showUnsetWarning', function (done) {
Vue.nextTick(() => {
expect(this.intervalPatternComponent.$el.outerHTML).not.toContain('Schedule not yet set');
done();
});
});
it('sets isEditable to true', function (done) {
Vue.nextTick(() => {
expect(this.intervalPatternComponent.isEditable).toBe(true);
done();
});
});
});
describe('when prop initialCronInterval is preset', function () {
beforeEach(function () {
this.intervalPatternComponent = new IntervalPatternInputComponent({
propsData: {
inputNameAttribute,
initialCronInterval: '0 4 * * *',
},
}).$mount();
});
it('is initialized as a Vue component', function () {
expect(this.intervalPatternComponent).toBeDefined();
});
it('sets showUnsetWarning to false', function (done) {
Vue.nextTick(() => {
expect(this.intervalPatternComponent.showUnsetWarning).toBe(false);
done();
});
});
it('does not render showUnsetWarning', function (done) {
Vue.nextTick(() => {
expect(this.intervalPatternComponent.$el.outerHTML).not.toContain('Schedule not yet set');
done();
});
});
it('sets isEditable to false', function (done) {
Vue.nextTick(() => {
expect(this.intervalPatternComponent.isEditable).toBe(false);
done();
});
});
});
});
describe('when prop initialCronInterval is not passed (new)', function () {
beforeEach(function () {
this.intervalPatternComponent = new IntervalPatternInputComponent({
propsData: {
inputNameAttribute,
},
}).$mount();
});
it('is initialized as a Vue component', function () {
expect(this.intervalPatternComponent).toBeDefined();
});
it('prop initialCronInterval is set', function () {
const defaultInitialCronInterval = '';
expect(this.intervalPatternComponent.initialCronInterval).toBe(defaultInitialCronInterval);
});
it('sets showUnsetWarning to true', function (done) {
Vue.nextTick(() => {
expect(this.intervalPatternComponent.showUnsetWarning).toBe(true);
done();
});
});
it('renders showUnsetWarning to true', function (done) {
Vue.nextTick(() => {
expect(this.intervalPatternComponent.$el.outerHTML).toContain('Schedule not yet set');
done();
});
});
it('sets isEditable to true', function (done) {
Vue.nextTick(() => {
expect(this.intervalPatternComponent.isEditable).toBe(true);
done();
});
});
});
describe('User Actions', function () {
beforeEach(function () {
// For an unknown reason, Phantom.js doesn't trigger click events
// on radio buttons in a way Vue can register. So, we have to mount
// to a fixture.
setFixtures('<div id="my-mount"></div>');
this.initialCronInterval = '1 2 3 4 5';
this.intervalPatternComponent = new IntervalPatternInputComponent({
propsData: {
initialCronInterval: this.initialCronInterval,
},
}).$mount('#my-mount');
});
it('cronInterval is updated when everyday preset interval is selected', function (done) {
this.intervalPatternComponent.$el.querySelector('#every-day').click();
Vue.nextTick(() => {
expect(this.intervalPatternComponent.cronInterval).toBe(cronIntervalPresets.everyDay);
expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').value).toBe(cronIntervalPresets.everyDay);
done();
});
});
it('cronInterval is updated when everyweek preset interval is selected', function (done) {
this.intervalPatternComponent.$el.querySelector('#every-week').click();
Vue.nextTick(() => {
expect(this.intervalPatternComponent.cronInterval).toBe(cronIntervalPresets.everyWeek);
expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').value).toBe(cronIntervalPresets.everyWeek);
done();
});
});
it('cronInterval is updated when everymonth preset interval is selected', function (done) {
this.intervalPatternComponent.$el.querySelector('#every-month').click();
Vue.nextTick(() => {
expect(this.intervalPatternComponent.cronInterval).toBe(cronIntervalPresets.everyMonth);
expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').value).toBe(cronIntervalPresets.everyMonth);
done();
});
});
it('only a space is added to cronInterval (trimmed later) when custom radio is selected', function (done) {
this.intervalPatternComponent.$el.querySelector('#every-month').click();
this.intervalPatternComponent.$el.querySelector('#custom').click();
Vue.nextTick(() => {
const intervalWithSpaceAppended = `${cronIntervalPresets.everyMonth} `;
expect(this.intervalPatternComponent.cronInterval).toBe(intervalWithSpaceAppended);
expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').value).toBe(intervalWithSpaceAppended);
done();
});
});
it('text input is disabled when preset interval is selected', function (done) {
this.intervalPatternComponent.$el.querySelector('#every-month').click();
Vue.nextTick(() => {
expect(this.intervalPatternComponent.isEditable).toBe(false);
expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').disabled).toBe(true);
done();
});
});
it('text input is enabled when custom is selected', function (done) {
this.intervalPatternComponent.$el.querySelector('#every-month').click();
this.intervalPatternComponent.$el.querySelector('#custom').click();
Vue.nextTick(() => {
expect(this.intervalPatternComponent.isEditable).toBe(true);
expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').disabled).toBe(false);
done();
});
});
});
});
import Vue from 'vue';
import Cookies from 'js-cookie';
import PipelineSchedulesCallout from '~/pipeline_schedules/components/pipeline_schedules_callout';
const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout);
const cookieKey = 'pipeline_schedules_callout_dismissed';
describe('Pipeline Schedule Callout', () => {
describe('independent of cookies', () => {
beforeEach(() => {
this.calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
});
it('the component can be initialized', () => {
expect(this.calloutComponent).toBeDefined();
});
it('correctly sets illustrationSvg', () => {
expect(this.calloutComponent.illustrationSvg).toContain('<svg');
});
});
describe(`when ${cookieKey} cookie is set`, () => {
beforeEach(() => {
Cookies.set(cookieKey, true);
this.calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
});
it('correctly sets calloutDismissed to true', () => {
expect(this.calloutComponent.calloutDismissed).toBe(true);
});
it('does not render the callout', () => {
expect(this.calloutComponent.$el.childNodes.length).toBe(0);
});
});
describe('when cookie is not set', () => {
beforeEach(() => {
Cookies.remove(cookieKey);
this.calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
});
it('correctly sets calloutDismissed to false', () => {
expect(this.calloutComponent.calloutDismissed).toBe(false);
});
it('renders the callout container', () => {
expect(this.calloutComponent.$el.querySelector('.bordered-box')).not.toBeNull();
});
it('renders the callout svg', () => {
expect(this.calloutComponent.$el.outerHTML).toContain('<svg');
});
it('renders the callout title', () => {
expect(this.calloutComponent.$el.outerHTML).toContain('Scheduling Pipelines');
});
it('renders the callout text', () => {
expect(this.calloutComponent.$el.outerHTML).toContain('runs pipelines in the future');
});
it('updates calloutDismissed when close button is clicked', (done) => {
this.calloutComponent.$el.querySelector('#dismiss-callout-btn').click();
Vue.nextTick(() => {
expect(this.calloutComponent.calloutDismissed).toBe(true);
done();
});
});
it('#dismissCallout updates calloutDismissed', (done) => {
this.calloutComponent.dismissCallout();
Vue.nextTick(() => {
expect(this.calloutComponent.calloutDismissed).toBe(true);
done();
});
});
it('is hidden when close button is clicked', (done) => {
this.calloutComponent.$el.querySelector('#dismiss-callout-btn').click();
Vue.nextTick(() => {
expect(this.calloutComponent.$el.childNodes.length).toBe(0);
done();
});
});
});
});
......@@ -101,6 +101,7 @@ pipelines:
- cancelable_statuses
- manual_actions
- artifacts
- pipeline_schedule
statuses:
- project
- pipeline
......@@ -112,9 +113,13 @@ triggers:
- project
- trigger_requests
- owner
- trigger_schedule
trigger_schedule:
- trigger
pipeline_schedules:
- project
- owner
- pipelines
- last_pipeline
pipeline_schedule:
- pipelines
deploy_keys:
- user
- deploy_keys_projects
......@@ -221,7 +226,7 @@ project:
- active_runners
- variables
- triggers
- trigger_schedules
- pipeline_schedules
- environments
- deployments
- project_feature
......
......@@ -188,6 +188,7 @@ Ci::Pipeline:
- user_id
- lock_version
- auto_canceled_by_id
- pipeline_schedule_id
CommitStatus:
- id
- project_id
......@@ -247,18 +248,19 @@ Ci::Trigger:
- owner_id
- description
- ref
Ci::TriggerSchedule:
Ci::PipelineSchedule:
- id
- project_id
- trigger_id
- deleted_at
- created_at
- updated_at
- description
- ref
- cron
- cron_timezone
- next_run_at
- ref
- project_id
- owner_id
- active
- deleted_at
- created_at
- updated_at
DeployKey:
- id
- user_id
......
......@@ -32,6 +32,7 @@ describe Gitlab::UsageData do
ci_pipelines
ci_runners
ci_triggers
ci_pipeline_schedules
deploy_keys
deployments
environments
......
require 'spec_helper'
describe Ci::TriggerSchedule, models: true do
describe Ci::PipelineSchedule, models: true do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:trigger) }
it { is_expected.to belong_to(:owner) }
it { is_expected.to have_many(:pipelines) }
it { is_expected.to respond_to(:ref) }
it { is_expected.to respond_to(:cron) }
it { is_expected.to respond_to(:cron_timezone) }
it { is_expected.to respond_to(:description) }
it { is_expected.to respond_to(:next_run_at) }
it { is_expected.to respond_to(:deleted_at) }
describe 'validations' do
it 'does not allow invalid cron patters' do
pipeline_schedule = build(:ci_pipeline_schedule, cron: '0 0 0 * *')
expect(pipeline_schedule).not_to be_valid
end
it 'does not allow invalid cron patters' do
pipeline_schedule = build(:ci_pipeline_schedule, cron_timezone: 'invalid')
expect(pipeline_schedule).not_to be_valid
end
end
describe '#set_next_run_at' do
context 'when creates new TriggerSchedule' do
before do
trigger_schedule = create(:ci_trigger_schedule, :nightly)
@expected_next_run_at = Gitlab::Ci::CronParser.new(trigger_schedule.cron, trigger_schedule.cron_timezone)
.next_time_from(Time.now)
let!(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly) }
context 'when creates new pipeline schedule' do
let(:expected_next_run_at) do
Gitlab::Ci::CronParser.new(pipeline_schedule.cron, pipeline_schedule.cron_timezone).
next_time_from(Time.now)
end
it 'updates next_run_at automatically' do
expect(Ci::TriggerSchedule.last.next_run_at).to eq(@expected_next_run_at)
expect(Ci::PipelineSchedule.last.next_run_at).to eq(expected_next_run_at)
end
end
context 'when updates cron of exsisted TriggerSchedule' do
before do
trigger_schedule = create(:ci_trigger_schedule, :nightly)
new_cron = '0 0 1 1 *'
trigger_schedule.update!(cron: new_cron) # Subject
@expected_next_run_at = Gitlab::Ci::CronParser.new(new_cron, trigger_schedule.cron_timezone)
.next_time_from(Time.now)
context 'when updates cron of exsisted pipeline schedule' do
let(:new_cron) { '0 0 1 1 *' }
let(:expected_next_run_at) do
Gitlab::Ci::CronParser.new(new_cron, pipeline_schedule.cron_timezone).
next_time_from(Time.now)
end
it 'updates next_run_at automatically' do
expect(Ci::TriggerSchedule.last.next_run_at).to eq(@expected_next_run_at)
pipeline_schedule.update!(cron: new_cron)
expect(Ci::PipelineSchedule.last.next_run_at).to eq(expected_next_run_at)
end
end
end
describe '#schedule_next_run!' do
context 'when reschedules after 10 days from now' do
before do
trigger_schedule = create(:ci_trigger_schedule, :nightly)
time_future = Time.now + 10.days
allow(Time).to receive(:now).and_return(time_future)
trigger_schedule.schedule_next_run! # Subject
@expected_next_run_at = Gitlab::Ci::CronParser.new(trigger_schedule.cron, trigger_schedule.cron_timezone)
.next_time_from(time_future)
end
let!(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly) }
it 'points to proper next_run_at' do
expect(Ci::TriggerSchedule.last.next_run_at).to eq(@expected_next_run_at)
end
end
context 'when cron is invalid' do
before do
trigger_schedule = create(:ci_trigger_schedule, :nightly)
trigger_schedule.cron = 'Invalid-cron'
trigger_schedule.schedule_next_run! # Subject
end
context 'when reschedules after 10 days from now' do
let(:future_time) { 10.days.from_now }
it 'sets nil to next_run_at' do
expect(Ci::TriggerSchedule.last.next_run_at).to be_nil
let(:expected_next_run_at) do
Gitlab::Ci::CronParser.new(pipeline_schedule.cron, pipeline_schedule.cron_timezone).
next_time_from(future_time)
end
end
context 'when cron_timezone is invalid' do
before do
trigger_schedule = create(:ci_trigger_schedule, :nightly)
trigger_schedule.cron_timezone = 'Invalid-cron_timezone'
trigger_schedule.schedule_next_run! # Subject
end
it 'points to proper next_run_at' do
Timecop.freeze(future_time) do
pipeline_schedule.schedule_next_run!
it 'sets nil to next_run_at' do
expect(Ci::TriggerSchedule.last.next_run_at).to be_nil
expect(pipeline_schedule.next_run_at).to eq(expected_next_run_at)
end
end
end
end
describe '#real_next_run' do
subject do
Ci::TriggerSchedule.last.real_next_run(worker_cron: worker_cron,
worker_time_zone: worker_time_zone)
described_class.last.real_next_run(worker_cron: worker_cron,
worker_time_zone: worker_time_zone)
end
context 'when GitLab time_zone is UTC' do
......@@ -90,7 +94,7 @@ describe Ci::TriggerSchedule, models: true do
context 'when cron_timezone is Eastern Time (US & Canada)' do
before do
create(:ci_trigger_schedule, :nightly,
create(:ci_pipeline_schedule, :nightly,
cron_timezone: 'Eastern Time (US & Canada)')
end
......
......@@ -13,6 +13,7 @@ describe Ci::Pipeline, models: true do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:auto_canceled_by) }
it { is_expected.to belong_to(:pipeline_schedule) }
it { is_expected.to have_many(:statuses) }
it { is_expected.to have_many(:trigger_requests) }
......
......@@ -7,7 +7,6 @@ describe Ci::Trigger, models: true do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:owner) }
it { is_expected.to have_many(:trigger_requests) }
it { is_expected.to have_one(:trigger_schedule) }
end
describe 'before_validation' do
......
......@@ -73,6 +73,7 @@ describe Project, models: true do
it { is_expected.to have_many(:notification_settings).dependent(:destroy) }
it { is_expected.to have_many(:forks).through(:forked_project_links) }
it { is_expected.to have_many(:uploads).dependent(:destroy) }
it { is_expected.to have_many(:pipeline_schedules).dependent(:destroy) }
context 'after initialized' do
it "has a project_feature" do
......
require 'spec_helper'
describe PipelineScheduleWorker do
subject { described_class.new.perform }
set(:project) { create(:project, :repository) }
set(:user) { create(:user) }
let!(:pipeline_schedule) do
create(:ci_pipeline_schedule, :nightly, project: project, owner: user)
end
before do
project.add_master(user)
stub_ci_pipeline_to_return_yaml_file
end
context 'when there is a scheduled pipeline within next_run_at' do
let(:next_run_at) { 2.days.ago }
before do
pipeline_schedule.update_column(:next_run_at, next_run_at)
end
it 'creates a new pipeline' do
expect { subject }.to change { project.pipelines.count }.by(1)
end
it 'updates the next_run_at field' do
subject
expect(pipeline_schedule.reload.next_run_at).to be > Time.now
end
it 'sets the schedule on the pipeline' do
subject
expect(project.pipelines.last.pipeline_schedule).to eq(pipeline_schedule)
end
end
context 'inactive schedule' do
before do
pipeline_schedule.update(active: false)
end
it 'does not creates a new pipeline' do
expect { subject }.not_to change { project.pipelines.count }
end
end
end
require 'spec_helper'
describe TriggerScheduleWorker do
let(:worker) { described_class.new }
before do
stub_ci_pipeline_to_return_yaml_file
end
context 'when there is a scheduled trigger within next_run_at' do
let(:next_run_at) { 2.days.ago }
let!(:trigger_schedule) do
create(:ci_trigger_schedule, :nightly)
end
before do
trigger_schedule.update_column(:next_run_at, next_run_at)
end
it 'creates a new trigger request' do
expect { worker.perform }.to change { Ci::TriggerRequest.count }
end
it 'creates a new pipeline' do
expect { worker.perform }.to change { Ci::Pipeline.count }
expect(Ci::Pipeline.last).to be_pending
end
it 'updates next_run_at' do
worker.perform
expect(trigger_schedule.reload.next_run_at).not_to eq(next_run_at)
end
context 'inactive schedule' do
before do
trigger_schedule.update(active: false)
end
it 'does not create a new trigger' do
expect { worker.perform }.not_to change { Ci::TriggerRequest.count }
end
end
end
context 'when there are no scheduled triggers within next_run_at' do
before { create(:ci_trigger_schedule, :nightly) }
it 'does not create a new pipeline' do
expect { worker.perform }.not_to change { Ci::Pipeline.count }
end
it 'does not update next_run_at' do
expect { worker.perform }.not_to change { Ci::TriggerSchedule.last.next_run_at }
end
end
context 'when next_run_at is nil' do
before do
schedule = create(:ci_trigger_schedule, :nightly)
schedule.update_column(:next_run_at, nil)
end
it 'does not create a new pipeline' do
expect { worker.perform }.not_to change { Ci::Pipeline.count }
end
it 'does not update next_run_at' do
expect { worker.perform }.not_to change { Ci::TriggerSchedule.last.next_run_at }
end
end
end
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册