diff options
author | Zeger-Jan van de Weg <zegerjan@gitlab.com> | 2017-05-07 22:35:56 +0000 |
---|---|---|
committer | Kamil Trzciński <ayufan@ayufan.eu> | 2017-05-07 22:35:56 +0000 |
commit | 8df3997a92bffa2d29f3c559933a336b837cdb93 (patch) | |
tree | 5ee50876b35b6c5fd40607665f72468cfcee51fe /app | |
parent | 8a0cde81feb3c8f3af26eefa5cef7b72eda2d266 (diff) | |
download | gitlab-ce-8df3997a92bffa2d29f3c559933a336b837cdb93.tar.gz |
Add Pipeline Schedules that supersedes experimental Trigger Schedule
Diffstat (limited to 'app')
36 files changed, 728 insertions, 66 deletions
diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js index 76de249ac3b..0add7075254 100644 --- a/app/assets/javascripts/gl_field_error.js +++ b/app/assets/javascripts/gl_field_error.js @@ -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)); diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js index 636258ec555..ca3cec07a88 100644 --- a/app/assets/javascripts/gl_field_errors.js +++ b/app/assets/javascripts/gl_field_errors.js @@ -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(); diff --git a/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js b/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js new file mode 100644 index 00000000000..152e75b747e --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js @@ -0,0 +1,143 @@ +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> + `, +}; diff --git a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js new file mode 100644 index 00000000000..27ffe6ea304 --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js @@ -0,0 +1,44 @@ +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> + `, +}; + diff --git a/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js b/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js new file mode 100644 index 00000000000..22e746ad2c3 --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js @@ -0,0 +1,42 @@ +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(); + } +} diff --git a/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js b/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js new file mode 100644 index 00000000000..c70e0502cf8 --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js @@ -0,0 +1,56 @@ +/* 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(); + } +} diff --git a/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg b/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg new file mode 100644 index 00000000000..26d1ff97b3e --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg @@ -0,0 +1 @@ +<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 diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js new file mode 100644 index 00000000000..c60e77decce --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js @@ -0,0 +1,21 @@ +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); +}); diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js new file mode 100644 index 00000000000..e36dc5db2ab --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js @@ -0,0 +1,9 @@ +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'); +}); diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/pages/pipeline_schedules.scss new file mode 100644 index 00000000000..0fee54a0d19 --- /dev/null +++ b/app/assets/stylesheets/pages/pipeline_schedules.scss @@ -0,0 +1,71 @@ +.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; + } +} diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb new file mode 100644 index 00000000000..1616b2cb6b8 --- /dev/null +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -0,0 +1,68 @@ +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 diff --git a/app/finders/pipeline_schedules_finder.rb b/app/finders/pipeline_schedules_finder.rb new file mode 100644 index 00000000000..2ac4289fbbe --- /dev/null +++ b/app/finders/pipeline_schedules_finder.rb @@ -0,0 +1,22 @@ +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 diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 3769830de2a..bcf71bc347b 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -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) diff --git a/app/helpers/pipeline_schedules_helper.rb b/app/helpers/pipeline_schedules_helper.rb new file mode 100644 index 00000000000..fee1edc2a1b --- /dev/null +++ b/app/helpers/pipeline_schedules_helper.rb @@ -0,0 +1,11 @@ +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 diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 4be4aa9ffe2..db994b861e5 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -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' diff --git a/app/models/ci/trigger_schedule.rb b/app/models/ci/pipeline_schedule.rb index 012a18eb439..6d7cc83971e 100644 --- a/app/models/ci/trigger_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -1,24 +1,35 @@ 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) diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 2f64f70685a..6df41a3f301 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -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 diff --git a/app/models/project.rb b/app/models/project.rb index edbca3b537b..a0413b4e651 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -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' diff --git a/app/policies/ci/pipeline_schedule_policy.rb b/app/policies/ci/pipeline_schedule_policy.rb new file mode 100644 index 00000000000..1877e89bb23 --- /dev/null +++ b/app/policies/ci/pipeline_schedule_policy.rb @@ -0,0 +1,4 @@ +module Ci + class PipelineSchedulePolicy < PipelinePolicy + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 5baac9ebe4b..8f25ac30a22 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -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 diff --git a/app/services/ci/create_pipeline_schedule_service.rb b/app/services/ci/create_pipeline_schedule_service.rb new file mode 100644 index 00000000000..cd40deb6187 --- /dev/null +++ b/app/services/ci/create_pipeline_schedule_service.rb @@ -0,0 +1,13 @@ +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 diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 21350be5557..ccdda08d885 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -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? diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb index dca5aa9f5d7..8362f01ddb8 100644 --- a/app/services/ci/create_trigger_request_service.rb +++ b/app/services/ci/create_trigger_request_service.rb @@ -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 diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml new file mode 100644 index 00000000000..4a21cce024e --- /dev/null +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -0,0 +1,32 @@ +- 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' diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml new file mode 100644 index 00000000000..1406868488f --- /dev/null +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -0,0 +1,35 @@ +- 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') diff --git a/app/views/projects/pipeline_schedules/_table.html.haml b/app/views/projects/pipeline_schedules/_table.html.haml new file mode 100644 index 00000000000..25c7604eb24 --- /dev/null +++ b/app/views/projects/pipeline_schedules/_table.html.haml @@ -0,0 +1,12 @@ +.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 diff --git a/app/views/projects/pipeline_schedules/_tabs.html.haml b/app/views/projects/pipeline_schedules/_tabs.html.haml new file mode 100644 index 00000000000..2a1fb16876a --- /dev/null +++ b/app/views/projects/pipeline_schedules/_tabs.html.haml @@ -0,0 +1,18 @@ +%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)) diff --git a/app/views/projects/pipeline_schedules/edit.html.haml b/app/views/projects/pipeline_schedules/edit.html.haml new file mode 100644 index 00000000000..e16fe0b7a98 --- /dev/null +++ b/app/views/projects/pipeline_schedules/edit.html.haml @@ -0,0 +1,7 @@ +- page_title "Edit", @schedule.description, "Pipeline Schedule" + +%h3.page-title + Edit Pipeline Schedule #{@schedule.id} +%hr + += render "form" diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml new file mode 100644 index 00000000000..dd35c3055f2 --- /dev/null +++ b/app/views/projects/pipeline_schedules/index.html.haml @@ -0,0 +1,24 @@ +- 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 + diff --git a/app/views/projects/pipeline_schedules/new.html.haml b/app/views/projects/pipeline_schedules/new.html.haml new file mode 100644 index 00000000000..b89e170ad3c --- /dev/null +++ b/app/views/projects/pipeline_schedules/new.html.haml @@ -0,0 +1,7 @@ +- page_title "New Pipeline Schedule" + +%h3.page-title + Schedule a new pipeline +%hr + += render "form" diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml index b0dac9de1c6..db9d77dba16 100644 --- a/app/views/projects/pipelines/_head.html.haml +++ b/app/views/projects/pipelines/_head.html.haml @@ -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 diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml index 70d654fa9a0..5f708b3a2ed 100644 --- a/app/views/projects/triggers/_form.html.haml +++ b/app/views/projects/triggers/_form.html.haml @@ -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" diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml index 84e945ee0df..cc74e50a5e3 100644 --- a/app/views/projects/triggers/_index.html.haml +++ b/app/views/projects/triggers/_index.html.haml @@ -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 diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml index ebd91a8e2af..9b5f63ae81a 100644 --- a/app/views/projects/triggers/_trigger.html.haml +++ b/app/views/projects/triggers/_trigger.html.haml @@ -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?" diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb new file mode 100644 index 00000000000..a449a765f7b --- /dev/null +++ b/app/workers/pipeline_schedule_worker.rb @@ -0,0 +1,19 @@ +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 diff --git a/app/workers/trigger_schedule_worker.rb b/app/workers/trigger_schedule_worker.rb deleted file mode 100644 index 9c1baf7e6c5..00000000000 --- a/app/workers/trigger_schedule_worker.rb +++ /dev/null @@ -1,18 +0,0 @@ -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 |