diff --git a/app/assets/javascripts/shipit/continuous_delivery_schedule.js.coffee b/app/assets/javascripts/shipit/continuous_delivery_schedule.js.coffee
new file mode 100644
index 000000000..b8750ffb0
--- /dev/null
+++ b/app/assets/javascripts/shipit/continuous_delivery_schedule.js.coffee
@@ -0,0 +1,15 @@
+$(document)
+ .on "click", ".continuous-delivery-schedule [data-action='copy-to-all']", (event) ->
+ form = event.target.closest("form");
+
+ mondayStart = form.elements.namedItem("continuous_delivery_schedule[monday_start]").value
+ mondayEnd = form.elements.namedItem("continuous_delivery_schedule[monday_end]").value
+
+ Array.from(form.elements).forEach (formElement) ->
+ return unless formElement.type == "time"
+
+ if formElement.name.endsWith("_start]")
+ formElement.value = mondayStart
+
+ if formElement.name.endsWith("_end]")
+ formElement.value = mondayEnd
diff --git a/app/controllers/shipit/continuous_delivery_schedules_controller.rb b/app/controllers/shipit/continuous_delivery_schedules_controller.rb
new file mode 100644
index 000000000..4efa02521
--- /dev/null
+++ b/app/controllers/shipit/continuous_delivery_schedules_controller.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Shipit
+ class ContinuousDeliverySchedulesController < ShipitController
+ before_action :load_stack
+
+ def show
+ @continuous_delivery_schedule = @stack.continuous_delivery_schedule || @stack.build_continuous_delivery_schedule
+ end
+
+ def update
+ @continuous_delivery_schedule = @stack.continuous_delivery_schedule || @stack.build_continuous_delivery_schedule
+ @continuous_delivery_schedule.assign_attributes(continuous_delivery_schedule_params)
+
+ if @continuous_delivery_schedule.save
+ flash[:success] = "Successfully updated"
+ redirect_to(stack_continuous_delivery_schedule_path)
+ else
+ flash.now[:warning] = "Check form for errors"
+ render(:show, status: :unprocessable_entity)
+ end
+ end
+
+ private
+
+ def load_stack
+ @stack = Stack.from_param!(params[:id])
+ end
+
+ def continuous_delivery_schedule_params
+ params.require(:continuous_delivery_schedule).permit(
+ *Shipit::ContinuousDeliverySchedule::DAYS.flat_map do |day|
+ [
+ "#{day}_start",
+ "#{day}_end",
+ "#{day}_enabled",
+ ]
+ end
+ )
+ end
+ end
+end
diff --git a/app/jobs/shipit/continuous_delivery_job.rb b/app/jobs/shipit/continuous_delivery_job.rb
index 46a292f01..e9f2af982 100644
--- a/app/jobs/shipit/continuous_delivery_job.rb
+++ b/app/jobs/shipit/continuous_delivery_job.rb
@@ -9,6 +9,12 @@ class ContinuousDeliveryJob < BackgroundJob
def perform(stack)
return unless stack.continuous_deployment?
+ # If there is a schedule defined for this stack, make sure we are within a
+ # deployment window before proceeding.
+ if stack.continuous_delivery_schedule
+ return unless stack.continuous_delivery_schedule.can_deploy?
+ end
+
# checks if there are any tasks running, including concurrent tasks
return if stack.occupied?
diff --git a/app/models/shipit/continuous_delivery_schedule.rb b/app/models/shipit/continuous_delivery_schedule.rb
new file mode 100644
index 000000000..52e700ea9
--- /dev/null
+++ b/app/models/shipit/continuous_delivery_schedule.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+module Shipit
+ class ContinuousDeliverySchedule < Record
+ belongs_to(:stack)
+
+ DAYS = %w[sunday monday tuesday wednesday thursday friday saturday].freeze
+
+ validates(
+ *DAYS.map { |day| "#{day}_enabled" },
+ inclusion: [true, false],
+ )
+
+ validates(
+ *DAYS.product([:start, :end]).map { |parts| parts.join("_") },
+ presence: true
+ )
+
+ validate(:validate_time_windows)
+
+ DeploymentWindow = Struct.new(:starts_at, :ends_at, :enabled) do
+ alias_method :enabled?, :enabled
+ end
+
+ def can_deploy?(now = Time.current)
+ # Make sure time is in the default time zone so weekdays match what is
+ # stored in the database.
+ now = now.in_time_zone(Time.zone)
+
+ deployment_window = get_deployment_window(now.to_date)
+
+ deployment_window.enabled? &&
+ now >= deployment_window.starts_at &&
+ now <= deployment_window.ends_at
+ end
+
+ def get_deployment_window(date)
+ wday_name = DAYS.fetch(date.wday)
+
+ enabled = read_attribute("#{wday_name}_enabled")
+
+ starts_at, ends_at = [:start, :end].map do |bound|
+ raw_time = read_attribute("#{wday_name}_#{bound}")
+
+ # `ActiveRecord::Type::Time` attributes are stored as timestamps
+ # normalized to 2000-01-01 so they can't be used for comparisons without
+ # having their dates adjusted.
+ # https://github.com/rails/rails/blob/ec667e5f114df58087493096253541f1034815af/activemodel/lib/active_model/type/time.rb#L23
+ Time.zone.local(
+ date.year,
+ date.month,
+ date.day,
+ raw_time.hour,
+ raw_time.min,
+ )
+ end
+
+ DeploymentWindow.new(
+ starts_at,
+ # Includes the full minute in the configured range. This is required so
+ # that a window configured to end at 17:59 actually ends at 17:59:59
+ # instead of 17:59:00.
+ ends_at.at_end_of_minute,
+ enabled,
+ )
+ end
+
+ private
+
+ # Make sure every `*_end` attribute comes after its matching `*_start`
+ # attribute
+ def validate_time_windows
+ DAYS.each do |day|
+ day_start, day_end = [:start, :end].map { |bound| read_attribute("#{day}_#{bound}") }
+
+ next unless day_start && day_end
+
+ next if day_start <= day_end
+
+ errors.add("#{day}_end", :must_be_after_start, start: day_start.strftime("%I:%M %p"))
+ end
+ end
+ end
+end
diff --git a/app/models/shipit/stack.rb b/app/models/shipit/stack.rb
index 23767f254..636ff18dc 100644
--- a/app/models/shipit/stack.rb
+++ b/app/models/shipit/stack.rb
@@ -38,6 +38,7 @@ def blank?
has_many :github_hooks, dependent: :destroy, class_name: 'Shipit::GithubHook::Repo'
has_many :hooks, dependent: :destroy
has_many :api_clients, dependent: :destroy
+ has_one :continuous_delivery_schedule
belongs_to :lock_author, class_name: :User, optional: true
belongs_to :repository
validates_associated :repository
diff --git a/app/views/shipit/continuous_delivery_schedules/show.html.erb b/app/views/shipit/continuous_delivery_schedules/show.html.erb
new file mode 100644
index 000000000..0e81894cd
--- /dev/null
+++ b/app/views/shipit/continuous_delivery_schedules/show.html.erb
@@ -0,0 +1,59 @@
+<%= render partial: 'shipit/stacks/header', locals: { stack: @stack } %>
+
+
+
+
+ <% if @continuous_delivery_schedule.errors.any? %>
+
+
Validation errors prevented your schedule from being saved
+
+ <% @continuous_delivery_schedule.errors.full_messages.each do |full_message| %>
+ - <%= full_message %>
+ <% end %>
+
+
+ <% end %>
+ <%= form_for(@continuous_delivery_schedule, url: stack_continuous_delivery_schedule_path, method: :patch) do |f| %>
+
+
+ <% Shipit::ContinuousDeliverySchedule::DAYS.rotate.each do |day| %>
+
+
+ <%= f.check_box("#{day}_enabled") %>
+ |
+
+ <%= f.label("#{day}_enabled", day.titlecase) %>
+ |
+
+ <%= f.time_field("#{day}_start", include_seconds: false) %>
+ |
+ → |
+
+ <%= f.time_field("#{day}_end", include_seconds: false) %>
+ |
+
+ <% if day == "monday" %>
+
+ <% end %>
+ |
+
+ <% end %>
+
+
+
+
+ ℹ️
+ All times are in <%= Time.zone.name %>
+ (the default time zone).
+
+
+
+ <%= f.submit("Save", class: "btn") %>
+
+ <% end %>
+
+
diff --git a/app/views/shipit/stacks/_settings_form.erb b/app/views/shipit/stacks/_settings_form.erb
index 7b0f6db35..c5ee0d8b9 100644
--- a/app/views/shipit/stacks/_settings_form.erb
+++ b/app/views/shipit/stacks/_settings_form.erb
@@ -17,6 +17,7 @@
<%= f.check_box :continuous_deployment %>
<%= f.label :continuous_deployment, 'Enable continuous deployment' %>
+ (<%= link_to("Edit schedule", stack_continuous_delivery_schedule_path) %>)
diff --git a/config/locales/en.yml b/config/locales/en.yml
index ed1cf47c7..f2c57c38f 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -81,6 +81,7 @@ en:
messages:
subset: "is not a strict subset of %{of}"
ascii: "contains non-ASCII characters"
+ must_be_after_start: "must be after start (%{start})"
deployment_description:
deploy:
in_progress: "%{author} triggered the deploy of %{stack} to %{sha}"
diff --git a/config/routes.rb b/config/routes.rb
index 9da476b8a..7d5bce9a1 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -84,6 +84,8 @@
post :refresh, controller: :stacks
get :refresh, controller: :stacks # For easier design, sorry :/
post :clear_git_cache, controller: :stacks
+
+ resource :continuous_delivery_schedule, only: %i(show update)
end
scope '/task/:id', controller: :tasks do
diff --git a/db/migrate/20240821003007_add_continuous_delivery_schedules.rb b/db/migrate/20240821003007_add_continuous_delivery_schedules.rb
new file mode 100644
index 000000000..b93a11e0d
--- /dev/null
+++ b/db/migrate/20240821003007_add_continuous_delivery_schedules.rb
@@ -0,0 +1,13 @@
+class AddContinuousDeliverySchedules < ActiveRecord::Migration[7.1]
+ def change
+ create_table(:continuous_delivery_schedules) do |t|
+ t.references(:stack, null: false, index: { unique: true })
+ %w[sunday monday tuesday wednesday thursday friday saturday].each do |day|
+ t.boolean("#{day}_enabled", null: false, default: true)
+ t.time("#{day}_start", null: false, default: "00:00")
+ t.time("#{day}_end", null: false, default: "23:59")
+ end
+ t.timestamps
+ end
+ end
+end
diff --git a/test/controllers/continuous_delivery_schedules_controller_test.rb b/test/controllers/continuous_delivery_schedules_controller_test.rb
new file mode 100644
index 000000000..af36fe1b6
--- /dev/null
+++ b/test/controllers/continuous_delivery_schedules_controller_test.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+require 'test_helper'
+
+module Shipit
+ class ContinuousDeliverySchedulesControllerTest < ActionController::TestCase
+ setup do
+ @routes = Shipit::Engine.routes
+ @stack = shipit_stacks(:shipit)
+ session[:user_id] = shipit_users(:walrus).id
+ end
+
+ def valid_params
+ Shipit::ContinuousDeliverySchedule::DAYS.each_with_object({}) do |day, hash|
+ hash[:"#{day}_enabled"] = "0"
+ hash[:"#{day}_start"] = "09:00"
+ hash[:"#{day}_end"] = "17:00"
+ end
+ end
+
+ test "#show returns a 200 response" do
+ get(:show, params: { id: @stack.to_param })
+
+ assert(response.ok?)
+ end
+
+ test "#update" do
+ patch(:update, params: {
+ id: @stack.to_param,
+ continuous_delivery_schedule: {
+ **valid_params,
+ },
+ })
+
+ assert_redirected_to(stack_continuous_delivery_schedule_path(@stack))
+ assert_equal("Successfully updated", flash[:success])
+
+ schedule = @stack.continuous_delivery_schedule
+
+ Shipit::ContinuousDeliverySchedule::DAYS.each do |day|
+ refute(schedule.read_attribute("#{day}_enabled"))
+
+ day_start = schedule.read_attribute("#{day}_start")
+ assert_equal("09:00:00 AM", day_start.strftime("%r"))
+
+ day_end = schedule.read_attribute("#{day}_end")
+ assert_equal("05:00:00 PM", day_end.strftime("%r"))
+ end
+ end
+
+ test "#update renders validation errors" do
+ patch(:update, params: {
+ id: @stack.to_param,
+ continuous_delivery_schedule: {
+ # Make Sunday end before it starts
+ **valid_params.merge(sunday_end: "08:00"),
+ },
+ })
+
+ assert_response(:unprocessable_entity)
+ assert_equal("Check form for errors", flash[:warning])
+ elements = assert_select(".validation-errors")
+ assert_includes(elements.sole.inner_text, "Sunday end must be after start (09:00 AM)")
+ end
+ end
+end
diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb
index 22bbfaffb..08b1db7b2 100644
--- a/test/dummy/db/schema.rb
+++ b/test/dummy/db/schema.rb
@@ -10,8 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2023_07_03_181143) do
-
+ActiveRecord::Schema[7.1].define(version: 2024_08_21_003007) do
create_table "api_clients", force: :cascade do |t|
t.text "permissions", limit: 65535
t.integer "creator_id", limit: 4
@@ -87,6 +86,34 @@
t.index ["stack_id"], name: "index_commits_on_stack_id"
end
+ create_table "continuous_delivery_schedules", force: :cascade do |t|
+ t.integer "stack_id", null: false
+ t.boolean "sunday_enabled", default: true, null: false
+ t.time "sunday_start", default: "2000-01-01 00:00:00", null: false
+ t.time "sunday_end", default: "2000-01-01 23:59:00", null: false
+ t.boolean "monday_enabled", default: true, null: false
+ t.time "monday_start", default: "2000-01-01 00:00:00", null: false
+ t.time "monday_end", default: "2000-01-01 23:59:00", null: false
+ t.boolean "tuesday_enabled", default: true, null: false
+ t.time "tuesday_start", default: "2000-01-01 00:00:00", null: false
+ t.time "tuesday_end", default: "2000-01-01 23:59:00", null: false
+ t.boolean "wednesday_enabled", default: true, null: false
+ t.time "wednesday_start", default: "2000-01-01 00:00:00", null: false
+ t.time "wednesday_end", default: "2000-01-01 23:59:00", null: false
+ t.boolean "thursday_enabled", default: true, null: false
+ t.time "thursday_start", default: "2000-01-01 00:00:00", null: false
+ t.time "thursday_end", default: "2000-01-01 23:59:00", null: false
+ t.boolean "friday_enabled", default: true, null: false
+ t.time "friday_start", default: "2000-01-01 00:00:00", null: false
+ t.time "friday_end", default: "2000-01-01 23:59:00", null: false
+ t.boolean "saturday_enabled", default: true, null: false
+ t.time "saturday_start", default: "2000-01-01 00:00:00", null: false
+ t.time "saturday_end", default: "2000-01-01 23:59:00", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["stack_id"], name: "index_continuous_delivery_schedules_on_stack_id", unique: true
+ end
+
create_table "deliveries", force: :cascade do |t|
t.integer "hook_id", limit: 4, null: false
t.string "status", limit: 50, default: "pending", null: false
diff --git a/test/jobs/shipit/continuous_delivery_job_test.rb b/test/jobs/shipit/continuous_delivery_job_test.rb
new file mode 100644
index 000000000..07b7d5331
--- /dev/null
+++ b/test/jobs/shipit/continuous_delivery_job_test.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+require 'test_helper'
+
+module Shipit
+ class ContinuousDeliveryJobTest < ActiveSupport::TestCase
+ test "calls trigger_continous_delivery" do
+ stack = shipit_stacks(:shipit)
+ stack.stubs(:continuous_deployment?).returns(true).once
+ stack.stubs(:occupied).returns(false).once
+ stack.expects(:trigger_continuous_delivery).once
+
+ Shipit::ContinuousDeliveryJob.new.perform(stack)
+ end
+
+ test "does not call trigger_continuous_delivery if outside of schedule" do
+ freeze_time do
+ monday_9am = Date.current.monday.at_beginning_of_day.advance(hours: 9)
+ travel_to(monday_9am)
+
+ stack = shipit_stacks(:shipit)
+ stack.create_continuous_delivery_schedule!(monday_start: "09:30")
+
+ stack.stubs(:continuous_deployment?).returns(true).once
+ stack.expects(:trigger_continuous_delivery).never
+
+ Shipit::ContinuousDeliveryJob.new.perform(stack)
+ end
+ end
+ end
+end
diff --git a/test/models/shipit/continuous_delivery_schedule_test.rb b/test/models/shipit/continuous_delivery_schedule_test.rb
new file mode 100644
index 000000000..2c41bcee4
--- /dev/null
+++ b/test/models/shipit/continuous_delivery_schedule_test.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+require "test_helper"
+
+module Shipit
+ class ContinuousDeliveryScheduleTest < ActiveSupport::TestCase
+ test "defaults to all the time" do
+ stack = shipit_stacks(:shipit)
+ schedule = stack.build_continuous_delivery_schedule
+
+ assert(schedule.valid?)
+
+ Shipit::ContinuousDeliverySchedule::DAYS.each_with_index do |day|
+ assert(schedule.read_attribute("#{day}_enabled"))
+
+ day_start = schedule.read_attribute("#{day}_start")
+ assert_equal(day_start.at_beginning_of_day, day_start)
+
+ day_end = schedule.read_attribute("#{day}_end")
+ assert_equal(day_end.at_end_of_day.at_beginning_of_minute, day_end)
+ end
+ end
+
+ test "#get_deployment_window" do
+ schedule = Shipit::ContinuousDeliverySchedule.new(
+ monday_enabled: false,
+ monday_start: "09:15",
+ monday_end: "17:30",
+ )
+
+ monday = Date.current.monday
+
+ deployment_window = schedule.get_deployment_window(monday)
+
+ refute(deployment_window.enabled?)
+
+ starts_at = deployment_window.starts_at
+ assert_equal(monday, starts_at.to_date)
+ assert_equal(9, starts_at.hour)
+ assert_equal(15, starts_at.min)
+ assert_equal(starts_at.at_beginning_of_minute, starts_at)
+
+ ends_at = deployment_window.ends_at
+ assert_equal(monday, ends_at.to_date)
+ assert_equal(17, ends_at.hour)
+ assert_equal(30, ends_at.min)
+ assert_equal(ends_at.at_end_of_minute, ends_at)
+ end
+
+ test "#can_deploy? is false if the day is disabled" do
+ schedule = Shipit::ContinuousDeliverySchedule.new(
+ tuesday_enabled: false,
+ tuesday_start: "00:00",
+ tuesday_end: "23:59",
+ )
+
+ tuesday = Date.current.monday.advance(days: 1).beginning_of_day
+
+ refute(schedule.can_deploy?(tuesday))
+ end
+
+ test "#can_deploy? is true when the current time is within the window" do
+ schedule = Shipit::ContinuousDeliverySchedule.new(
+ wednesday_enabled: true,
+ wednesday_start: "09:15",
+ wednesday_end: "17:30",
+ )
+
+ wednesday = Date.current.monday.advance(days: 2).beginning_of_day
+
+ refute(schedule.can_deploy?(wednesday))
+ assert(schedule.can_deploy?(wednesday.advance(hours: 9, minutes: 15)))
+ assert(schedule.can_deploy?(wednesday.advance(hours: 12)))
+ assert(schedule.can_deploy?(wednesday.advance(hours: 17, minutes: 30).at_end_of_minute))
+ refute(schedule.can_deploy?(wednesday.advance(hours: 17, minutes: 31)))
+ end
+
+ test "validates that end times must come after start times" do
+ schedule = Shipit::ContinuousDeliverySchedule.new(
+ thursday_start: "15:00",
+ thursday_end: "14:00"
+ )
+
+ schedule.validate
+ assert(schedule.errors.include?(:thursday_end))
+ assert_equal(["must be after start (03:00 PM)"], schedule.errors.messages_for(:thursday_end))
+ end
+
+ test "validates `*_enabled` fields" do
+ schedule = Shipit::ContinuousDeliverySchedule.new(
+ friday_enabled: nil,
+ )
+
+ schedule.validate
+ assert_equal(["is not included in the list"], schedule.errors.messages_for(:friday_enabled))
+ end
+
+ test "requires `_start` and `_end` fields" do
+ schedule = Shipit::ContinuousDeliverySchedule.new(
+ saturday_start: nil,
+ saturday_end: nil,
+ )
+
+ schedule.validate
+ assert_equal(["can't be blank"], schedule.errors.messages_for(:saturday_start))
+ assert_equal(["can't be blank"], schedule.errors.messages_for(:saturday_end))
+ end
+ end
+end