Skip to content
Snippets Groups Projects
Commit 8df3997a authored by Zeger-Jan van de Weg's avatar Zeger-Jan van de Weg Committed by Kamil Trzciński
Browse files

Add Pipeline Schedules that supersedes experimental Trigger Schedule

parent 8a0cde81
No related branches found
No related tags found
No related merge requests found
Showing
with 550 additions and 13 deletions
Loading
Loading
@@ -65,6 +65,7 @@ class GlFieldError {
this.state = {
valid: false,
empty: true,
submitted: false,
};
 
this.initFieldValidation();
Loading
Loading
@@ -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));
Loading
Loading
Loading
Loading
@@ -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();
Loading
Loading
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
Loading
Loading
@@ -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)
Loading
Loading
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
Loading
Loading
@@ -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'
Loading
Loading
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
Loading
Loading
@@ -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)
Loading
Loading
Loading
Loading
@@ -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
Loading
Loading
@@ -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
Loading
Loading
@@ -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'
 
Loading
Loading
module Ci
class PipelineSchedulePolicy < PipelinePolicy
end
end
Loading
Loading
@@ -46,6 +46,7 @@ class ProjectPolicy < BasePolicy
 
if project.public_builds?
can! :read_pipeline
can! :read_pipeline_schedule
can! :read_build
end
end
Loading
Loading
@@ -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
Loading
Loading
@@ -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
Loading
Loading
@@ -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
Loading
Loading
@@ -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
Loading
Loading
@@ -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
Loading
Loading
@@ -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
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment