Repository: fluxcd/notification-controller
Pull request #451 (2022-12-04 02:50:16)

feat: add gitea notifier
add support to send event notification to Gitea repo commit status

![image](https://user-images.githubusercontent.com/41882455/205472077-05b8073d-3760-4b5e-b36c-caed17d91703.png)

Gitea is a forge software package for hosting software development version control using Git as well as other collaborative features like bug tracking, code review, kanban boards, tickets, and wikis. It supports self-hosting but also provides a free public first-party instance

https://github.com/go-gitea/gitea

ttys3: the tests failed.  it complains about `config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml` which I added `gitea` 
kingdonb: I have an idea @ttys3 – it looks like you need to run `make generate` and commit the result. The error is a "dirty working tree" which just means that something in the e2e job runs `make generate` and you're supposed to already have run this.

I tried that locally but it only came up with half of the changes that failing test mentioned, the other one in `go.sum`

I guess that `make test` runs all of that

I found both the change in `go.sum` and in `notification[..]providers.yaml` like you mentioned. If you run the tests locally, you should get those changes in your working tree then you can commit them and e2e should hopefully pass after that 🎉 
ttys3: @kingdonb `make test` will remove `gitea` from config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml

`make generate` is right.

I need add `gitea` to api/v1beta1/provider_types.go, the yaml is generated from the annotation


ttys3: OK. fixed

PTAL


makkes: @ttys3 please squash the commits into one.
ttys3: squash done.


ttys3: @makkes added. PTAL

stefanprodan: @ttys3 this need to be ported to the v1beta2 API, we've deprecated v1beta1 in the main branch and that version is now freezed. 

Patch:
diff --git a/api/v1beta2/provider_types.go b/api/v1beta2/provider_types.go
index bd0e5cc..def7409 100644
--- a/api/v1beta2/provider_types.go
+++ b/api/v1beta2/provider_types.go
@@ -35,6 +35,7 @@ const (
 	GitHubDispatchProvider string = "githubdispatch"
 	GitHubProvider         string = "github"
 	GitLabProvider         string = "gitlab"
+	GiteaProvider          string = "gitea"
 	BitbucketProvider      string = "bitbucket"
 	AzureDevOpsProvider    string = "azuredevops"
 	GoogleChatProvider     string = "googlechat"
@@ -51,7 +52,7 @@ const (
 // ProviderSpec defines the desired state of the Provider.
 type ProviderSpec struct {
 	// Type specifies which Provider implementation to use.
-	// +kubebuilder:validation:Enum=slack;discord;msteams;rocket;generic;generic-hmac;github;gitlab;bitbucket;azuredevops;googlechat;webex;sentry;azureeventhub;telegram;lark;matrix;opsgenie;alertmanager;grafana;githubdispatch;
+	// +kubebuilder:validation:Enum=slack;discord;msteams;rocket;generic;generic-hmac;github;gitlab;gitea;bitbucket;azuredevops;googlechat;webex;sentry;azureeventhub;telegram;lark;matrix;opsgenie;alertmanager;grafana;githubdispatch;
 	// +required
 	Type string `json:"type"`
 
diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml
index c9c1d22..dabaa12 100644
--- a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml
+++ b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml
@@ -289,6 +289,7 @@ spec:
                 - generic-hmac
                 - github
                 - gitlab
+                - gitea
                 - bitbucket
                 - azuredevops
                 - googlechat
diff --git a/docs/spec/v1beta2/providers.md b/docs/spec/v1beta2/providers.md
index 37a5da4..9623be5 100644
--- a/docs/spec/v1beta2/providers.md
+++ b/docs/spec/v1beta2/providers.md
@@ -130,6 +130,7 @@ The supported providers for [Git commit status updates](#git-commit-status-updat
 | [Bitbucket](#bitbucket)       | `bitbucket`   |
 | [GitHub](#github)             | `github`      |
 | [GitLab](#gitlab)             | `gitlab`      |
+| [Gitea](#gitea)               | `gitea`       |
 
 #### Alerting
 
@@ -1291,6 +1292,19 @@ You can create the secret with `kubectl` like this:
 kubectl create secret generic gitlab-token --from-literal=token=<GITLAB-TOKEN>
 ```
 
+#### Gitea
+
+When `.spec.type` is set to `gitea`, the referenced secret must contain a key called `token` with the value set to a
+[Gitea token](https://docs.gitea.io/en-us/api-usage/#generating-and-listing-api-tokens).
+
+The token owner must have permissions to update the commit status for the Gitea repository specified in `.spec.address`.
+
+You can create the secret with `kubectl` like this:
+
+```shell
+kubectl create secret generic gitea-token --from-literal=token=<GITEA-TOKEN>
+```
+
 #### BitBucket
 
 When `.spec.type` is set to `bitbucket`, the referenced secret must contain a key called `token` with the value
diff --git a/go.mod b/go.mod
index d2ec378..36aab97 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.18
 replace github.com/fluxcd/notification-controller/api => ./api
 
 require (
+	code.gitea.io/sdk/gitea v0.15.1
 	github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8
 	github.com/Azure/azure-amqp-common-go/v3 v3.2.3
 	github.com/Azure/azure-event-hubs-go/v3 v3.3.20
@@ -99,6 +100,7 @@ require (
 	github.com/google/uuid v1.3.0 // indirect
 	github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+	github.com/hashicorp/go-version v1.6.0 // indirect
 	github.com/imdario/mergo v0.3.13 // indirect
 	github.com/inconshreveable/mousetrap v1.0.1 // indirect
 	github.com/josharian/intern v1.0.0 // indirect
diff --git a/go.sum b/go.sum
index e7e068f..9037c3f 100644
--- a/go.sum
+++ b/go.sum
@@ -44,6 +44,9 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
 cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
 cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
 cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
+code.gitea.io/gitea-vet v0.2.1/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE=
+code.gitea.io/sdk/gitea v0.15.1 h1:WJreC7YYuxbn0UDaPuWIe/mtiNKTvLN8MLkaw71yx/M=
+code.gitea.io/sdk/gitea v0.15.1/go.mod h1:klY2LVI3s3NChzIk/MzMn7G1FHrfU7qd63iSMVoHRBA=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
 github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8 h1:d+pBUmsteW5tM87xmVXHZ4+LibHRFn40SPAoZJOg2ak=
 github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8/go.mod h1:i9fr2JpcEcY/IHEvzCM3qXUZYOQHgR89dt4es1CgMhc=
@@ -344,6 +347,9 @@ github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxC
 github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
 github.com/hashicorp/go-retryablehttp v0.7.1 h1:sUiuQAnLlbvmExtFQs72iFW/HXeUn8Z1aJLQ4LJJbTQ=
 github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
+github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
+github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
@@ -839,6 +845,7 @@ golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapK
 golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
 golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
 golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
+golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
 golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
 golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
diff --git a/internal/notifier/factory.go b/internal/notifier/factory.go
index 98cf9e6..ec0fdd8 100644
--- a/internal/notifier/factory.go
+++ b/internal/notifier/factory.go
@@ -83,6 +83,8 @@ func (f Factory) Notifier(provider string) (Interface, error) {
 		n, err = NewGitHubDispatch(f.URL, f.Token, f.CertPool)
 	case apiv1.GitLabProvider:
 		n, err = NewGitLab(f.ProviderUID, f.URL, f.Token, f.CertPool)
+	case apiv1.GiteaProvider:
+		n, err = NewGitea(f.ProviderUID, f.URL, f.Token, f.CertPool)
 	case apiv1.BitbucketProvider:
 		n, err = NewBitbucket(f.ProviderUID, f.URL, f.Token, f.CertPool)
 	case apiv1.AzureDevOpsProvider:
diff --git a/internal/notifier/gitea.go b/internal/notifier/gitea.go
new file mode 100644
index 0000000..d382d57
--- /dev/null
+++ b/internal/notifier/gitea.go
@@ -0,0 +1,192 @@
+/*
+Copyright 2022 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package notifier
+
+import (
+	"context"
+	"crypto/tls"
+	"crypto/x509"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"os"
+	"strings"
+
+	"code.gitea.io/sdk/gitea"
+	eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1"
+	"github.com/fluxcd/pkg/apis/meta"
+	ctrl "sigs.k8s.io/controller-runtime"
+)
+
+type Gitea struct {
+	BaseURL     string
+	Token       string
+	Owner       string
+	Repo        string
+	ProviderUID string
+	Client      *gitea.Client
+	Debug       bool
+}
+
+var _ Interface = &Gitea{}
+
+func NewGitea(providerUID string, addr string, token string, certPool *x509.CertPool) (*Gitea, error) {
+	if len(token) == 0 {
+		return nil, errors.New("gitea token cannot be empty")
+	}
+
+	host, id, err := parseGitAddress(addr)
+	if err != nil {
+		return nil, err
+	}
+
+	if _, err := url.Parse(host); err != nil {
+		return nil, err
+	}
+
+	idComponents := strings.Split(id, "/")
+	if len(idComponents) != 2 {
+		return nil, fmt.Errorf("invalid repository id %q", id)
+	}
+
+	client, err := gitea.NewClient(host, gitea.SetToken(token))
+	if err != nil {
+		return nil, err
+	}
+
+	if certPool != nil {
+		tr := &http.Transport{
+			TLSClientConfig: &tls.Config{
+				RootCAs: certPool,
+			},
+		}
+		client.SetHTTPClient(&http.Client{Transport: tr})
+	}
+
+	return &Gitea{
+		BaseURL:     host,
+		Token:       token,
+		Owner:       idComponents[0],
+		Repo:        idComponents[1],
+		ProviderUID: providerUID,
+		Client:      client,
+		Debug:       os.Getenv("NOTIFIER_GITEA_DEBUG") == "true",
+	}, err
+}
+
+func (g *Gitea) Post(ctx context.Context, event eventv1.Event) error {
+	revString, ok := event.Metadata[eventv1.MetaRevisionKey]
+	if !ok {
+		return errors.New("missing revision metadata")
+	}
+	rev, err := parseRevision(revString)
+	if err != nil {
+		return err
+	}
+	state, err := toGiteaState(event)
+	if err != nil {
+		return err
+	}
+
+	_, desc := formatNameAndDescription(event)
+	id := generateCommitStatusID(g.ProviderUID, event)
+
+	status := gitea.CreateStatusOption{
+		State:       state,
+		TargetURL:   "",
+		Description: desc,
+		Context:     id,
+	}
+
+	listStatusesOpts := gitea.ListStatusesOption{
+		ListOptions: gitea.ListOptions{
+			Page:     0,
+			PageSize: 50,
+		},
+	}
+	statuses, _, err := g.Client.ListStatuses(g.Owner, g.Repo, rev, listStatusesOpts)
+	if err != nil {
+		return fmt.Errorf("could not list commit statuses: %w", err)
+	}
+	if duplicateGiteaStatus(statuses, &status) {
+		if g.Debug {
+			ctrl.Log.Info("gitea skip posting duplicate status",
+				"owner", g.Owner, "repo", g.Repo, "commit_hash", rev, "status", status)
+		}
+		return nil
+	}
+
+	if g.Debug {
+		ctrl.Log.Info("gitea create commit begin",
+			"base_url", g.BaseURL, "token", g.Token, "event", event, "status", status)
+	}
+
+	st, rsp, err := g.Client.CreateStatus(g.Owner, g.Repo, rev, status)
+	if err != nil {
+		if g.Debug {
+			ctrl.Log.Error(err, "gitea create commit failed", "status", status)
+		}
+		return err
+	}
+
+	if g.Debug {
+		ctrl.Log.Info("gitea create commit ok", "response", rsp, "response_status", st)
+	}
+
+	return nil
+}
+
+func toGiteaState(event eventv1.Event) (gitea.StatusState, error) {
+	// progressing events
+	if event.HasReason(meta.ProgressingReason) {
+		// pending
+		return gitea.StatusPending, nil
+	}
+	switch event.Severity {
+	case eventv1.EventSeverityInfo:
+		return gitea.StatusSuccess, nil
+	case eventv1.EventSeverityError:
+		return gitea.StatusFailure, nil
+	default:
+		return gitea.StatusError, errors.New("can't convert to gitea state")
+	}
+}
+
+// duplicateGiteaStatus return true if the latest status
+// with a matching context has the same state and description
+func duplicateGiteaStatus(statuses []*gitea.Status, status *gitea.CreateStatusOption) bool {
+	if status == nil || statuses == nil {
+		return false
+	}
+
+	for _, s := range statuses {
+		if s.Context == "" || s.State == "" || s.Description == "" {
+			continue
+		}
+
+		if s.Context == status.Context {
+			if s.State == status.State && s.Description == status.Description {
+				return true
+			}
+
+			return false
+		}
+	}
+
+	return false
+}


Test patch:
diff --git a/internal/notifier/gitea_test.go b/internal/notifier/gitea_test.go
new file mode 100644
index 0000000..d02610e
--- /dev/null
+++ b/internal/notifier/gitea_test.go
@@ -0,0 +1,71 @@
+/*
+Copyright 2022 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package notifier
+
+import (
+	"context"
+	eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNewGiteaBasic(t *testing.T) {
+	g, err := NewGitea("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://try.gitea.io/foo/bar", "foobar", nil)
+	assert.Nil(t, err)
+	assert.Equal(t, g.Owner, "foo")
+	assert.Equal(t, g.Repo, "bar")
+	assert.Equal(t, g.BaseURL, "https://try.gitea.io")
+}
+
+func TestNewGiteaInvalidUrl(t *testing.T) {
+	_, err := NewGitea("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://try.gitea.io/foo/bar/baz", "foobar", nil)
+	assert.NotNil(t, err)
+}
+
+func TestNewGiteaEmptyToken(t *testing.T) {
+	_, err := NewGitea("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://try.gitea.io/foo/bar", "", nil)
+	assert.NotNil(t, err)
+}
+
+func TestGitea_Post(t *testing.T) {
+	g, err := NewGitea("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://try.gitea.io/foo/bar", "foobar", nil)
+	assert.Nil(t, err)
+
+	event := eventv1.Event{
+		InvolvedObject: corev1.ObjectReference{
+			Kind:      "Kustomization",
+			Namespace: "flux-system",
+			Name:      "podinfo-repo",
+		},
+		Severity: "info",
+		Timestamp: metav1.Time{
+			Time: time.Now(),
+		},
+		Metadata: map[string]string{
+			eventv1.MetaRevisionKey: "main/1234567890",
+		},
+		Message: "Service/podinfo/podinfo configured",
+		Reason:  "",
+	}
+	err = g.Post(context.Background(), event)
+	assert.NotNil(t, err)
+	assert.ErrorContains(t, err, "404 Not Found")
+}