feat(alerting): implement Gitea alerting provider (#842)

* feat: implement Gitea alerting provider integration

- Add TypeGitea for the gitea alerting provider
- Introduce a new file for the gitea alerting provider implementation
- Implement the AlertProvider struct with necessary fields for gitea integration
- Add validation logic for the AlertProvider configuration
- Create tests for the AlertProvider's validation and sending functionality
- Update go.mod to include the gitea SDK as a dependency
- Modify the alerting configuration validation to recognize TypeGitea

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* chore: integrate Gitea alerting provider configuration

- Add Gitea alerting provider import to the configuration file
- Update the comment for the RepositoryURL field to reflect Gitea instead of GitHub

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* feat: add Assignees support to AlertProvider functionality

- Add a field for Assignees to the AlertProvider struct
- Update the Send function to include Assignees in the alert payload

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* feat: implement Gitea alerting configuration and documentation

- Add a new image asset for Gitea alerts
- Update the README to include configuration details for Gitea alerts
- Introduce parameters for Gitea alerting, including repository URL and personal access token
- Document the behavior of the Gitea alerting provider regarding issue creation and resolution
- Include an example YAML configuration for Gitea alerts

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* Update README.md

Co-authored-by: TwiN <twin@linux.com>

* Update README.md

Co-authored-by: TwiN <twin@linux.com>

* Update README.md

Co-authored-by: TwiN <twin@linux.com>

* feat: refactor AlertProvider for improved client configuration

- Add import for the Gatus client library
- Remove the SkipVerify field from the AlertProvider struct
- Introduce ClientConfig field in the AlertProvider struct for client configuration
- Update validation logic to check for ClientConfig instead of SkipVerify

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* Update README.md

Co-authored-by: TwiN <twin@linux.com>

* chore: update configuration for Gitea integration

- Change references from GitHub to Gitea in the configuration section
- Update alerting provider descriptions to reflect the correct platform
- Swap the order of GitHub and Gitea configurations
- Replace Gitea alert image with GitHub alert image
- Adjust the type field from gitea to github in the relevant sections

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* fix: ensure ClientConfig is validated and defaults set

- Add a check for nil ClientConfig in the IsValid function
- Set ClientConfig to a default configuration if it is nil

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

---------

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
This commit is contained in:
Bo-Yi Wu
2024-08-22 05:51:45 +08:00
committed by GitHub
parent dd435a8eaf
commit 7de5a1fe48
10 changed files with 398 additions and 2 deletions

View File

@ -0,0 +1,167 @@
package gitea
import (
"crypto/tls"
"fmt"
"net/http"
"net/url"
"strings"
"code.gitea.io/sdk/gitea"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// AlertProvider is the configuration necessary for sending an alert using Discord
type AlertProvider struct {
RepositoryURL string `yaml:"repository-url"` // The URL of the Gitea repository to create issues in
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// Assignees is a list of users to assign the issue to
Assignees []string `yaml:"assignees,omitempty"`
username string
repositoryOwner string
repositoryName string
giteaClient *gitea.Client
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 {
return false
}
// Validate format of the repository URL
repositoryURL, err := url.Parse(provider.RepositoryURL)
if err != nil {
return false
}
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 {
return false
}
provider.repositoryOwner = pathParts[1]
provider.repositoryName = pathParts[2]
opts := []gitea.ClientOption{
gitea.SetToken(provider.Token),
}
if provider.ClientConfig != nil && provider.ClientConfig.Insecure {
// add new http client for skip verify
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
opts = append(opts, gitea.SetHTTPClient(httpClient))
}
provider.giteaClient, err = gitea.NewClient(baseURL, opts...)
if err != nil {
return false
}
user, _, err := provider.giteaClient.GetMyUserInfo()
if err != nil {
return false
}
provider.username = user.UserName
return true
}
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
title := "alert(gatus): " + ep.DisplayName()
if !resolved {
_, _, err := provider.giteaClient.CreateIssue(
provider.repositoryOwner,
provider.repositoryName,
gitea.CreateIssueOption{
Title: title,
Body: provider.buildIssueBody(ep, alert, result),
Assignees: provider.Assignees,
},
)
if err != nil {
return fmt.Errorf("failed to create issue: %w", err)
}
return nil
}
issues, _, err := provider.giteaClient.ListRepoIssues(
provider.repositoryOwner,
provider.repositoryName,
gitea.ListIssueOption{
State: gitea.StateOpen,
CreatedBy: provider.username,
ListOptions: gitea.ListOptions{
Page: 100,
},
},
)
if err != nil {
return fmt.Errorf("failed to list issues: %w", err)
}
for _, issue := range issues {
if issue.Title == title {
stateClosed := gitea.StateClosed
_, _, err = provider.giteaClient.EditIssue(
provider.repositoryOwner,
provider.repositoryName,
issue.ID,
gitea.EditIssueOption{
State: &stateClosed,
},
)
if err != nil {
return fmt.Errorf("failed to close issue: %w", err)
}
}
}
return nil
}
// buildIssueBody builds the body of the issue
func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result) string {
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\n## Condition results\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
}
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
return message + description + formattedConditionResults
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@ -0,0 +1,169 @@
package gitea
import (
"net/http"
"strings"
"testing"
"code.gitea.io/sdk/gitea"
"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 TestAlertDefaultProvider_IsValid(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
Expected bool
}{
{
Name: "invalid",
Provider: AlertProvider{RepositoryURL: "", Token: ""},
Expected: false,
},
{
Name: "invalid-token",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
Expected: false,
},
{
Name: "missing-repository-name",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"},
Expected: false,
},
{
Name: "enterprise-client",
Provider: AlertProvider{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"},
Expected: false,
},
{
Name: "invalid-url",
Provider: AlertProvider{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"},
Expected: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if scenario.Provider.IsValid() != scenario.Expected {
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered-error",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedError: true,
},
{
Name: "resolved-error",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Provider.giteaClient, _ = gitea.NewClient("https://gitea.com")
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
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_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
scenarios := []struct {
Name string
Endpoint endpoint.Endpoint
Provider AlertProvider
Alert alert.Alert
NoConditions bool
ExpectedBody string
}{
{
Name: "triggered",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
},
{
Name: "triggered-with-no-description",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
},
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\n> description-1",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false},
}
}
body := scenario.Provider.buildIssueBody(
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
)
if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
})
}
}
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")
}
}

View File

@ -6,6 +6,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
@ -66,6 +67,7 @@ var (
_ AlertProvider = (*email.AlertProvider)(nil)
_ AlertProvider = (*github.AlertProvider)(nil)
_ AlertProvider = (*gitlab.AlertProvider)(nil)
_ AlertProvider = (*gitea.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil)
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil)