diff --git a/README.md b/README.md index 9d1efe1..131d912 100644 --- a/README.md +++ b/README.md @@ -27,22 +27,20 @@ schedules_mapping: # User group name in Slack to be updated based on the OpsGenie schedule. user_group_name: '' - # Optional - # Overrides OpsGenie schedules based on the given conditions. + # Optional. Overrides OpsGenie schedules based on the given config. overrides: - # Email of the user that's on-call should be equal to the given value. - - when_user: '' - # Current time (in the given timezone) must be within the given time range. - # Format: HH:MM:SS. - from_time: '' - to_time: '' - with_timezone: '' - # Days that should be considered to override. - # Possible values: + # Email of the user that's on-call. + - user_email: '' + # Current time (in the given timezone) must be within the given time range. Format: HH:MM:SS. + timezone: '' + starts_on: '' + ends_on: '' + # Optional. Defaults to all_days. + # Days that should be considered to override. Possible values: # - all_days # - weekdays # - weekends - on: '' + repeats_on: '' # Update the user group in Slack with the given users instead of the one that's on-call. replace_by: - '' @@ -60,11 +58,11 @@ schedules_mapping: - schedule_name: sre user_group_name: 'sre-oncall' overrides: - - when_user: john.doe@gmail.com - from_time: '23:00:00' - to_time: '01:00:00' - with_timezone: 'America/Fortaleza' - on: weekdays + - user_email: john.doe@gmail.com + timezone: 'America/Fortaleza' + starts_on: '23:00:00' + ends_on: '01:00:00' + repeats_on: weekdays replace_by: - jane.doe@gmail.com ``` diff --git a/disturbed/configuration/__init__.py b/disturbed/configuration/__init__.py index cd96ab8..506c307 100644 --- a/disturbed/configuration/__init__.py +++ b/disturbed/configuration/__init__.py @@ -5,7 +5,12 @@ import yaml import yamlcore -from disturbed.configuration.types import Config, ScheduleMapping, ScheduleOverride +from disturbed.configuration.types import ( + Config, + RepeatsOn, + ScheduleMapping, + ScheduleOverride, +) from disturbed.opsgenie.api import logger @@ -33,7 +38,17 @@ def _load(self) -> Config: for mapping in config_dict["schedules_mapping"]: overrides = None if "overrides" in mapping: - overrides = [ScheduleOverride(**override) for override in mapping["overrides"]] + overrides = [ + ScheduleOverride( + user_email=override["user_email"], + timezone=override["timezone"], + starts_on=override["starts_on"], + ends_on=override["ends_on"], + repeats_on=RepeatsOn(override["repeats_on"]) if "repeats_on" in override else None, + replace_by=override["replace_by"], + ) + for override in mapping["overrides"] + ] mappings.append( ScheduleMapping( schedule_name=mapping["schedule_name"], diff --git a/disturbed/configuration/types.py b/disturbed/configuration/types.py index 8a466c6..2aa951a 100644 --- a/disturbed/configuration/types.py +++ b/disturbed/configuration/types.py @@ -1,18 +1,21 @@ from dataclasses import dataclass +from enum import Enum from typing import Optional -ALL_DAYS = "all_days" -WEEKDAYS = "weekdays" -WEEKENDS = "weekends" + +class RepeatsOn(Enum): + ALL_DAYS = "all_days" + WEEKDAYS = "weekdays" + WEEKENDS = "weekends" @dataclass class ScheduleOverride: - when_user: str - from_time: str - to_time: str - with_timezone: str - on: str + user_email: str + timezone: str + starts_on: str + ends_on: str + repeats_on: Optional[RepeatsOn] replace_by: list[str] diff --git a/disturbed/handler/schedule.py b/disturbed/handler/schedule.py index 515e5b7..d36223e 100644 --- a/disturbed/handler/schedule.py +++ b/disturbed/handler/schedule.py @@ -2,7 +2,7 @@ from typing import Optional from disturbed.configuration import Configuration, ScheduleMapping, ScheduleOverride -from disturbed.configuration.types import ALL_DAYS, WEEKDAYS, WEEKENDS +from disturbed.configuration.types import RepeatsOn from disturbed.handler.time import is_time_between, is_weekday from disturbed.opsgenie.api import OpsgenieApi from disturbed.slack.api import SlackApi @@ -18,7 +18,7 @@ def __init__(self, config: Configuration, opsgenie_api: OpsgenieApi, slack_api: self.opsgenie_api = opsgenie_api self.slack_api = slack_api - def handle_schedules(self) -> Optional[DisturbedError]: + def process(self) -> Optional[DisturbedError]: group_id_by_name = self.slack_api.find_user_group_ids( group_names=[mapping.user_group_name for mapping in self.config.schedules_mapping] ) @@ -33,13 +33,17 @@ def handle_schedules(self) -> Optional[DisturbedError]: if schedule.overrides: continue_processing = True for override in schedule.overrides: - override_applied = self._handle_override( - oncall_user_email.value, schedule, override, group_id_by_name.value + override_applied = self._apply_override( + oncall_user_email=oncall_user_email.value, + schedule=schedule, + override=override, + group_id_by_name=group_id_by_name.value, ) if override_applied.is_left(): return override_applied.value if override_applied.value: continue_processing = False + break if not continue_processing: continue @@ -54,25 +58,30 @@ def handle_schedules(self) -> Optional[DisturbedError]: return error logger.info("All done!") - def _handle_override( + def _apply_override( self, oncall_user_email: str, schedule: ScheduleMapping, override: ScheduleOverride, group_id_by_name: dict[str, str], ) -> Either[DisturbedError, bool]: - if override.when_user != oncall_user_email: + if oncall_user_email != override.user_email: return Either.right(False) - is_on_valid_day = ( - override.on == ALL_DAYS - or (override.on == WEEKDAYS and is_weekday(override.with_timezone)) - or (override.on == WEEKENDS and not is_weekday(override.with_timezone)) + is_on_repeat = ( + not override.repeats_on + or override.repeats_on == RepeatsOn.ALL_DAYS + or (override.repeats_on == RepeatsOn.WEEKDAYS and is_weekday(override.timezone)) + or (override.repeats_on == RepeatsOn.WEEKENDS and not is_weekday(override.timezone)) ) - if not is_on_valid_day: + if not is_on_repeat: return Either.right(False) - is_time_within_range = is_time_between(override.with_timezone, override.from_time, override.to_time) + is_time_within_range = is_time_between( + timezone=override.timezone, + from_time=override.starts_on, + to_time=override.ends_on, + ) if is_time_within_range.is_left(): return is_time_within_range if not is_time_within_range.value: diff --git a/disturbed/types/either.py b/disturbed/types/either.py index eddf91a..af20838 100644 --- a/disturbed/types/either.py +++ b/disturbed/types/either.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Generic, TypeVar, Union +from typing import Callable, Generic, Optional, TypeVar, Union, cast L = TypeVar("L") # Left type R = TypeVar("R") # Right type @@ -28,3 +28,86 @@ def left(value: L) -> "Either[L, R]": def is_left(self) -> bool: return not self.is_right + + def map(self, f: Callable[[R], A]) -> "Either[L, A]": + """ + Apply a function to the Right value, leaving Left values unchanged. + """ + if self.is_right: + return Either.right(f(cast(R, self.value))) + return Either.left(cast(L, self.value)) + + def map_left(self, f: Callable[[L], A]) -> "Either[A, R]": + """ + Apply a function to the Left value, leaving Right values unchanged. + """ + if self.is_left(): + return Either.left(f(cast(L, self.value))) + return Either.right(cast(R, self.value)) + + def get_or_else(self, default: R) -> R: + """ + Get the Right value or a default if this is a Left. + """ + if self.is_right: + return cast(R, self.value) + return default + + def get_or_else_get(self, f: Callable[[], R]) -> R: + """ + Get the Right value or compute a default if this is a Left. + """ + if self.is_right: + return cast(R, self.value) + return f() + + def or_else(self, other: "Either[L, R]") -> "Either[L, R]": + """ + Return this Either if it's a Right, otherwise return the other Either. + """ + if self.is_right: + return self + return other + + def or_else_get(self, f: Callable[[], "Either[L, R]"]) -> "Either[L, R]": + """ + Return this Either if it's a Right, otherwise compute and return another Either. + """ + if self.is_right: + return self + return f() + + def contains(self, value: R) -> bool: + """ + Check if this Either is a Right and contains the given value. + """ + if not self.is_right: + return False + return cast(R, self.value) == value + + def exists(self, predicate: Callable[[R], bool]) -> bool: + """ + Check if this Either is a Right and its value satisfies the predicate. + """ + if not self.is_right: + return False + return predicate(cast(R, self.value)) + + def filter_or_else(self, predicate: Callable[[R], bool], zero: L) -> "Either[L, R]": + """ + Convert this Either to a Left if it's a Right and fails the predicate. + """ + if self.is_right: + right_value = cast(R, self.value) + if not predicate(right_value): + return Either.left(zero) + return self + return self + + def to_optional(self) -> Optional[R]: + """ + Convert this Either to an Optional, discarding the Left value. + """ + if self.is_right: + return cast(R, self.value) + return None diff --git a/main.py b/main.py index 9c0a378..7f2c708 100644 --- a/main.py +++ b/main.py @@ -15,12 +15,12 @@ def main(): level=get_env("DISTURBED_LOG_LEVEL", logging.INFO), ) - config = Configuration() - opsgenie_api = OpsgenieApi(api_key=get_env("DISTURBED_OPSGENIE_API_KEY")) - slack_api = SlackApi(token=get_env("DISTURBED_SLACK_API_TOKEN")) - - handler = ScheduleHandler(config, opsgenie_api, slack_api) - error = handler.handle_schedules() + handler = ScheduleHandler( + config=Configuration(), + opsgenie_api=OpsgenieApi(api_key=get_env("DISTURBED_OPSGENIE_API_KEY")), + slack_api=SlackApi(token=get_env("DISTURBED_SLACK_API_TOKEN")), + ) + error = handler.process() if error: logger.critical(error) sys.exit(1) diff --git a/poetry.lock b/poetry.lock index 1071408..2364b4e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "black" @@ -455,13 +455,13 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "slack-sdk" -version = "3.33.1" +version = "3.33.2" description = "The Slack API Platform SDK for Python" optional = false python-versions = ">=3.6" files = [ - {file = "slack_sdk-3.33.1-py2.py3-none-any.whl", hash = "sha256:ef93beec3ce9c8f64da02fd487598a05ec4bc9c92ceed58f122dbe632691cbe2"}, - {file = "slack_sdk-3.33.1.tar.gz", hash = "sha256:e328bb661d95db5f66b993b1d64288ac7c72201a745b4c7cf8848dafb7b74e40"}, + {file = "slack_sdk-3.33.2-py2.py3-none-any.whl", hash = "sha256:8107912488028f5a3f04ee9c58524418d3a2763a843db25530375b7733e6ec0a"}, + {file = "slack_sdk-3.33.2.tar.gz", hash = "sha256:34c51cfb9c254553219a0bba7a741c913f5d4d372d6278c3e7e10fe7bb667724"}, ] [package.extras] @@ -501,4 +501,4 @@ PyYAML = "*" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "fe4eac26a9b36b8e2043e619224516242d47946c7b702ec9806bce3b4cd422b5" +content-hash = "a086b757b9aacf7d10eb2230015b4ccdd8203e57368ff460e101a53f8fb04c3e" diff --git a/pyproject.toml b/pyproject.toml index 5078a02..238d7b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.12" requests = "^2.32.3" -slack-sdk = "^3.33.1" +slack-sdk = "^3.33.2" pyyaml = "^6.0.2" yamlcore = "^0.0.4"