Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add monthly rotation option #3243

Merged
merged 40 commits into from
Sep 13, 2023
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
d1af692
add monthly rotation feat
Aug 21, 2023
62d9d99
Merge branch 'master' into feat/monthly-rotation
allending313 Aug 22, 2023
51cb81a
update db-schema
Aug 22, 2023
af86af3
revert migration update, create new migration
Aug 22, 2023
9e3d9ff
update models.go
Aug 22, 2023
3091f82
update monthly start/end to be datetime-from-start-of-month
mastercactapus Aug 22, 2023
814c023
update monthly rotation logic
Aug 24, 2023
34bce95
make db-schema && make generate
Forfold Aug 28, 2023
94748b3
Merge branch 'master' into feat/monthly-rotation
Forfold Aug 28, 2023
d4bb395
fix ts error
Forfold Aug 29, 2023
8eb91f1
add monthly rotation smoke test
Aug 30, 2023
88603c2
fix rotation tests
Aug 31, 2023
3bfed55
refactor smoke test
Sep 5, 2023
e56c57c
refactor shiftLengthHours to shiftLength
Sep 5, 2023
16d580d
Merge branch 'master' into feat/monthly-rotation
allending313 Sep 5, 2023
0636ae1
fix webhook expansion when slack is disabled (#3264)
mastercactapus Sep 6, 2023
d0e77bb
dev: add local db url to dev setup guide (#3266)
mastercactapus Sep 11, 2023
99a3fea
Bump react-redux from 8.1.1 to 8.1.2 (#3269)
dependabot[bot] Sep 11, 2023
a94f5ca
Bump eslint-plugin-import from 2.27.5 to 2.28.1 (#3268)
dependabot[bot] Sep 11, 2023
97b48e7
Bump actions/checkout from 3 to 4 (#3267)
dependabot[bot] Sep 11, 2023
f87b409
preserve field/type information when parsing ISODuration (#3272)
mastercactapus Sep 11, 2023
e573204
add monthly rotation feat
Aug 21, 2023
9a934a8
update db-schema
Aug 22, 2023
86f0185
revert migration update, create new migration
Aug 22, 2023
1b9c21b
update monthly rotation logic
Aug 24, 2023
d79ae0f
make db-schema && make generate
Forfold Aug 28, 2023
ba0c05e
refactor monthly rotation api
Sep 11, 2023
82aee09
fix rotation test
Sep 11, 2023
57600be
Merge branch 'master' into feat/monthly-rotation
mastercactapus Sep 11, 2023
23ca07b
move new migration to the end
mastercactapus Sep 11, 2023
148c36b
cleanup CalcRotationHandoffTimes and fix missing Start
mastercactapus Sep 12, 2023
4083e38
add missing validation and fix error
mastercactapus Sep 12, 2023
839c447
fix deprecation warning
mastercactapus Sep 12, 2023
430c9a3
polish
mastercactapus Sep 12, 2023
1b19bc9
revert whitespace change (will fail on CI otherwise)
mastercactapus Sep 12, 2023
e7721aa
fix schema.sql out of date
Sep 12, 2023
882659d
add comments to new rotation logic
Sep 12, 2023
498eaab
minor fixes for clarity
Sep 13, 2023
22bbec1
revert timeFormat test corrections
Sep 13, 2023
58c096a
fix page crash if rotation is monthly
mastercactapus Sep 13, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -926,6 +926,7 @@ type Rotation {
}

enum RotationType {
monthly
weekly
daily
hourly
Expand Down Expand Up @@ -972,7 +973,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=352bdae95107b10d3d72b37649c056c72a8ebf2f4c62116005795107a2acc4e3 -
-- DISK=9a7f47853bcc8b1aa4f0f916f53609e8523bfd8b9eb44cfee84fbb7286ef3d99 -
-- PSQL=9a7f47853bcc8b1aa4f0f916f53609e8523bfd8b9eb44cfee84fbb7286ef3d99 -
--
-- 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
57 changes: 56 additions & 1 deletion schedule/rotation/rotation.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,57 @@ 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")
}
}

func (r Rotation) monthStartTime(t time.Time, n int) time.Time {
if n > 10000 {
panic("too many iterations")
}

if t.After(r.Start) || t.Equal(r.Start) { // t is 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)
}

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
}

return r.monthStartTime(t, n+1)
}

func (r Rotation) monthEndTime(t time.Time, n int) time.Time {
if n > 10000 {
panic("too many iterations")
}

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

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)
}

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).
func (r Rotation) StartTime(t time.Time) time.Time {
Expand All @@ -45,6 +92,10 @@ func (r Rotation) StartTime(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.monthStartTime(t, 1)
}

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

Expand All @@ -65,6 +116,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 +143,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