Skip to content

Commit

Permalink
Merge pull request #33 from grafana/make-unavailability-limit-configu…
Browse files Browse the repository at this point in the history
…rable

make the unavailability limit configurable
  • Loading branch information
replay authored May 8, 2024
2 parents d27cc0c + 41f0c06 commit 4cf2bb2
Show file tree
Hide file tree
Showing 7 changed files with 57 additions and 22 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ teams:
output: "slack-handle"
ignoreLabels:
- stale
unavailabilityLimit: 6h
```

#### Root configuration struct
Expand All @@ -125,6 +126,7 @@ ignoreLabels:
| -------------- | -------------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ignoreLabels` | List of Strings | false | `[]` | List of labels which mark this issue to be ignored. If triggered on an issue which has at least **one** of the labels to be ignored, the action exits without doing something |
| `teams` | Map of Team configurations | true | `nil` | Definition of the teams this issue is distributed between. |
| `unavailabilityLimit` | Duration | false | `6h` | Duration for which a calendar event must block someone's availability for them to be considered unavailable. |

#### Team configuration struct

Expand Down Expand Up @@ -176,4 +178,4 @@ Next members can share their availability with this service account via:
2. Open Settings by opening the hamburger menu next to your personal calendar and clicking `Settings and sharing`
3. In the `Share with specific people or groups` section you click `+ Add people and groups`
4. Enter the email address of the service account you created and select `See only free/busy (hide details)` under Permissions.
5. Click `Send` and you are done
5. Click `Send` and you are done
8 changes: 4 additions & 4 deletions pkg/icassigner/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func (a *Action) Run(ctx context.Context, event *github.IssuesEvent, dryRun bool
continue
}

isAvailable, err := checkAvailability(member)
isAvailable, err := checkAvailability(member, a.Config.UnavailabilityLimit)
if err != nil {
log.Printf("Unable to fetch availability of %q, due %v", name, err)
}
Expand Down Expand Up @@ -161,16 +161,16 @@ func (a *Action) calculateIssueBusynessPerTeamMember(ctx context.Context, now ti
return busyness.CalculateBusynessForTeam(ctx, now, a.Client, a.Config.IgnoredLabels, team)
}

func checkAvailability(m MemberConfig) (bool, error) {
func checkAvailability(m MemberConfig, unavailabilityLimit time.Duration) (bool, error) {
if m.GoogleCalendar != "" {
cfg, err := GetGoogleConfig()
if err != nil {
return true, err
}
return calendar.CheckGoogleAvailability(cfg, m.GoogleCalendar, m.Name, time.Now())
return calendar.CheckGoogleAvailability(cfg, m.GoogleCalendar, m.Name, time.Now(), unavailabilityLimit)
}

return calendar.CheckAvailability(m.IcalURL, m.Name, time.Now())
return calendar.CheckAvailability(m.IcalURL, m.Name, time.Now(), unavailabilityLimit)
}

func GetGoogleConfig() (calendar.GoogleConfigJSON, error) {
Expand Down
6 changes: 4 additions & 2 deletions pkg/icassigner/calendar/google.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import (

type GoogleConfigJSON string

func CheckGoogleAvailability(cfg GoogleConfigJSON, calendarName string, name string, now time.Time) (bool, error) {
func CheckGoogleAvailability(cfg GoogleConfigJSON, calendarName string, name string, now time.Time, unavailabilityLimit time.Duration) (bool, error) {
opt := option.WithCredentialsJSON([]byte(cfg))
calService, err := calendar.NewService(context.Background(), opt)
if err != nil {
Expand All @@ -54,6 +54,8 @@ func CheckGoogleAvailability(cfg GoogleConfigJSON, calendarName string, name str
return true, fmt.Errorf("unable to access calendar from %v, please ensure they shared their calendar with the service account. Internal error %q", name, calendar.Errors[0].Reason)
}

availabilityChecker := newIcalAvailabilityChecker(now, unavailabilityLimit, time.UTC)

// check all events
for _, e := range calendar.Busy {
start, err := time.Parse(time.RFC3339, e.Start)
Expand All @@ -66,7 +68,7 @@ func CheckGoogleAvailability(cfg GoogleConfigJSON, calendarName string, name str
continue
}

if isEventBlockingAvailability(now, start, end, time.UTC) {
if availabilityChecker.isEventBlockingAvailability(start, end) {
return false, nil
}
}
Expand Down
36 changes: 26 additions & 10 deletions pkg/icassigner/calendar/ical.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,30 @@ import (
"github.com/emersion/go-ical"
)

const UnavailabilityLimit = 6 * time.Hour // 6hr
const DefaultUnavailabilityLimit = 6 * time.Hour

func isEventBlockingAvailability(now time.Time, start, end time.Time, loc *time.Location) bool {
type icalAvailabilityChecker struct {
now time.Time
unavailabilityLimit time.Duration
location *time.Location
}

func newIcalAvailabilityChecker(now time.Time, unavailabilityLimit time.Duration, location *time.Location) icalAvailabilityChecker {
return icalAvailabilityChecker{
now: now,
unavailabilityLimit: unavailabilityLimit,
location: location,
}
}

func (i *icalAvailabilityChecker) isEventBlockingAvailability(start, end time.Time) bool {
// if event is shorter than unavailabilityLimit, skip it
if end.Sub(start) < UnavailabilityLimit {
if end.Sub(start) < i.unavailabilityLimit {
return false
}

// if the end of this date is already before the current date, skip it
if end.Before(now) {
if end.Before(i.now) {
return false
}

Expand All @@ -44,7 +58,7 @@ func isEventBlockingAvailability(now time.Time, start, end time.Time, loc *time.
//
// Now we need to check if that event starts in the next 12 business hours
lookAheadTime := 12 * time.Hour
localDate := now.In(loc)
localDate := i.now.In(i.location)

switch localDate.Weekday() {
case time.Friday:
Expand Down Expand Up @@ -76,7 +90,9 @@ func parseStartEnd(e ical.Event, loc *time.Location) (time.Time, time.Time, erro
return start, end, nil
}

func checkEvents(events []ical.Event, name string, now time.Time, loc *time.Location) (bool, error) {
func checkEvents(events []ical.Event, name string, now time.Time, loc *time.Location, unavailabilityLimit time.Duration) (bool, error) {
availabilityChecker := newIcalAvailabilityChecker(now, unavailabilityLimit, loc)

for _, event := range events {
if prop := event.Props.Get(ical.PropTransparency); prop != nil && prop.Value == "TRANSPARENT" {
continue
Expand All @@ -89,7 +105,7 @@ func checkEvents(events []ical.Event, name string, now time.Time, loc *time.Loca
}

// check original occurence
if isEventBlockingAvailability(now, start, end, loc) {
if availabilityChecker.isEventBlockingAvailability(start, end) {
log.Printf("calendar.isAvailableOn: person %q in %q is unavailable due to event from %q to %q\n", name, loc.String(), start, end)
return false, nil
}
Expand All @@ -109,7 +125,7 @@ func checkEvents(events []ical.Event, name string, now time.Time, loc *time.Loca
start := o
end := o.Add(completeDuration)

if isEventBlockingAvailability(now, start, end, loc) {
if availabilityChecker.isEventBlockingAvailability(start, end) {
log.Printf(`calendar.isAvailableOn: person %q is unavailable due to event from %q to %q`, name, start, end)
return false, nil
}
Expand All @@ -119,7 +135,7 @@ func checkEvents(events []ical.Event, name string, now time.Time, loc *time.Loca
return true, nil
}

func CheckAvailability(icalUrl string, name string, now time.Time) (bool, error) {
func CheckAvailability(icalUrl string, name string, now time.Time, unavailabilityLimit time.Duration) (bool, error) {
resp, err := http.Get(icalUrl)
if err != nil {
return true, fmt.Errorf("unable to download ical file, due %w", err)
Expand All @@ -143,5 +159,5 @@ func CheckAvailability(icalUrl string, name string, now time.Time) (bool, error)
}
}

return checkEvents(cal.Events(), name, now, loc)
return checkEvents(cal.Events(), name, now, loc, unavailabilityLimit)
}
5 changes: 3 additions & 2 deletions pkg/icassigner/calendar/ical_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ func TestIsEventBlockingAvailability(t *testing.T) {

for _, testcase := range testCases {
t.Run(testcase.name, func(t *testing.T) {
res := isEventBlockingAvailability(testcase.now, testcase.start, testcase.end, testcase.location)
availabilityChecker := newIcalAvailabilityChecker(testcase.now, 6*time.Hour, testcase.location)
res := availabilityChecker.isEventBlockingAvailability(testcase.start, testcase.end)
if res != testcase.expectedResult {
t.Errorf("Expected isEventBlockingAvailability to be %v, but got %v for event between %q and %q (tz=%v)", testcase.expectedResult, res, testcase.start, testcase.end, testcase.location.String())
}
Expand Down Expand Up @@ -230,7 +231,7 @@ END:VCALENDAR`
loc, _ := time.LoadLocation("UTC")
now := time.Date(2023, time.December, 07, 16, 0, 0, 0, loc)

r, err := CheckAvailability(ts.URL, "tester", now)
r, err := CheckAvailability(ts.URL, "tester", now, DefaultUnavailabilityLimit)

if err != nil {
t.Errorf("No error expected during basic ical check, but got %v", err)
Expand Down
12 changes: 10 additions & 2 deletions pkg/icassigner/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,17 @@ import (
"context"
"fmt"
"io"
"time"

"github.com/google/go-github/github"
"github.com/grafana/escalation-scheduler/pkg/icassigner/calendar"
"gopkg.in/yaml.v2"
)

type Config struct {
Teams map[string]TeamConfig `yaml:"teams,omitempty"`
IgnoredLabels []string `yaml:"ignoreLabels,omitempty"`
UnavailabilityLimit time.Duration `yaml:"unavailabilityLimit,omitempty"`
Teams map[string]TeamConfig `yaml:"teams,omitempty"`
IgnoredLabels []string `yaml:"ignoreLabels,omitempty"`
}

type TeamConfig struct {
Expand All @@ -51,6 +54,11 @@ func ParseConfig(r io.Reader) (Config, error) {
return cfg, fmt.Errorf("unable to parse config, due: %w", err)
}

if cfg.UnavailabilityLimit == 0 {
// If unset, set default value
cfg.UnavailabilityLimit = calendar.DefaultUnavailabilityLimit
}

return cfg, nil
}

Expand Down
8 changes: 7 additions & 1 deletion pkg/icassigner/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package icassigner
import (
"bytes"
"testing"
"time"
)

func TestMimirConfigCanBeParsed(t *testing.T) {
Expand All @@ -38,7 +39,8 @@ func TestMimirConfigCanBeParsed(t *testing.T) {
ical-url: https://tester2/basic.ics
output: slack2
ignoreLabels:
- stale` // redacted excerpt from a real world config
- stale
unavailabilityLimit: 6h` // redacted excerpt from a real world config

r := bytes.NewBuffer([]byte(rawConfig))

Expand All @@ -57,6 +59,10 @@ ignoreLabels:
t.Fatal("Expected to find team \"mimir\", but got none")
}

if cfg.UnavailabilityLimit != 6*time.Hour {
t.Error("Expected unavailability limit to be 6h, but got", cfg.UnavailabilityLimit)
}

expectedRequiredLabels := []string{"cloud-prometheus", "enterprise-metrics"}
for i, e := range expectedRequiredLabels {
if i >= len(team.RequireLabel) {
Expand Down

0 comments on commit 4cf2bb2

Please sign in to comment.