Skip to content

Commit

Permalink
Merge pull request #1361 from davidcornu/continuous-delivery-schedule
Browse files Browse the repository at this point in the history
[Feature] Continuous Deployment Hours
  • Loading branch information
casperisfine authored Sep 30, 2024
2 parents c494be6 + 33bdf10 commit 3d7fffd
Show file tree
Hide file tree
Showing 14 changed files with 456 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions app/controllers/shipit/continuous_delivery_schedules_controller.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions app/jobs/shipit/continuous_delivery_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
84 changes: 84 additions & 0 deletions app/models/shipit/continuous_delivery_schedule.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions app/models/shipit/stack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions app/views/shipit/continuous_delivery_schedules/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<%= render partial: 'shipit/stacks/header', locals: { stack: @stack } %>

<div class="wrapper continuous-delivery-schedule">
<section>
<header class="section-header">
<h2>Continuous Delivery Schedule (Stack #<%= @stack.id %>)</h2>
</header>
</section>
<div class="setting-section">
<% if @continuous_delivery_schedule.errors.any? %>
<div class="validation-errors">
<p>Validation errors prevented your schedule from being saved</p>
<ul>
<% @continuous_delivery_schedule.errors.full_messages.each do |full_message| %>
<li><%= full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<%= form_for(@continuous_delivery_schedule, url: stack_continuous_delivery_schedule_path, method: :patch) do |f| %>
<table class="field-wrapper">
<tbody>
<% Shipit::ContinuousDeliverySchedule::DAYS.rotate.each do |day| %>
<tr>
<td>
<%= f.check_box("#{day}_enabled") %>
</td>
<td>
<%= f.label("#{day}_enabled", day.titlecase) %>
</td>
<td>
<%= f.time_field("#{day}_start", include_seconds: false) %>
</td>
<td>&rarr;</td>
<td>
<%= f.time_field("#{day}_end", include_seconds: false) %>
</td>
<td>
<% if day == "monday" %>
<button data-action="copy-to-all" type="button">Copy to all &darr;</button>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>

<p>
&#x2139;&#xFE0F;
All times are in <%= Time.zone.name %>
(the <a href="https://guides.rubyonrails.org/configuring.html#config-time-zone">default time zone</a>).
</p>

<div class="field-wrapper">
<%= f.submit("Save", class: "btn") %>
</div>
<% end %>
</div>
</div>
1 change: 1 addition & 0 deletions app/views/shipit/stacks/_settings_form.erb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<div class="field-wrapper">
<%= f.check_box :continuous_deployment %>
<%= f.label :continuous_deployment, 'Enable continuous deployment' %>
(<%= link_to("Edit schedule", stack_continuous_delivery_schedule_path) %>)
</div>

<div class="field-wrapper">
Expand Down
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions db/migrate/20240821003007_add_continuous_delivery_schedules.rb
Original file line number Diff line number Diff line change
@@ -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
65 changes: 65 additions & 0 deletions test/controllers/continuous_delivery_schedules_controller_test.rb
Original file line number Diff line number Diff line change
@@ -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
31 changes: 29 additions & 2 deletions test/dummy/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 3d7fffd

Please sign in to comment.