diff --git a/README.md b/README.md index 233528c62..7af31ee62 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga - [Tunneling](#tunneling) - [Alerting](#alerting) - [Configuring AWS SES alerts](#configuring-aws-ses-alerts) + - [Configuring ClickUp alerts](#configuring-clickup-alerts) - [Configuring Datadog alerts](#configuring-datadog-alerts) - [Configuring Discord alerts](#configuring-discord-alerts) - [Configuring Email alerts](#configuring-email-alerts) @@ -827,6 +828,7 @@ endpoints: | Parameter | Description | Default | |:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------| | `alerting.awsses` | Configuration for alerts of type `awsses`.
See [Configuring AWS SES alerts](#configuring-aws-ses-alerts). | `{}` | +| `alerting.clickup` | Configuration for alerts of type `clickup`.
See [Configuring ClickUp alerts](#configuring-clickup-alerts). | `{}` | | `alerting.custom` | Configuration for custom actions on failure or alerts.
See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` | | `alerting.datadog` | Configuration for alerts of type `datadog`.
See [Configuring Datadog alerts](#configuring-datadog-alerts). | `{}` | | `alerting.discord` | Configuration for alerts of type `discord`.
See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` | @@ -908,6 +910,72 @@ If the `access-key-id` and `secret-access-key` are not defined Gatus will fall b Make sure you have the ability to use `ses:SendEmail`. +#### Configuring ClickUp alerts + +| Parameter | Description | Default | +| :--------------------------------- | :----------------------------------------------------------------------------------------- | :------------ | +| `alerting.clickup` | Configuration for alerts of type `clickup` | `{}` | +| `alerting.clickup.list-id` | ClickUp List ID where tasks will be created | Required `""` | +| `alerting.clickup.token` | ClickUp API token | Required `""` | +| `alerting.clickup.api-url` | Custom API URL | `https://api.clickup.com/api/v2` | +| `alerting.clickup.assignees` | List of user IDs to assign tasks to | `[]` | +| `alerting.clickup.status` | Initial status for created tasks | `""` | +| `alerting.clickup.priority` | Priority level: `urgent`, `high`, `normal`, `low`, or `none` | `normal` | +| `alerting.clickup.notify-all` | Whether to notify all assignees when task is created | `true` | +| `alerting.clickup.name` | Custom task name template (supports placeholders) | `Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]` | +| `alerting.clickup.content` | Custom task content template (supports placeholders) | `Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]` | +| `alerting.clickup.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.clickup.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.clickup.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.clickup.overrides[].*` | See `alerting.clickup.*` parameters | `{}` | + +The ClickUp alerting provider creates tasks in a ClickUp list when alerts are triggered. If `send-on-resolved` is set to `true` on the endpoint alert, the task will be automatically closed when the alert is resolved. + +The following placeholders are supported in `name` and `content`: + +- `[ENDPOINT_GROUP]` - Resolved from `endpoints[].group` +- `[ENDPOINT_NAME]` - Resolved from `endpoints[].name` +- `[ALERT_DESCRIPTION]` - Resolved from `endpoints[].alerts[].description` +- `[RESULT_ERRORS]` - Resolved from the health evaluation errors + +```yaml +alerting: + clickup: + list-id: "123456789" + token: "pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + assignees: + - "12345" + - "67890" + status: "in progress" + priority: high + name: "Health Check Alert: [ENDPOINT_GROUP] - [ENDPOINT_NAME]" + content: "Alert triggered for [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + alerts: + - type: clickup + send-on-resolved: true +``` + +To get your ClickUp API token follow: [Generate or regenerate a Personal API Token](https://developer.clickup.com/docs/authentication#:~:text=the%20API%20docs.-,Generate%20or%20regenerate%20a%20Personal%20API%20Token,-Log%20in%20to) + +To find your List ID: + +1. Open the ClickUp list where you want tasks to be created +2. The List ID is in the URL: `https://app.clickup.com/{workspace_id}/v/l/li/{list_id}` + +To find Assignee IDs: + +1. Go to `https://app.clickup.com/{workspace_id}/teams-pulse/teams/people` +2. Hover over a team member +3. Click the 3 dots (overflow menu) +3. Click `Copy member ID` + #### Configuring Datadog alerts > ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. diff --git a/alerting/alert/type.go b/alerting/alert/type.go index 43bd24879..6a5759779 100644 --- a/alerting/alert/type.go +++ b/alerting/alert/type.go @@ -8,6 +8,9 @@ const ( // TypeAWSSES is the Type for the awsses alerting provider TypeAWSSES Type = "aws-ses" + // TypeClickUp is the Type for the clickup alerting provider + TypeClickUp Type = "clickup" + // TypeCustom is the Type for the custom alerting provider TypeCustom Type = "custom" diff --git a/alerting/config.go b/alerting/config.go index 65150396b..923d6cbd8 100644 --- a/alerting/config.go +++ b/alerting/config.go @@ -7,6 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/provider" "github.com/TwiN/gatus/v5/alerting/provider/awsses" + "github.com/TwiN/gatus/v5/alerting/provider/clickup" "github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/discord" @@ -54,6 +55,9 @@ type Config struct { // AWSSimpleEmailService is the configuration for the aws-ses alerting provider AWSSimpleEmailService *awsses.AlertProvider `yaml:"aws-ses,omitempty"` + // ClickUp is the configuration for the clickup alerting provider + ClickUp *clickup.AlertProvider `yaml:"clickup,omitempty"` + // Custom is the configuration for the custom alerting provider Custom *custom.AlertProvider `yaml:"custom,omitempty"` diff --git a/alerting/provider/clickup/clickup.go b/alerting/provider/clickup/clickup.go new file mode 100644 index 000000000..2552c21b2 --- /dev/null +++ b/alerting/provider/clickup/clickup.go @@ -0,0 +1,285 @@ +package clickup + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +var ( + ErrListIDNotSet = errors.New("list-id not set") + ErrTokenNotSet = errors.New("token not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") + ErrInvalidPriority = errors.New("priority must be one of: urgent, high, normal, low, none") +) + +var priorityMap = map[string]int{ + "urgent": 1, + "high": 2, + "normal": 3, + "low": 4, + "none": 0, +} + +type Config struct { + APIURL string `yaml:"api-url"` + ListID string `yaml:"list-id"` + Token string `yaml:"token"` + Assignees []string `yaml:"assignees"` + Status string `yaml:"status"` + Priority string `yaml:"priority"` + NotifyAll *bool `yaml:"notify-all,omitempty"` + Name string `yaml:"name,omitempty"` + MarkdownContent string `yaml:"content,omitempty"` +} + +func (cfg *Config) Validate() error { + if cfg.ListID == "" { + return ErrListIDNotSet + } + if cfg.Token == "" { + return ErrTokenNotSet + } + if cfg.Priority == "" { + cfg.Priority = "normal" + } + if _, ok := priorityMap[cfg.Priority]; !ok { + return ErrInvalidPriority + } + if cfg.NotifyAll == nil { + defaultNotifyAll := true + cfg.NotifyAll = &defaultNotifyAll + } + if cfg.APIURL == "" { + cfg.APIURL = "https://api.clickup.com/api/v2" + } + if cfg.Name == "" { + cfg.Name = "Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]" + } + if cfg.MarkdownContent == "" { + cfg.MarkdownContent = "Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]" + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if override.APIURL != "" { + cfg.APIURL = override.APIURL + } + if override.ListID != "" { + cfg.ListID = override.ListID + } + if override.Token != "" { + cfg.Token = override.Token + } + if override.Status != "" { + cfg.Status = override.Status + } + if override.Priority != "" { + cfg.Priority = override.Priority + } + if override.NotifyAll != nil { + cfg.NotifyAll = override.NotifyAll + } + if len(override.Assignees) > 0 { + cfg.Assignees = override.Assignees + } + if override.Name != "" { + cfg.Name = override.Name + } + if override.MarkdownContent != "" { + cfg.MarkdownContent = override.MarkdownContent + } +} + +// AlertProvider is the configuration necessary for sending an alert using ClickUp +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default configuration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + if resolved { + return provider.CloseTask(cfg, ep) + } + // Replace placeholders + name := strings.ReplaceAll(cfg.Name, "[ENDPOINT_GROUP]", ep.Group) + name = strings.ReplaceAll(name, "[ENDPOINT_NAME]", ep.Name) + markdownContent := strings.ReplaceAll(cfg.MarkdownContent, "[ENDPOINT_GROUP]", ep.Group) + markdownContent = strings.ReplaceAll(markdownContent, "[ENDPOINT_NAME]", ep.Name) + markdownContent = strings.ReplaceAll(markdownContent, "[ALERT_DESCRIPTION]", alert.GetDescription()) + markdownContent = strings.ReplaceAll(markdownContent, "[RESULT_ERRORS]", strings.Join(result.Errors, ", ")) + body := map[string]interface{}{ + "name": name, + "markdown_content": markdownContent, + "assignees": cfg.Assignees, + "status": cfg.Status, + "notify_all": *cfg.NotifyAll, + } + if cfg.Priority != "none" { + body["priority"] = priorityMap[cfg.Priority] + } + return provider.CreateTask(cfg, body) +} + +func (provider *AlertProvider) CreateTask(cfg *Config, body map[string]interface{}) error { + jsonBody, err := json.Marshal(body) + if err != nil { + return err + } + createURL := fmt.Sprintf("%s/list/%s/task", cfg.APIURL, cfg.ListID) + req, err := http.NewRequest("POST", createURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", cfg.Token) + httpClient := client.GetHTTPClient(nil) + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("failed to create task, status: %d", resp.StatusCode) + } + return nil +} + +func (provider *AlertProvider) CloseTask(cfg *Config, ep *endpoint.Endpoint) error { + fetchURL := fmt.Sprintf("%s/list/%s/task?include_closed=false", cfg.APIURL, cfg.ListID) + req, err := http.NewRequest("GET", fetchURL, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", cfg.Token) + httpClient := client.GetHTTPClient(nil) + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("failed to fetch tasks, status: %d", resp.StatusCode) + } + var fetchResponse struct { + Tasks []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"tasks"` + } + if err := json.NewDecoder(resp.Body).Decode(&fetchResponse); err != nil { + return err + } + var matchingTaskIDs []string + for _, task := range fetchResponse.Tasks { + if strings.Contains(task.Name, ep.Group) && strings.Contains(task.Name, ep.Name) { + matchingTaskIDs = append(matchingTaskIDs, task.ID) + } + } + if len(matchingTaskIDs) == 0 { + return fmt.Errorf("no matching tasks found for %s:%s", ep.Group, ep.Name) + } + for _, taskID := range matchingTaskIDs { + if err := provider.UpdateTaskStatus(cfg, taskID, "closed"); err != nil { + return fmt.Errorf("failed to close task %s: %v", taskID, err) + } + } + return nil +} + +func (provider *AlertProvider) UpdateTaskStatus(cfg *Config, taskID, status string) error { + updateURL := fmt.Sprintf("%s/task/%s", cfg.APIURL, taskID) + body := map[string]interface{}{"status": status} + jsonBody, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequest("PUT", updateURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", cfg.Token) + httpClient := client.GetHTTPClient(nil) + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("failed to update task %s, status: %d", taskID, resp.StatusCode) + } + return nil +} + +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/clickup/clickup_test.go b/alerting/provider/clickup/clickup_test.go new file mode 100644 index 000000000..0f2683b4d --- /dev/null +++ b/alerting/provider/clickup/clickup_test.go @@ -0,0 +1,310 @@ +package clickup + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/test" +) + +func TestAlertProvider_Validate(t *testing.T) { + invalidProviderNoListID := AlertProvider{DefaultConfig: Config{ListID: "", Token: "test-token"}} + if err := invalidProviderNoListID.Validate(); err == nil { + t.Error("provider shouldn't have been valid without list-id") + } + invalidProviderNoToken := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: ""}} + if err := invalidProviderNoToken.Validate(); err == nil { + t.Error("provider shouldn't have been valid without token") + } + invalidProviderBadPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "invalid"}} + if err := invalidProviderBadPriority.Validate(); err == nil { + t.Error("provider shouldn't have been valid with invalid priority") + } + validProvider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}} + if err := validProvider.Validate(); err != nil { + t.Error("provider should've been valid") + } + if validProvider.DefaultConfig.Priority != "normal" { + t.Errorf("expected default priority to be 'normal', got '%s'", validProvider.DefaultConfig.Priority) + } + validProviderWithAPIURL := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", APIURL: "https://api.clickup.com/api/v2"}} + if err := validProviderWithAPIURL.Validate(); err != nil { + t.Error("provider should've been valid") + } + validProviderWithPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"}} + if err := validProviderWithPriority.Validate(); err != nil { + t.Error("provider should've been valid with priority 'urgent'") + } + validProviderWithNone := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"}} + if err := validProviderWithNone.Validate(); err != nil { + t.Error("provider should've been valid with priority 'none'") + } +} + +func TestAlertProvider_ValidateSetsDefaultAPIURL(t *testing.T) { + provider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}} + if err := provider.Validate(); err != nil { + t.Error("provider should've been valid") + } + if provider.DefaultConfig.APIURL != "https://api.clickup.com/api/v2" { + t.Errorf("expected APIURL to be set to default, got %s", provider.DefaultConfig.APIURL) + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Endpoint endpoint.Endpoint + Alert alert.Alert + Resolved bool + MockRoundTripper test.MockRoundTripper + ExpectedError bool + }{ + { + Name: "triggered", + Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}, + Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + if r.Method == "POST" && r.URL.Path == "/api/v2/list/test-list-id/task" { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + } + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: false, + }, + { + Name: "triggered-error", + Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}, + Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: true, + }, + { + Name: "resolved", + Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}, + Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + if r.Method == "GET" { + // Mock fetch tasks response + tasksResponse := map[string]interface{}{ + "tasks": []map[string]interface{}{ + { + "id": "task-123", + "name": "Health Check: endpoint-group:endpoint-name", + }, + }, + } + body, _ := json.Marshal(tasksResponse) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + } + } + if r.Method == "PUT" { + // Mock update task status response + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + } + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: false, + }, + { + Name: "resolved-no-matching-tasks", + Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}, + Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + if r.Method == "GET" { + // Mock fetch tasks response with no matching tasks + tasksResponse := map[string]interface{}{ + "tasks": []map[string]interface{}{}, + } + body, _ := json.Marshal(tasksResponse) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + } + } + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: true, + }, + { + Name: "resolved-error-fetching-tasks", + Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}, + Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: true, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) + err := scenario.Provider.Send( + &scenario.Endpoint, + &scenario.Alert, + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + }, + Errors: []string{"error1", "error2"}, + }, + scenario.Resolved, + ) + if scenario.ExpectedError && err == nil { + t.Error("expected error, got none") + } + if !scenario.ExpectedError && err != nil { + t.Error("expected no error, got", err.Error()) + } + }) + } +} + +func TestAlertProvider_GetDefaultAlert(t *testing.T) { + if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { + t.Error("expected default alert to be not nil") + } + if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { + t.Error("expected default alert to be nil") + } +} + +func TestAlertProvider_GetConfig(t *testing.T) { + scenarios := []struct { + Name string + Provider AlertProvider + InputGroup string + InputAlert alert.Alert + ExpectedOutput Config + }{ + { + Name: "provider-no-override-should-default", + Provider: AlertProvider{ + DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}, + }, + InputGroup: "", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "normal"}, + }, + { + Name: "provider-with-alert-override-should-override", + Provider: AlertProvider{ + DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}, + }, + InputGroup: "", + InputAlert: alert.Alert{ProviderOverride: map[string]any{ + "list-id": "override-list-id", + "token": "override-token", + }}, + ExpectedOutput: Config{ListID: "override-list-id", Token: "override-token", Priority: "normal"}, + }, + { + Name: "provider-with-partial-alert-override-should-merge", + Provider: AlertProvider{ + DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Status: "in progress"}, + }, + InputGroup: "", + InputAlert: alert.Alert{ProviderOverride: map[string]any{ + "status": "closed", + }}, + ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Status: "closed", Priority: "normal"}, + }, + { + Name: "provider-with-assignees-override", + Provider: AlertProvider{ + DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}, + }, + InputGroup: "", + InputAlert: alert.Alert{ProviderOverride: map[string]any{ + "assignees": []string{"user1", "user2"}, + }}, + ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Assignees: []string{"user1", "user2"}, Priority: "normal"}, + }, + { + Name: "provider-with-priority-override", + Provider: AlertProvider{ + DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}, + }, + InputGroup: "", + InputAlert: alert.Alert{ProviderOverride: map[string]any{ + "priority": "urgent", + }}, + ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"}, + }, + { + Name: "provider-with-none-priority", + Provider: AlertProvider{ + DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}, + }, + InputGroup: "", + InputAlert: alert.Alert{ProviderOverride: map[string]any{ + "priority": "none", + }}, + ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"}, + }, + { + Name: "provider-with-group-override", + Provider: AlertProvider{ + DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}, + Overrides: []Override{ + {Group: "core", Config: Config{ListID: "core-list-id", Priority: "urgent"}}, + }, + }, + InputGroup: "core", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{ListID: "core-list-id", Token: "test-token", Priority: "urgent"}, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if got.ListID != scenario.ExpectedOutput.ListID { + t.Errorf("expected ListID to be %s, got %s", scenario.ExpectedOutput.ListID, got.ListID) + } + if got.Token != scenario.ExpectedOutput.Token { + t.Errorf("expected Token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token) + } + if got.Status != scenario.ExpectedOutput.Status { + t.Errorf("expected Status to be %s, got %s", scenario.ExpectedOutput.Status, got.Status) + } + if got.Priority != scenario.ExpectedOutput.Priority { + t.Errorf("expected Priority to be %s, got %s", scenario.ExpectedOutput.Priority, got.Priority) + } + if len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) { + t.Errorf("expected Assignees length to be %d, got %d", len(scenario.ExpectedOutput.Assignees), len(got.Assignees)) + } + // Test ValidateOverrides as well, since it really just calls GetConfig + if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil { + t.Errorf("unexpected error: %s", err) + } + }) + } +} diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go index 64084e203..35beb92c3 100644 --- a/alerting/provider/provider.go +++ b/alerting/provider/provider.go @@ -3,6 +3,7 @@ package provider import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/provider/awsses" + "github.com/TwiN/gatus/v5/alerting/provider/clickup" "github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/discord" @@ -92,6 +93,7 @@ func MergeProviderDefaultAlertIntoEndpointAlert(providerDefaultAlert, endpointAl var ( // Validate provider interface implementation on compile _ AlertProvider = (*awsses.AlertProvider)(nil) + _ AlertProvider = (*clickup.AlertProvider)(nil) _ AlertProvider = (*custom.AlertProvider)(nil) _ AlertProvider = (*datadog.AlertProvider)(nil) _ AlertProvider = (*discord.AlertProvider)(nil) @@ -133,6 +135,7 @@ var ( // Validate config interface implementation on compile _ Config[awsses.Config] = (*awsses.Config)(nil) + _ Config[clickup.Config] = (*clickup.Config)(nil) _ Config[custom.Config] = (*custom.Config)(nil) _ Config[datadog.Config] = (*datadog.Config)(nil) _ Config[discord.Config] = (*discord.Config)(nil) diff --git a/config/config.go b/config/config.go index 4d9724b54..1597e9ceb 100644 --- a/config/config.go +++ b/config/config.go @@ -594,6 +594,7 @@ func ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi } alertTypes := []alert.Type{ alert.TypeAWSSES, + alert.TypeClickUp, alert.TypeCustom, alert.TypeDatadog, alert.TypeDiscord, diff --git a/config/config_test.go b/config/config_test.go index 811f0ed1c..216cd4ec4 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -12,6 +12,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/provider" "github.com/TwiN/gatus/v5/alerting/provider/awsses" + "github.com/TwiN/gatus/v5/alerting/provider/clickup" "github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/discord" @@ -1854,6 +1855,7 @@ func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) { func TestGetAlertingProviderByAlertType(t *testing.T) { alertingConfig := &alerting.Config{ AWSSimpleEmailService: &awsses.AlertProvider{}, + ClickUp: &clickup.AlertProvider{}, Custom: &custom.AlertProvider{}, Datadog: &datadog.AlertProvider{}, Discord: &discord.AlertProvider{}, @@ -1898,6 +1900,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { expected provider.AlertProvider }{ {alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService}, + {alertType: alert.TypeClickUp, expected: alertingConfig.ClickUp}, {alertType: alert.TypeCustom, expected: alertingConfig.Custom}, {alertType: alert.TypeDatadog, expected: alertingConfig.Datadog}, {alertType: alert.TypeDiscord, expected: alertingConfig.Discord}, diff --git a/watchdog/alerting_test.go b/watchdog/alerting_test.go index 2c9ccd922..7c0fbad54 100644 --- a/watchdog/alerting_test.go +++ b/watchdog/alerting_test.go @@ -7,6 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting" "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/alerting/provider/clickup" "github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/discord" @@ -506,6 +507,18 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) { }, }, }, + { + Name: "clickup", + AlertType: alert.TypeClickUp, + AlertingConfig: &alerting.Config{ + ClickUp: &clickup.AlertProvider{ + DefaultConfig: clickup.Config{ + ListID: "test-list-id", + Token: "test-token", + }, + }, + }, + }, } for _, scenario := range scenarios {