Skip to content

Commit

Permalink
feat: add monthly rotation option (#3243)
Browse files Browse the repository at this point in the history
* add monthly rotation feat

* update db-schema

* revert migration update, create new migration

* update models.go

* update monthly start/end to be datetime-from-start-of-month

* update monthly rotation logic

* make db-schema && make generate

* fix ts error

* add monthly rotation smoke test

* fix rotation tests

* refactor smoke test

* refactor shiftLengthHours to shiftLength

* fix webhook expansion when slack is disabled (#3264)

* dev: add local db url to dev setup guide (#3266)

* add local db url to dev setup guide

* clarify no pass

* Bump react-redux from 8.1.1 to 8.1.2 (#3269)

Bumps [react-redux](https://github.com/reduxjs/react-redux) from 8.1.1 to 8.1.2.
- [Release notes](https://github.com/reduxjs/react-redux/releases)
- [Changelog](https://github.com/reduxjs/react-redux/blob/master/CHANGELOG.md)
- [Commits](reduxjs/react-redux@v8.1.1...v8.1.2)

---
updated-dependencies:
- dependency-name: react-redux
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump eslint-plugin-import from 2.27.5 to 2.28.1 (#3268)

Bumps [eslint-plugin-import](https://github.com/import-js/eslint-plugin-import) from 2.27.5 to 2.28.1.
- [Release notes](https://github.com/import-js/eslint-plugin-import/releases)
- [Changelog](https://github.com/import-js/eslint-plugin-import/blob/main/CHANGELOG.md)
- [Commits](import-js/eslint-plugin-import@v2.27.5...v2.28.1)

---
updated-dependencies:
- dependency-name: eslint-plugin-import
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump actions/checkout from 3 to 4 (#3267)

Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](actions/checkout@v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* preserve field/type information when parsing ISODuration (#3272)

* add monthly rotation feat

* update db-schema

* revert migration update, create new migration

* update monthly rotation logic

* make db-schema && make generate

* refactor monthly rotation api

* fix rotation test

* move new migration to the end

* cleanup CalcRotationHandoffTimes and fix missing Start

* add missing validation and fix error

* fix deprecation warning

* polish

* revert whitespace change (will fail on CI otherwise)

* fix schema.sql out of date

* add comments to new rotation logic

* minor fixes for clarity

* revert timeFormat test corrections

* fix page crash if rotation is monthly

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: AllenDing <Allen.Ding@target.com>
Co-authored-by: Nathaniel Caza <mastercactapus@gmail.com>
Co-authored-by: Nathaniel Cook <nathanieljcook@outlook.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
  • Loading branch information
5 people authored Sep 13, 2023
1 parent 96090f2 commit 9d75a88
Show file tree
Hide file tree
Showing 17 changed files with 410 additions and 80 deletions.
2 changes: 1 addition & 1 deletion devtools/pgmocktime/mocker.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ func (m *Mocker) Close() error { m.db.Close(); return nil }

// AdvanceTime advances the time by the given duration.
func (m *Mocker) AdvanceTime(ctx context.Context, d time.Duration) error {
m.exec(ctx, `update %s.flux_capacitor set ref_time = current_timestamp, base_time = %s.now() + '%d milliseconds'::interval`, m.safeSchema(), m.safeSchema(), d/time.Millisecond)
m.exec(ctx, `update %s.flux_capacitor set ref_time = current_timestamp, base_time = %s.now() + '%d hours'::interval + '%d milliseconds'::interval`, m.safeSchema(), m.safeSchema(), d/time.Hour, (d % time.Hour).Milliseconds())
return m.readErr("advance time")
}

Expand Down
2 changes: 1 addition & 1 deletion devtools/resetdb/datagen.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import (

var (
timeZones = []string{"America/Chicago", "Europe/Berlin", "UTC"}
rotationTypes = []rotation.Type{rotation.TypeDaily, rotation.TypeHourly, rotation.TypeWeekly}
rotationTypes = []rotation.Type{rotation.TypeDaily, rotation.TypeHourly, rotation.TypeWeekly, rotation.TypeMonthly}
)

type AlertLog struct {
Expand Down
7 changes: 4 additions & 3 deletions gadb/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 27 additions & 2 deletions graphql2/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

84 changes: 71 additions & 13 deletions graphql2/graphqlapp/rotation.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/target/goalert/search"
"github.com/target/goalert/user"
"github.com/target/goalert/util"
"github.com/target/goalert/util/timeutil"
"github.com/target/goalert/validation"
"github.com/target/goalert/validation/validate"

Expand Down Expand Up @@ -378,38 +379,95 @@ func (m *Mutation) UpdateRotation(ctx context.Context, input graphql2.UpdateRota
}

func (a *Query) CalcRotationHandoffTimes(ctx context.Context, input *graphql2.CalcRotationHandoffTimesInput) ([]time.Time, error) {
var result []time.Time
var err error

err = validate.Many(
err,
validate.Range("count", input.Count, 0, 20),
validate.Range("hours", input.ShiftLengthHours, 0, 99999),
)
err := validate.Range("count", input.Count, 0, 20)
if err != nil {
return result, err
return nil, err
}

loc, err := util.LoadLocation(input.TimeZone)
if err != nil {
return result, validation.NewFieldError("timeZone", err.Error())
return nil, validation.NewFieldError("timeZone", err.Error())
}

if input.ShiftLength != nil && input.ShiftLengthHours != nil {
return nil, validation.NewFieldError("shiftLength", "only one of (shiftLength, shiftLengthHours) is allowed")
}

rot := &rotation.Rotation{
Start: input.Handoff.In(loc),
ShiftLength: input.ShiftLengthHours,
Type: rotation.TypeHourly,
rot := rotation.Rotation{
Start: input.Handoff.In(loc),
}
switch {
case input.ShiftLength != nil:
err = setRotationShiftFromISO(&rot, input.ShiftLength)
if err != nil {
return nil, err
}
case input.ShiftLengthHours != nil:
err = validate.Range("hours", *input.ShiftLengthHours, 0, 99999)
if err != nil {
return nil, err
}
rot.Type = rotation.TypeHourly
rot.ShiftLength = *input.ShiftLengthHours
default:
return nil, validation.NewFieldError("shiftLength", "must be specified")
}

t := time.Now()
if input.From != nil {
t = input.From.In(loc)
}

var result []time.Time
for len(result) < input.Count {
t = rot.EndTime(t)
result = append(result, t)
}

return result, nil
}

// getRotationFromISO determines the rotation type based on the given ISODuration. An error is given if the unsupported year field or multiple non-zero fields are given.
func setRotationShiftFromISO(rot *rotation.Rotation, dur *timeutil.ISODuration) error {

// validate only one time field (year, month, days, timepart) is non-zero
nonZeroFields := 0

if dur.YearPart > 0 {
// These validation errors are only possible from direct api calls,
// thus using ISO standard terminology "designator" to match the spec.
return validation.NewFieldError("shiftLength", "year designator not allowed")
}

if dur.MonthPart > 0 {
rot.Type = rotation.TypeMonthly
rot.ShiftLength = dur.MonthPart
nonZeroFields++
}
if dur.WeekPart > 0 {
rot.Type = rotation.TypeWeekly
rot.ShiftLength = dur.WeekPart
nonZeroFields++
}
if dur.DayPart > 0 {
rot.Type = rotation.TypeDaily
rot.ShiftLength = dur.DayPart
nonZeroFields++
}
if dur.HourPart > 0 {
rot.Type = rotation.TypeHourly
rot.ShiftLength = dur.HourPart
nonZeroFields++
}

if nonZeroFields == 0 {
return validation.NewFieldError("shiftLength", "must not be zero")
}
if nonZeroFields > 1 {
// Same as above, this error is only possible from direct api calls.
return validation.NewFieldError("shiftLength", "only one of (M, W, D, H) is allowed")
}

return nil
}
11 changes: 6 additions & 5 deletions graphql2/models_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion graphql2/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,7 @@ type Rotation {
}

enum RotationType {
monthly
weekly
daily
hourly
Expand Down Expand Up @@ -974,7 +975,12 @@ input CalcRotationHandoffTimesInput {
handoff: ISOTimestamp!
from: ISOTimestamp
timeZone: String!
shiftLengthHours: Int!
shiftLengthHours: Int
@deprecated(
reason: "Only accurate for hourly-type rotations. Use shiftLength instead."
)

shiftLength: ISODuration
count: Int!
}

Expand Down
5 changes: 5 additions & 0 deletions migrate/migrations/20230911153243-add-monthly-rotation.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- +migrate Up notransaction
ALTER TYPE enum_rotation_type ADD VALUE IF NOT EXISTS 'monthly';

-- +migrate Down

7 changes: 4 additions & 3 deletions migrate/schema.sql
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
-- This file is auto-generated by "make db-schema"; DO NOT EDIT
-- DATA=35c108711cef0a38d78d5872c062fe576844c7f16e309378a051faa813578de0 -
-- DISK=b18bab67b9291c444025e882a3360c022c034d85477cad010781b2ec1b49fe3b -
-- PSQL=b18bab67b9291c444025e882a3360c022c034d85477cad010781b2ec1b49fe3b -
-- DATA=6b404a10cbecec48dd680c5a6c3312950cef04a97ebf2b8bbff62df0c419d76d -
-- DISK=ef7c2b41f4d00eabfe482d4677365671da36c0562d0872b32d4adc95c74e0f8b -
-- PSQL=ef7c2b41f4d00eabfe482d4677365671da36c0562d0872b32d4adc95c74e0f8b -
--
-- pgdump-lite database dump
--
Expand Down Expand Up @@ -122,6 +122,7 @@ CREATE TYPE enum_outgoing_messages_type AS ENUM (
CREATE TYPE enum_rotation_type AS ENUM (
'daily',
'hourly',
'monthly',
'weekly'
);

Expand Down
67 changes: 65 additions & 2 deletions schedule/rotation/rotation.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,77 @@ func (r Rotation) shiftClock() timeutil.Clock {
case TypeWeekly:
return timeutil.NewClock(r.ShiftLength*24*7, 0)
default:
// monthly is handled separately
panic("unexpected rotation type")
}
}

// monthStartTime recursively calculates the previous handoff time of a rotation active at t.
func (r Rotation) monthStartTime(t time.Time, n int) time.Time {
if n > 10000 {
panic("too many iterations")
}

if !t.Before(r.Start) { // t is at or after start of rotation
next := r.Start.AddDate(0, r.ShiftLength*n, 0)
if next.After(t) {
return r.Start.AddDate(0, r.ShiftLength*(n-1), 0)
}

// recursively finds the end of shift time of the rotation which came immediately before t
return r.monthStartTime(t, n+1)
}

// t is before start of rotation
prev := r.Start.AddDate(0, -r.ShiftLength*n, 0)
if prev.Before(t) {
return prev
}

// recursively finds the end of shift time of the rotation which came immediately before t when t is before the rotation start time
return r.monthStartTime(t, n+1)
}

// monthEndTime recursively calculates the end of the rotation (handoff time) that was active at time t.
func (r Rotation) monthEndTime(t time.Time, n int) time.Time {
if n > 10000 {
panic("too many iterations")
}

if !t.Before(r.Start) { // t is at or after start of rotation
next := r.Start.AddDate(0, r.ShiftLength*n, 0)
if next.After(t) {
return next
}

// recursively finds the immediate end of shift time after t
return r.monthEndTime(t, n+1)
}

// t is before start of rotation
prev := r.Start.AddDate(0, -r.ShiftLength*n, 0)
if prev.Before(t) || prev.Equal(t) {
return r.Start.AddDate(0, -r.ShiftLength*(n-1), 0)
}

// recursively finds the immediate end of shift time after t for cases when t is before the rotation start time
return r.monthEndTime(t, n+1)
}

// StartTime calculates the start of the "shift" that started at (or was active) at t.
// For daily and weekly rotations, start time will be the previous handoff time (from start).
// For daily, weekly, and monthly rotations, start time will be the previous handoff time (from start).
// For monthly rotations, the monthStartTime function is used to recursively handle calculations as the length of months vary.
func (r Rotation) StartTime(t time.Time) time.Time {
if r.ShiftLength <= 0 {
r.ShiftLength = 1
}
t = t.In(r.Start.Location()).Truncate(time.Minute)
r.Start = r.Start.Truncate(time.Minute)

if r.Type == TypeMonthly {
return r.monthStartTime(t, 1)
}

shiftClockLen := r.shiftClock()
rem := timeutil.ClockDiff(r.Start, t) % shiftClockLen

Expand All @@ -56,6 +114,7 @@ func (r Rotation) StartTime(t time.Time) time.Time {
}

// EndTime calculates the end of the "shift" that started at (or was active) at t.
// For monthly rotations, the monthEndTime function is used to recursively handle calculations as the length of months vary.
//
// It is guaranteed to occur after t.
func (r Rotation) EndTime(t time.Time) time.Time {
Expand All @@ -65,6 +124,10 @@ func (r Rotation) EndTime(t time.Time) time.Time {
t = t.In(r.Start.Location()).Truncate(time.Minute)
r.Start = r.Start.Truncate(time.Minute)

if r.Type == TypeMonthly {
return r.monthEndTime(t, 1)
}

shiftClockLen := r.shiftClock()
rem := timeutil.ClockDiff(r.Start, t) % shiftClockLen

Expand All @@ -88,7 +151,7 @@ func (r Rotation) Normalize() (*Rotation, error) {
err := validate.Many(
validate.IDName("Name", r.Name),
validate.Range("ShiftLength", r.ShiftLength, 1, 9000),
validate.OneOf("Type", r.Type, TypeWeekly, TypeDaily, TypeHourly),
validate.OneOf("Type", r.Type, TypeMonthly, TypeWeekly, TypeDaily, TypeHourly),
validate.Text("Description", r.Description, 1, 255),
)
if err != nil {
Expand Down
Loading

0 comments on commit 9d75a88

Please sign in to comment.