summaryrefslogtreecommitdiffstats
path: root/services/webhook
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
commitdd136858f1ea40ad3c94191d647487fa4f31926c (patch)
tree58fec94a7b2a12510c9664b21793f1ed560c6518 /services/webhook
parentInitial commit. (diff)
downloadforgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.tar.xz
forgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.zip
Adding upstream version 9.0.0.HEADupstream/9.0.0upstreamdebian
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r--services/webhook/default.go160
-rw-r--r--services/webhook/default_test.go260
-rw-r--r--services/webhook/deliver.go258
-rw-r--r--services/webhook/deliver_test.go332
-rw-r--r--services/webhook/dingtalk.go232
-rw-r--r--services/webhook/dingtalk_test.go252
-rw-r--r--services/webhook/discord.go367
-rw-r--r--services/webhook/discord_test.go348
-rw-r--r--services/webhook/feishu.go200
-rw-r--r--services/webhook/feishu_test.go193
-rw-r--r--services/webhook/general.go354
-rw-r--r--services/webhook/general_test.go673
-rw-r--r--services/webhook/gogs.go42
-rw-r--r--services/webhook/main_test.go26
-rw-r--r--services/webhook/matrix.go316
-rw-r--r--services/webhook/matrix_test.go255
-rw-r--r--services/webhook/msteams.go377
-rw-r--r--services/webhook/msteams_test.go455
-rw-r--r--services/webhook/notifier.go887
-rw-r--r--services/webhook/packagist.go90
-rw-r--r--services/webhook/packagist_test.go70
-rw-r--r--services/webhook/shared/img.go15
-rw-r--r--services/webhook/shared/payloader.go161
-rw-r--r--services/webhook/slack.go361
-rw-r--r--services/webhook/slack_test.go265
-rw-r--r--services/webhook/sourcehut/builds.go301
-rw-r--r--services/webhook/sourcehut/builds_test.go386
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/HEAD1
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/config4
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/description1
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/info/exclude6
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/objects/3c/3d4b799b3933ba687b263eeef2034300a5315ebin0 -> 83 bytes
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/objects/58/771003157b81abc6bf41df0c5db4147a3e3c832
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/objects/69/b217caa89166a02b8cd368b64fb83a44720e141
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/objects/99/fb389b232e5497f0dcdb1c1065eac1d10d3794bin0 -> 57 bytes
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/objects/9e/4b777f81b316a1c75a0797b33add68ee49b0d0bin0 -> 54 bytes
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/objects/a5/4082fdb8e55055382725f10a81bb4dc2b130294
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/objects/aa/3905af404394f576f88f00e7f0919b4b97453fbin0 -> 57 bytes
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/objects/b0/404943256a1f5a50c3726f4378756b4c1e5704bin0 -> 160 bytes
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/objects/d2/e0862c8b8097ba4bdd72946c20479751d307a04
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/refs/heads/main1
-rw-r--r--services/webhook/telegram.go228
-rw-r--r--services/webhook/telegram_test.go212
-rw-r--r--services/webhook/webhook.go270
-rw-r--r--services/webhook/webhook_test.go100
-rw-r--r--services/webhook/wechatwork.go210
46 files changed, 8680 insertions, 0 deletions
diff --git a/services/webhook/default.go b/services/webhook/default.go
new file mode 100644
index 0000000..089ff8b
--- /dev/null
+++ b/services/webhook/default.go
@@ -0,0 +1,160 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "net/http"
+ "net/url"
+ "strings"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/svg"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/webhook/shared"
+)
+
+var _ Handler = defaultHandler{}
+
+type defaultHandler struct {
+ forgejo bool
+}
+
+func (dh defaultHandler) Type() webhook_module.HookType {
+ if dh.forgejo {
+ return webhook_module.FORGEJO
+ }
+ return webhook_module.GITEA
+}
+
+func (dh defaultHandler) Icon(size int) template.HTML {
+ if dh.forgejo {
+ // forgejo.svg is not in web_src/svg/, so svg.RenderHTML does not work
+ return shared.ImgIcon("forgejo.svg", size)
+ }
+ return svg.RenderHTML("gitea-gitea", size, "img")
+}
+
+func (defaultHandler) Metadata(*webhook_model.Webhook) any { return nil }
+
+func (defaultHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
+ var form struct {
+ forms.WebhookCoreForm
+ PayloadURL string `binding:"Required;ValidUrl"`
+ HTTPMethod string `binding:"Required;In(POST,GET)"`
+ ContentType int `binding:"Required"`
+ Secret string
+ }
+ bind(&form)
+
+ contentType := webhook_model.ContentTypeJSON
+ if webhook_model.HookContentType(form.ContentType) == webhook_model.ContentTypeForm {
+ contentType = webhook_model.ContentTypeForm
+ }
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: form.PayloadURL,
+ ContentType: contentType,
+ Secret: form.Secret,
+ HTTPMethod: form.HTTPMethod,
+ Metadata: nil,
+ }
+}
+
+func (defaultHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (req *http.Request, body []byte, err error) {
+ payloadContent := t.PayloadContent
+ if w.Type == webhook_module.GITEA &&
+ (t.EventType == webhook_module.HookEventCreate || t.EventType == webhook_module.HookEventDelete) {
+ // Woodpecker expects the ref to be short on tag creation only
+ // https://github.com/woodpecker-ci/woodpecker/blob/00ccec078cdced80cf309cd4da460a5041d7991a/server/forge/gitea/helper.go#L134
+ // see https://codeberg.org/codeberg/community/issues/1556
+ payloadContent, err = substituteRefShortName(payloadContent)
+ if err != nil {
+ return nil, nil, fmt.Errorf("could not substitute ref: %w", err)
+ }
+ }
+
+ switch w.HTTPMethod {
+ case "":
+ log.Info("HTTP Method for %s webhook %s [ID: %d] is not set, defaulting to POST", w.Type, w.URL, w.ID)
+ fallthrough
+ case http.MethodPost:
+ switch w.ContentType {
+ case webhook_model.ContentTypeJSON:
+ req, err = http.NewRequest("POST", w.URL, strings.NewReader(payloadContent))
+ if err != nil {
+ return nil, nil, err
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ case webhook_model.ContentTypeForm:
+ forms := url.Values{
+ "payload": []string{payloadContent},
+ }
+
+ req, err = http.NewRequest("POST", w.URL, strings.NewReader(forms.Encode()))
+ if err != nil {
+ return nil, nil, err
+ }
+
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ default:
+ return nil, nil, fmt.Errorf("invalid content type: %v", w.ContentType)
+ }
+ case http.MethodGet:
+ u, err := url.Parse(w.URL)
+ if err != nil {
+ return nil, nil, fmt.Errorf("invalid URL: %w", err)
+ }
+ vals := u.Query()
+ vals["payload"] = []string{payloadContent}
+ u.RawQuery = vals.Encode()
+ req, err = http.NewRequest("GET", u.String(), nil)
+ if err != nil {
+ return nil, nil, err
+ }
+ case http.MethodPut:
+ switch w.Type {
+ case webhook_module.MATRIX: // used when t.Version == 1
+ txnID, err := getMatrixTxnID([]byte(payloadContent))
+ if err != nil {
+ return nil, nil, err
+ }
+ url := fmt.Sprintf("%s/%s", w.URL, url.PathEscape(txnID))
+ req, err = http.NewRequest("PUT", url, strings.NewReader(payloadContent))
+ if err != nil {
+ return nil, nil, err
+ }
+ default:
+ return nil, nil, fmt.Errorf("invalid http method: %v", w.HTTPMethod)
+ }
+ default:
+ return nil, nil, fmt.Errorf("invalid http method: %v", w.HTTPMethod)
+ }
+
+ body = []byte(payloadContent)
+ return req, body, shared.AddDefaultHeaders(req, []byte(w.Secret), t, body)
+}
+
+func substituteRefShortName(body string) (string, error) {
+ var m map[string]any
+ if err := json.Unmarshal([]byte(body), &m); err != nil {
+ return body, err
+ }
+ ref, ok := m["ref"].(string)
+ if !ok {
+ return body, fmt.Errorf("expected string 'ref', got %T", m["ref"])
+ }
+
+ m["ref"] = git.RefName(ref).ShortName()
+
+ buf, err := json.Marshal(m)
+ return string(buf), err
+}
diff --git a/services/webhook/default_test.go b/services/webhook/default_test.go
new file mode 100644
index 0000000..f3e2848
--- /dev/null
+++ b/services/webhook/default_test.go
@@ -0,0 +1,260 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "testing"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/json"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+
+ jsoniter "github.com/json-iterator/go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGiteaPayload(t *testing.T) {
+ dh := defaultHandler{
+ forgejo: false,
+ }
+ hook := &webhook_model.Webhook{
+ RepoID: 3,
+ IsActive: true,
+ Type: webhook_module.GITEA,
+ URL: "https://gitea.example.com/",
+ Meta: ``,
+ HTTPMethod: "POST",
+ ContentType: webhook_model.ContentTypeJSON,
+ }
+
+ // Woodpecker expects the ref to be short on tag creation only
+ // https://github.com/woodpecker-ci/woodpecker/blob/00ccec078cdced80cf309cd4da460a5041d7991a/server/forge/gitea/helper.go#L134
+ // see https://codeberg.org/codeberg/community/issues/1556
+ t.Run("Create", func(t *testing.T) {
+ p := createTestPayload()
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ task := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventCreate,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ req, reqBody, err := dh.NewRequest(context.Background(), hook, task)
+ require.NoError(t, err)
+ require.NotNil(t, req)
+ require.NotNil(t, reqBody)
+
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, "https://gitea.example.com/", req.URL.String())
+ assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256"))
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+ var body struct {
+ Ref string `json:"ref"`
+ }
+ err = json.NewDecoder(req.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Equal(t, "test", body.Ref) // short ref
+ })
+
+ t.Run("Push", func(t *testing.T) {
+ p := pushTestPayload()
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ task := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventPush,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ req, reqBody, err := dh.NewRequest(context.Background(), hook, task)
+ require.NoError(t, err)
+ require.NotNil(t, req)
+ require.NotNil(t, reqBody)
+
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, "https://gitea.example.com/", req.URL.String())
+ assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256"))
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+ var body struct {
+ Ref string `json:"ref"`
+ }
+ err = json.NewDecoder(req.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Equal(t, "refs/heads/test", body.Ref) // full ref
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ p := deleteTestPayload()
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ task := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventDelete,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ req, reqBody, err := dh.NewRequest(context.Background(), hook, task)
+ require.NoError(t, err)
+ require.NotNil(t, req)
+ require.NotNil(t, reqBody)
+
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, "https://gitea.example.com/", req.URL.String())
+ assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256"))
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+ var body struct {
+ Ref string `json:"ref"`
+ }
+ err = json.NewDecoder(req.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Equal(t, "test", body.Ref) // short ref
+ })
+}
+
+func TestForgejoPayload(t *testing.T) {
+ dh := defaultHandler{
+ forgejo: true,
+ }
+ hook := &webhook_model.Webhook{
+ RepoID: 3,
+ IsActive: true,
+ Type: webhook_module.FORGEJO,
+ URL: "https://forgejo.example.com/",
+ Meta: ``,
+ HTTPMethod: "POST",
+ ContentType: webhook_model.ContentTypeJSON,
+ }
+
+ // always return the full ref for consistency
+ t.Run("Create", func(t *testing.T) {
+ p := createTestPayload()
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ task := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventCreate,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ req, reqBody, err := dh.NewRequest(context.Background(), hook, task)
+ require.NoError(t, err)
+ require.NotNil(t, req)
+ require.NotNil(t, reqBody)
+
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, "https://forgejo.example.com/", req.URL.String())
+ assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256"))
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+ var body struct {
+ Ref string `json:"ref"`
+ }
+ err = json.NewDecoder(req.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Equal(t, "refs/heads/test", body.Ref) // full ref
+ })
+
+ t.Run("Push", func(t *testing.T) {
+ p := pushTestPayload()
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ task := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventPush,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ req, reqBody, err := dh.NewRequest(context.Background(), hook, task)
+ require.NoError(t, err)
+ require.NotNil(t, req)
+ require.NotNil(t, reqBody)
+
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, "https://forgejo.example.com/", req.URL.String())
+ assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256"))
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+ var body struct {
+ Ref string `json:"ref"`
+ }
+ err = json.NewDecoder(req.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Equal(t, "refs/heads/test", body.Ref) // full ref
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ p := deleteTestPayload()
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ task := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventDelete,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ req, reqBody, err := dh.NewRequest(context.Background(), hook, task)
+ require.NoError(t, err)
+ require.NotNil(t, req)
+ require.NotNil(t, reqBody)
+
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, "https://forgejo.example.com/", req.URL.String())
+ assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256"))
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+ var body struct {
+ Ref string `json:"ref"`
+ }
+ err = json.NewDecoder(req.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Equal(t, "refs/heads/test", body.Ref) // full ref
+ })
+}
+
+func TestOpenProjectPayload(t *testing.T) {
+ t.Run("PullRequest", func(t *testing.T) {
+ p := pullRequestTestPayload()
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ // adapted from https://github.com/opf/openproject/blob/4c5c45fe995da0060902bc8dd5f1bf704d0b8737/modules/github_integration/lib/open_project/github_integration/services/upsert_pull_request.rb#L56
+ j := jsoniter.Get(data, "pull_request")
+
+ assert.Equal(t, 12, j.Get("id").MustBeValid().ToInt())
+ assert.Equal(t, "user1", j.Get("user", "login").MustBeValid().ToString())
+ assert.Equal(t, 12, j.Get("number").MustBeValid().ToInt())
+ assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", j.Get("html_url").MustBeValid().ToString())
+ assert.Equal(t, jsoniter.NilValue, j.Get("updated_at").ValueType())
+ assert.Equal(t, "", j.Get("state").MustBeValid().ToString())
+ assert.Equal(t, "Fix bug", j.Get("title").MustBeValid().ToString())
+ assert.Equal(t, "fixes bug #2", j.Get("body").MustBeValid().ToString())
+
+ assert.Equal(t, "test/repo", j.Get("base", "repo", "full_name").MustBeValid().ToString())
+ assert.Equal(t, "http://localhost:3000/test/repo", j.Get("base", "repo", "html_url").MustBeValid().ToString())
+
+ assert.False(t, j.Get("draft").MustBeValid().ToBool())
+ assert.Equal(t, jsoniter.NilValue, j.Get("merge_commit_sha").ValueType())
+ assert.False(t, j.Get("merged").MustBeValid().ToBool())
+ assert.Equal(t, jsoniter.NilValue, j.Get("merged_by").ValueType())
+ assert.Equal(t, jsoniter.NilValue, j.Get("merged_at").ValueType())
+ assert.Equal(t, 0, j.Get("comments").MustBeValid().ToInt())
+ assert.Equal(t, 0, j.Get("review_comments").MustBeValid().ToInt())
+ assert.Equal(t, 0, j.Get("additions").MustBeValid().ToInt())
+ assert.Equal(t, 0, j.Get("deletions").MustBeValid().ToInt())
+ assert.Equal(t, 0, j.Get("changed_files").MustBeValid().ToInt())
+ // assert.Equal(t,"labels:", j.Get("labels").map { |values| extract_label_values(values) )
+ })
+}
diff --git a/services/webhook/deliver.go b/services/webhook/deliver.go
new file mode 100644
index 0000000..2566814
--- /dev/null
+++ b/services/webhook/deliver.go
@@ -0,0 +1,258 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/hostmatcher"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/proxy"
+ "code.gitea.io/gitea/modules/queue"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+
+ "github.com/gobwas/glob"
+)
+
+// Deliver creates the [http.Request] (depending on the webhook type), sends it
+// and records the status and response.
+func Deliver(ctx context.Context, t *webhook_model.HookTask) error {
+ w, err := webhook_model.GetWebhookByID(ctx, t.HookID)
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ err := recover()
+ if err == nil {
+ return
+ }
+ // There was a panic whilst delivering a hook...
+ log.Error("PANIC whilst trying to deliver webhook task[%d] to webhook %s Panic: %v\nStacktrace: %s", t.ID, w.URL, err, log.Stack(2))
+ }()
+
+ t.IsDelivered = true
+
+ handler := GetWebhookHandler(w.Type)
+ if handler == nil {
+ return fmt.Errorf("GetWebhookHandler %q", w.Type)
+ }
+ if t.PayloadVersion == 1 {
+ handler = defaultHandler{true}
+ }
+
+ req, body, err := handler.NewRequest(ctx, w, t)
+ if err != nil {
+ return fmt.Errorf("cannot create http request for webhook %s[%d %s]: %w", w.Type, w.ID, w.URL, err)
+ }
+
+ // Record delivery information.
+ t.RequestInfo = &webhook_model.HookRequest{
+ URL: req.URL.String(),
+ HTTPMethod: req.Method,
+ Headers: map[string]string{},
+ Body: string(body),
+ }
+ for k, vals := range req.Header {
+ t.RequestInfo.Headers[k] = strings.Join(vals, ",")
+ }
+
+ // Add Authorization Header
+ authorization, err := w.HeaderAuthorization()
+ if err != nil {
+ return fmt.Errorf("cannot get Authorization header for webhook %s[%d %s]: %w", w.Type, w.ID, w.URL, err)
+ }
+ if authorization != "" {
+ req.Header.Set("Authorization", authorization)
+ redacted := "******"
+ if strings.HasPrefix(authorization, "Bearer ") {
+ redacted = "Bearer " + redacted
+ } else if strings.HasPrefix(authorization, "Basic ") {
+ redacted = "Basic " + redacted
+ }
+ t.RequestInfo.Headers["Authorization"] = redacted
+ }
+
+ t.ResponseInfo = &webhook_model.HookResponse{
+ Headers: map[string]string{},
+ }
+
+ // OK We're now ready to attempt to deliver the task - we must double check that it
+ // has not been delivered in the meantime
+ updated, err := webhook_model.MarkTaskDelivered(ctx, t)
+ if err != nil {
+ log.Error("MarkTaskDelivered[%d]: %v", t.ID, err)
+ return fmt.Errorf("unable to mark task[%d] delivered in the db: %w", t.ID, err)
+ }
+ if !updated {
+ // This webhook task has already been attempted to be delivered or is in the process of being delivered
+ log.Trace("Webhook Task[%d] already delivered", t.ID)
+ return nil
+ }
+
+ // All code from this point will update the hook task
+ defer func() {
+ t.Delivered = timeutil.TimeStampNanoNow()
+ if t.IsSucceed {
+ log.Trace("Hook delivered: %s", t.UUID)
+ } else if !w.IsActive {
+ log.Trace("Hook delivery skipped as webhook is inactive: %s", t.UUID)
+ } else {
+ log.Trace("Hook delivery failed: %s", t.UUID)
+ }
+
+ if err := webhook_model.UpdateHookTask(ctx, t); err != nil {
+ log.Error("UpdateHookTask [%d]: %v", t.ID, err)
+ }
+
+ // Update webhook last delivery status.
+ if t.IsSucceed {
+ w.LastStatus = webhook_module.HookStatusSucceed
+ } else {
+ w.LastStatus = webhook_module.HookStatusFail
+ }
+ if err = webhook_model.UpdateWebhookLastStatus(ctx, w); err != nil {
+ log.Error("UpdateWebhookLastStatus: %v", err)
+ return
+ }
+ }()
+
+ if setting.DisableWebhooks {
+ return fmt.Errorf("webhook task skipped (webhooks disabled): [%d]", t.ID)
+ }
+
+ if !w.IsActive {
+ log.Trace("Webhook %s in Webhook Task[%d] is not active", w.URL, t.ID)
+ return nil
+ }
+
+ resp, err := webhookHTTPClient.Do(req.WithContext(ctx))
+ if err != nil {
+ t.ResponseInfo.Body = fmt.Sprintf("Delivery: %v", err)
+ return fmt.Errorf("unable to deliver webhook task[%d] in %s due to error in http client: %w", t.ID, w.URL, err)
+ }
+ defer resp.Body.Close()
+
+ // Status code is 20x can be seen as succeed.
+ t.IsSucceed = resp.StatusCode/100 == 2
+ t.ResponseInfo.Status = resp.StatusCode
+ for k, vals := range resp.Header {
+ t.ResponseInfo.Headers[k] = strings.Join(vals, ",")
+ }
+
+ p, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.ResponseInfo.Body = fmt.Sprintf("read body: %s", err)
+ return fmt.Errorf("unable to deliver webhook task[%d] in %s as unable to read response body: %w", t.ID, w.URL, err)
+ }
+ t.ResponseInfo.Body = string(p)
+ return nil
+}
+
+var (
+ webhookHTTPClient *http.Client
+ once sync.Once
+ hostMatchers []glob.Glob
+)
+
+func webhookProxy(allowList *hostmatcher.HostMatchList) func(req *http.Request) (*url.URL, error) {
+ if setting.Webhook.ProxyURL == "" {
+ return proxy.Proxy()
+ }
+
+ once.Do(func() {
+ for _, h := range setting.Webhook.ProxyHosts {
+ if g, err := glob.Compile(h); err == nil {
+ hostMatchers = append(hostMatchers, g)
+ } else {
+ log.Error("glob.Compile %s failed: %v", h, err)
+ }
+ }
+ })
+
+ return func(req *http.Request) (*url.URL, error) {
+ for _, v := range hostMatchers {
+ if v.Match(req.URL.Host) {
+ if !allowList.MatchHostName(req.URL.Host) {
+ return nil, fmt.Errorf("webhook can only call allowed HTTP servers (check your %s setting), deny '%s'", allowList.SettingKeyHint, req.URL.Host)
+ }
+ return http.ProxyURL(setting.Webhook.ProxyURLFixed)(req)
+ }
+ }
+ return http.ProxyFromEnvironment(req)
+ }
+}
+
+// Init starts the hooks delivery thread
+func Init() error {
+ timeout := time.Duration(setting.Webhook.DeliverTimeout) * time.Second
+
+ allowedHostListValue := setting.Webhook.AllowedHostList
+ if allowedHostListValue == "" {
+ allowedHostListValue = hostmatcher.MatchBuiltinExternal
+ }
+ allowedHostMatcher := hostmatcher.ParseHostMatchList("webhook.ALLOWED_HOST_LIST", allowedHostListValue)
+
+ webhookHTTPClient = &http.Client{
+ Timeout: timeout,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: setting.Webhook.SkipTLSVerify},
+ Proxy: webhookProxy(allowedHostMatcher),
+ DialContext: hostmatcher.NewDialContext("webhook", allowedHostMatcher, nil, setting.Webhook.ProxyURLFixed),
+ },
+ }
+
+ hookQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "webhook_sender", handler)
+ if hookQueue == nil {
+ return fmt.Errorf("unable to create webhook_sender queue")
+ }
+ go graceful.GetManager().RunWithCancel(hookQueue)
+
+ go graceful.GetManager().RunWithShutdownContext(populateWebhookSendingQueue)
+
+ return nil
+}
+
+func populateWebhookSendingQueue(ctx context.Context) {
+ ctx, _, finished := process.GetManager().AddContext(ctx, "Webhook: Populate sending queue")
+ defer finished()
+
+ lowerID := int64(0)
+ for {
+ taskIDs, err := webhook_model.FindUndeliveredHookTaskIDs(ctx, lowerID)
+ if err != nil {
+ log.Error("Unable to populate webhook queue as FindUndeliveredHookTaskIDs failed: %v", err)
+ return
+ }
+ if len(taskIDs) == 0 {
+ return
+ }
+ lowerID = taskIDs[len(taskIDs)-1]
+
+ for _, taskID := range taskIDs {
+ select {
+ case <-ctx.Done():
+ log.Warn("Shutdown before Webhook Sending queue finishing being populated")
+ return
+ default:
+ }
+ if err := enqueueHookTask(taskID); err != nil {
+ log.Error("Unable to push HookTask[%d] to the Webhook Sending queue: %v", taskID, err)
+ }
+ }
+ }
+}
diff --git a/services/webhook/deliver_test.go b/services/webhook/deliver_test.go
new file mode 100644
index 0000000..21af3c7
--- /dev/null
+++ b/services/webhook/deliver_test.go
@@ -0,0 +1,332 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/hostmatcher"
+ "code.gitea.io/gitea/modules/setting"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWebhookProxy(t *testing.T) {
+ oldWebhook := setting.Webhook
+ oldHTTPProxy := os.Getenv("http_proxy")
+ oldHTTPSProxy := os.Getenv("https_proxy")
+ t.Cleanup(func() {
+ setting.Webhook = oldWebhook
+ os.Setenv("http_proxy", oldHTTPProxy)
+ os.Setenv("https_proxy", oldHTTPSProxy)
+ })
+ os.Unsetenv("http_proxy")
+ os.Unsetenv("https_proxy")
+
+ setting.Webhook.ProxyURL = "http://localhost:8080"
+ setting.Webhook.ProxyURLFixed, _ = url.Parse(setting.Webhook.ProxyURL)
+ setting.Webhook.ProxyHosts = []string{"*.discordapp.com", "discordapp.com"}
+
+ allowedHostMatcher := hostmatcher.ParseHostMatchList("webhook.ALLOWED_HOST_LIST", "discordapp.com,s.discordapp.com")
+
+ tests := []struct {
+ req string
+ want string
+ wantErr bool
+ }{
+ {
+ req: "https://discordapp.com/api/webhooks/xxxxxxxxx/xxxxxxxxxxxxxxxxxxx",
+ want: "http://localhost:8080",
+ wantErr: false,
+ },
+ {
+ req: "http://s.discordapp.com/assets/xxxxxx",
+ want: "http://localhost:8080",
+ wantErr: false,
+ },
+ {
+ req: "http://github.com/a/b",
+ want: "",
+ wantErr: false,
+ },
+ {
+ req: "http://www.discordapp.com/assets/xxxxxx",
+ want: "",
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.req, func(t *testing.T) {
+ req, err := http.NewRequest("POST", tt.req, nil)
+ require.NoError(t, err)
+
+ u, err := webhookProxy(allowedHostMatcher)(req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+
+ require.NoError(t, err)
+
+ got := ""
+ if u != nil {
+ got = u.String()
+ }
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestWebhookDeliverAuthorizationHeader(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ done := make(chan struct{}, 1)
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/webhook", r.URL.Path)
+ assert.Equal(t, "Bearer s3cr3t-t0ken", r.Header.Get("Authorization"))
+ w.WriteHeader(200)
+ done <- struct{}{}
+ }))
+ t.Cleanup(s.Close)
+
+ hook := &webhook_model.Webhook{
+ RepoID: 3,
+ URL: s.URL + "/webhook",
+ ContentType: webhook_model.ContentTypeJSON,
+ IsActive: true,
+ Type: webhook_module.GITEA,
+ }
+ err := hook.SetHeaderAuthorization("Bearer s3cr3t-t0ken")
+ require.NoError(t, err)
+ require.NoError(t, webhook_model.CreateWebhook(db.DefaultContext, hook))
+
+ hookTask := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventPush,
+ PayloadVersion: 2,
+ }
+
+ hookTask, err = webhook_model.CreateHookTask(db.DefaultContext, hookTask)
+ require.NoError(t, err)
+ assert.NotNil(t, hookTask)
+
+ require.NoError(t, Deliver(context.Background(), hookTask))
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("waited to long for request to happen")
+ }
+
+ assert.True(t, hookTask.IsSucceed)
+ assert.Equal(t, "Bearer ******", hookTask.RequestInfo.Headers["Authorization"])
+}
+
+func TestWebhookDeliverHookTask(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ done := make(chan struct{}, 1)
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "PUT", r.Method)
+ switch r.URL.Path {
+ case "/webhook/66d222a5d6349e1311f551e50722d837e30fce98":
+ // Version 1
+ assert.Equal(t, "push", r.Header.Get("X-GitHub-Event"))
+ assert.Equal(t, "", r.Header.Get("Content-Type"))
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ assert.Equal(t, `{"data": 42}`, string(body))
+
+ case "/webhook/6db5dc1e282529a8c162c7fe93dd2667494eeb51":
+ // Version 2
+ assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ assert.Len(t, body, 2147)
+
+ default:
+ w.WriteHeader(404)
+ t.Fatalf("unexpected url path %s", r.URL.Path)
+ return
+ }
+ w.WriteHeader(200)
+ done <- struct{}{}
+ }))
+ t.Cleanup(s.Close)
+
+ hook := &webhook_model.Webhook{
+ RepoID: 3,
+ IsActive: true,
+ Type: webhook_module.MATRIX,
+ URL: s.URL + "/webhook",
+ HTTPMethod: "PUT",
+ ContentType: webhook_model.ContentTypeJSON,
+ Meta: `{"message_type":0}`, // text
+ }
+ require.NoError(t, webhook_model.CreateWebhook(db.DefaultContext, hook))
+
+ t.Run("Version 1", func(t *testing.T) {
+ hookTask := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventPush,
+ PayloadContent: `{"data": 42}`,
+ PayloadVersion: 1,
+ }
+
+ hookTask, err := webhook_model.CreateHookTask(db.DefaultContext, hookTask)
+ require.NoError(t, err)
+ assert.NotNil(t, hookTask)
+
+ require.NoError(t, Deliver(context.Background(), hookTask))
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("waited to long for request to happen")
+ }
+
+ assert.True(t, hookTask.IsSucceed)
+ })
+
+ t.Run("Version 2", func(t *testing.T) {
+ p := pushTestPayload()
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ hookTask := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventPush,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ hookTask, err = webhook_model.CreateHookTask(db.DefaultContext, hookTask)
+ require.NoError(t, err)
+ assert.NotNil(t, hookTask)
+
+ require.NoError(t, Deliver(context.Background(), hookTask))
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("waited to long for request to happen")
+ }
+
+ assert.True(t, hookTask.IsSucceed)
+ })
+}
+
+func TestWebhookDeliverSpecificTypes(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ type hookCase struct {
+ gotBody chan []byte
+ expectedMethod string
+ }
+
+ cases := map[string]hookCase{
+ webhook_module.SLACK: {
+ gotBody: make(chan []byte, 1),
+ },
+ webhook_module.DISCORD: {
+ gotBody: make(chan []byte, 1),
+ },
+ webhook_module.DINGTALK: {
+ gotBody: make(chan []byte, 1),
+ },
+ webhook_module.TELEGRAM: {
+ gotBody: make(chan []byte, 1),
+ },
+ webhook_module.MSTEAMS: {
+ gotBody: make(chan []byte, 1),
+ },
+ webhook_module.FEISHU: {
+ gotBody: make(chan []byte, 1),
+ },
+ webhook_module.MATRIX: {
+ gotBody: make(chan []byte, 1),
+ expectedMethod: "PUT",
+ },
+ webhook_module.WECHATWORK: {
+ gotBody: make(chan []byte, 1),
+ },
+ webhook_module.PACKAGIST: {
+ gotBody: make(chan []byte, 1),
+ },
+ }
+
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "application/json", r.Header.Get("Content-Type"), r.URL.Path)
+
+ typ := strings.Split(r.URL.Path, "/")[1] // take first segment (after skipping leading slash)
+ hc := cases[typ]
+
+ if hc.expectedMethod != "" {
+ assert.Equal(t, hc.expectedMethod, r.Method, r.URL.Path)
+ } else {
+ assert.Equal(t, "POST", r.Method, r.URL.Path)
+ }
+
+ require.NotNil(t, hc.gotBody, r.URL.Path)
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ w.WriteHeader(200)
+ hc.gotBody <- body
+ }))
+ t.Cleanup(s.Close)
+
+ p := pushTestPayload()
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ for typ, hc := range cases {
+ typ := typ
+ hc := hc
+ t.Run(typ, func(t *testing.T) {
+ t.Parallel()
+ hook := &webhook_model.Webhook{
+ RepoID: 3,
+ IsActive: true,
+ Type: typ,
+ URL: s.URL + "/" + typ,
+ HTTPMethod: "", // should fallback to POST, when left unset by the specific hook
+ ContentType: 0, // set to 0 so that falling back to default request fails with "invalid content type"
+ Meta: "{}",
+ }
+ require.NoError(t, webhook_model.CreateWebhook(db.DefaultContext, hook))
+
+ hookTask := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventPush,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ hookTask, err := webhook_model.CreateHookTask(db.DefaultContext, hookTask)
+ require.NoError(t, err)
+ assert.NotNil(t, hookTask)
+
+ require.NoError(t, Deliver(context.Background(), hookTask))
+ select {
+ case gotBody := <-hc.gotBody:
+ assert.NotEqual(t, string(data), string(gotBody), "request body must be different from the event payload")
+ assert.Equal(t, hookTask.RequestInfo.Body, string(gotBody), "request body was not saved")
+ case <-time.After(5 * time.Second):
+ t.Fatal("waited to long for request to happen")
+ }
+
+ assert.True(t, hookTask.IsSucceed)
+ })
+ }
+}
diff --git a/services/webhook/dingtalk.go b/services/webhook/dingtalk.go
new file mode 100644
index 0000000..899c5b2
--- /dev/null
+++ b/services/webhook/dingtalk.go
@@ -0,0 +1,232 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "net/http"
+ "net/url"
+ "strings"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/git"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/webhook/shared"
+)
+
+type dingtalkHandler struct{}
+
+func (dingtalkHandler) Type() webhook_module.HookType { return webhook_module.DINGTALK }
+func (dingtalkHandler) Metadata(*webhook_model.Webhook) any { return nil }
+func (dingtalkHandler) Icon(size int) template.HTML { return shared.ImgIcon("dingtalk.ico", size) }
+
+func (dingtalkHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
+ var form struct {
+ forms.WebhookCoreForm
+ PayloadURL string `binding:"Required;ValidUrl"`
+ }
+ bind(&form)
+
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: form.PayloadURL,
+ ContentType: webhook_model.ContentTypeJSON,
+ Secret: "",
+ HTTPMethod: http.MethodPost,
+ Metadata: nil,
+ }
+}
+
+type (
+ // DingtalkPayload represents an dingtalk payload.
+ DingtalkPayload struct {
+ MsgType string `json:"msgtype"`
+ Text struct {
+ Content string `json:"content"`
+ } `json:"text"`
+ ActionCard DingtalkActionCard `json:"actionCard"`
+ }
+
+ DingtalkActionCard struct {
+ Text string `json:"text"`
+ Title string `json:"title"`
+ HideAvatar string `json:"hideAvatar"`
+ SingleTitle string `json:"singleTitle"`
+ SingleURL string `json:"singleURL"`
+ }
+)
+
+// Create implements PayloadConvertor Create method
+func (dc dingtalkConvertor) Create(p *api.CreatePayload) (DingtalkPayload, error) {
+ // created tag/branch
+ refName := git.RefName(p.Ref).ShortName()
+ title := fmt.Sprintf("[%s] %s %s created", p.Repo.FullName, p.RefType, refName)
+
+ return createDingtalkPayload(title, title, fmt.Sprintf("view ref %s", refName), p.Repo.HTMLURL+"/src/"+util.PathEscapeSegments(refName)), nil
+}
+
+// Delete implements PayloadConvertor Delete method
+func (dc dingtalkConvertor) Delete(p *api.DeletePayload) (DingtalkPayload, error) {
+ // created tag/branch
+ refName := git.RefName(p.Ref).ShortName()
+ title := fmt.Sprintf("[%s] %s %s deleted", p.Repo.FullName, p.RefType, refName)
+
+ return createDingtalkPayload(title, title, fmt.Sprintf("view ref %s", refName), p.Repo.HTMLURL+"/src/"+util.PathEscapeSegments(refName)), nil
+}
+
+// Fork implements PayloadConvertor Fork method
+func (dc dingtalkConvertor) Fork(p *api.ForkPayload) (DingtalkPayload, error) {
+ title := fmt.Sprintf("%s is forked to %s", p.Forkee.FullName, p.Repo.FullName)
+
+ return createDingtalkPayload(title, title, fmt.Sprintf("view forked repo %s", p.Repo.FullName), p.Repo.HTMLURL), nil
+}
+
+// Push implements PayloadConvertor Push method
+func (dc dingtalkConvertor) Push(p *api.PushPayload) (DingtalkPayload, error) {
+ var (
+ branchName = git.RefName(p.Ref).ShortName()
+ commitDesc string
+ )
+
+ var titleLink, linkText string
+ if p.TotalCommits == 1 {
+ commitDesc = "1 new commit"
+ titleLink = p.Commits[0].URL
+ linkText = "view commit"
+ } else {
+ commitDesc = fmt.Sprintf("%d new commits", p.TotalCommits)
+ titleLink = p.CompareURL
+ linkText = "view commits"
+ }
+ if titleLink == "" {
+ titleLink = p.Repo.HTMLURL + "/src/" + util.PathEscapeSegments(branchName)
+ }
+
+ title := fmt.Sprintf("[%s:%s] %s", p.Repo.FullName, branchName, commitDesc)
+
+ var text string
+ // for each commit, generate attachment text
+ for i, commit := range p.Commits {
+ var authorName string
+ if commit.Author != nil {
+ authorName = " - " + commit.Author.Name
+ }
+ text += fmt.Sprintf("[%s](%s) %s", commit.ID[:7], commit.URL,
+ strings.TrimRight(commit.Message, "\r\n")) + authorName
+ // add linebreak to each commit but the last
+ if i < len(p.Commits)-1 {
+ text += "\r\n"
+ }
+ }
+
+ return createDingtalkPayload(title, text, linkText, titleLink), nil
+}
+
+// Issue implements PayloadConvertor Issue method
+func (dc dingtalkConvertor) Issue(p *api.IssuePayload) (DingtalkPayload, error) {
+ text, issueTitle, attachmentText, _ := getIssuesPayloadInfo(p, noneLinkFormatter, true)
+
+ return createDingtalkPayload(issueTitle, text+"\r\n\r\n"+attachmentText, "view issue", p.Issue.HTMLURL), nil
+}
+
+// Wiki implements PayloadConvertor Wiki method
+func (dc dingtalkConvertor) Wiki(p *api.WikiPayload) (DingtalkPayload, error) {
+ text, _, _ := getWikiPayloadInfo(p, noneLinkFormatter, true)
+ url := p.Repository.HTMLURL + "/wiki/" + url.PathEscape(p.Page)
+
+ return createDingtalkPayload(text, text, "view wiki", url), nil
+}
+
+// IssueComment implements PayloadConvertor IssueComment method
+func (dc dingtalkConvertor) IssueComment(p *api.IssueCommentPayload) (DingtalkPayload, error) {
+ text, issueTitle, _ := getIssueCommentPayloadInfo(p, noneLinkFormatter, true)
+
+ return createDingtalkPayload(issueTitle, text+"\r\n\r\n"+p.Comment.Body, "view issue comment", p.Comment.HTMLURL), nil
+}
+
+// PullRequest implements PayloadConvertor PullRequest method
+func (dc dingtalkConvertor) PullRequest(p *api.PullRequestPayload) (DingtalkPayload, error) {
+ text, issueTitle, attachmentText, _ := getPullRequestPayloadInfo(p, noneLinkFormatter, true)
+
+ return createDingtalkPayload(issueTitle, text+"\r\n\r\n"+attachmentText, "view pull request", p.PullRequest.HTMLURL), nil
+}
+
+// Review implements PayloadConvertor Review method
+func (dc dingtalkConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (DingtalkPayload, error) {
+ var text, title string
+ if p.Action == api.HookIssueReviewed {
+ action, err := parseHookPullRequestEventType(event)
+ if err != nil {
+ return DingtalkPayload{}, err
+ }
+
+ title = fmt.Sprintf("[%s] Pull request review %s : #%d %s", p.Repository.FullName, action, p.Index, p.PullRequest.Title)
+ text = p.Review.Content
+ }
+
+ return createDingtalkPayload(title, title+"\r\n\r\n"+text, "view pull request", p.PullRequest.HTMLURL), nil
+}
+
+// Repository implements PayloadConvertor Repository method
+func (dc dingtalkConvertor) Repository(p *api.RepositoryPayload) (DingtalkPayload, error) {
+ switch p.Action {
+ case api.HookRepoCreated:
+ title := fmt.Sprintf("[%s] Repository created", p.Repository.FullName)
+ return createDingtalkPayload(title, title, "view repository", p.Repository.HTMLURL), nil
+ case api.HookRepoDeleted:
+ title := fmt.Sprintf("[%s] Repository deleted", p.Repository.FullName)
+ return DingtalkPayload{
+ MsgType: "text",
+ Text: struct {
+ Content string `json:"content"`
+ }{
+ Content: title,
+ },
+ }, nil
+ }
+
+ return DingtalkPayload{}, nil
+}
+
+// Release implements PayloadConvertor Release method
+func (dc dingtalkConvertor) Release(p *api.ReleasePayload) (DingtalkPayload, error) {
+ text, _ := getReleasePayloadInfo(p, noneLinkFormatter, true)
+
+ return createDingtalkPayload(text, text, "view release", p.Release.HTMLURL), nil
+}
+
+func (dc dingtalkConvertor) Package(p *api.PackagePayload) (DingtalkPayload, error) {
+ text, _ := getPackagePayloadInfo(p, noneLinkFormatter, true)
+
+ return createDingtalkPayload(text, text, "view package", p.Package.HTMLURL), nil
+}
+
+func createDingtalkPayload(title, text, singleTitle, singleURL string) DingtalkPayload {
+ return DingtalkPayload{
+ MsgType: "actionCard",
+ ActionCard: DingtalkActionCard{
+ Text: strings.TrimSpace(text),
+ Title: strings.TrimSpace(title),
+ HideAvatar: "0",
+ SingleTitle: singleTitle,
+
+ // https://developers.dingtalk.com/document/app/message-link-description
+ // to open the link in browser, we should use this URL, otherwise the page is displayed inside DingTalk client, very difficult to visit non-public URLs.
+ SingleURL: "dingtalk://dingtalkclient/page/link?pc_slide=false&url=" + url.QueryEscape(singleURL),
+ },
+ }
+}
+
+type dingtalkConvertor struct{}
+
+var _ shared.PayloadConvertor[DingtalkPayload] = dingtalkConvertor{}
+
+func (dingtalkHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
+ return shared.NewJSONRequest(dingtalkConvertor{}, w, t, true)
+}
diff --git a/services/webhook/dingtalk_test.go b/services/webhook/dingtalk_test.go
new file mode 100644
index 0000000..d0a2d48
--- /dev/null
+++ b/services/webhook/dingtalk_test.go
@@ -0,0 +1,252 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "net/url"
+ "testing"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/json"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDingTalkPayload(t *testing.T) {
+ parseRealSingleURL := func(singleURL string) string {
+ if u, err := url.Parse(singleURL); err == nil {
+ assert.Equal(t, "dingtalk", u.Scheme)
+ assert.Equal(t, "dingtalkclient", u.Host)
+ assert.Equal(t, "/page/link", u.Path)
+ return u.Query().Get("url")
+ }
+ return ""
+ }
+ dc := dingtalkConvertor{}
+
+ t.Run("Create", func(t *testing.T) {
+ p := createTestPayload()
+
+ pl, err := dc.Create(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] branch test created", pl.ActionCard.Text)
+ assert.Equal(t, "[test/repo] branch test created", pl.ActionCard.Title)
+ assert.Equal(t, "view ref test", pl.ActionCard.SingleTitle)
+ assert.Equal(t, "http://localhost:3000/test/repo/src/test", parseRealSingleURL(pl.ActionCard.SingleURL))
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ p := deleteTestPayload()
+
+ pl, err := dc.Delete(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] branch test deleted", pl.ActionCard.Text)
+ assert.Equal(t, "[test/repo] branch test deleted", pl.ActionCard.Title)
+ assert.Equal(t, "view ref test", pl.ActionCard.SingleTitle)
+ assert.Equal(t, "http://localhost:3000/test/repo/src/test", parseRealSingleURL(pl.ActionCard.SingleURL))
+ })
+
+ t.Run("Fork", func(t *testing.T) {
+ p := forkTestPayload()
+
+ pl, err := dc.Fork(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "test/repo2 is forked to test/repo", pl.ActionCard.Text)
+ assert.Equal(t, "test/repo2 is forked to test/repo", pl.ActionCard.Title)
+ assert.Equal(t, "view forked repo test/repo", pl.ActionCard.SingleTitle)
+ assert.Equal(t, "http://localhost:3000/test/repo", parseRealSingleURL(pl.ActionCard.SingleURL))
+ })
+
+ t.Run("Push", func(t *testing.T) {
+ p := pushTestPayload()
+
+ pl, err := dc.Push(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", pl.ActionCard.Text)
+ assert.Equal(t, "[test/repo:test] 2 new commits", pl.ActionCard.Title)
+ assert.Equal(t, "view commits", pl.ActionCard.SingleTitle)
+ assert.Equal(t, "http://localhost:3000/test/repo/src/test", parseRealSingleURL(pl.ActionCard.SingleURL))
+ })
+
+ t.Run("Issue", func(t *testing.T) {
+ p := issueTestPayload()
+
+ p.Action = api.HookIssueOpened
+ pl, err := dc.Issue(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Issue opened: #2 crash by user1\r\n\r\nissue body", pl.ActionCard.Text)
+ assert.Equal(t, "#2 crash", pl.ActionCard.Title)
+ assert.Equal(t, "view issue", pl.ActionCard.SingleTitle)
+ assert.Equal(t, "http://localhost:3000/test/repo/issues/2", parseRealSingleURL(pl.ActionCard.SingleURL))
+
+ p.Action = api.HookIssueClosed
+ pl, err = dc.Issue(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Issue closed: #2 crash by user1", pl.ActionCard.Text)
+ assert.Equal(t, "#2 crash", pl.ActionCard.Title)
+ assert.Equal(t, "view issue", pl.ActionCard.SingleTitle)
+ assert.Equal(t, "http://localhost:3000/test/repo/issues/2", parseRealSingleURL(pl.ActionCard.SingleURL))
+ })
+
+ t.Run("IssueComment", func(t *testing.T) {
+ p := issueCommentTestPayload()
+
+ pl, err := dc.IssueComment(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] New comment on issue #2 crash by user1\r\n\r\nmore info needed", pl.ActionCard.Text)
+ assert.Equal(t, "#2 crash", pl.ActionCard.Title)
+ assert.Equal(t, "view issue comment", pl.ActionCard.SingleTitle)
+ assert.Equal(t, "http://localhost:3000/test/repo/issues/2#issuecomment-4", parseRealSingleURL(pl.ActionCard.SingleURL))
+ })
+
+ t.Run("PullRequest", func(t *testing.T) {
+ p := pullRequestTestPayload()
+
+ pl, err := dc.PullRequest(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Pull request opened: #12 Fix bug by user1\r\n\r\nfixes bug #2", pl.ActionCard.Text)
+ assert.Equal(t, "#12 Fix bug", pl.ActionCard.Title)
+ assert.Equal(t, "view pull request", pl.ActionCard.SingleTitle)
+ assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", parseRealSingleURL(pl.ActionCard.SingleURL))
+ })
+
+ t.Run("PullRequestComment", func(t *testing.T) {
+ p := pullRequestCommentTestPayload()
+
+ pl, err := dc.IssueComment(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] New comment on pull request #12 Fix bug by user1\r\n\r\nchanges requested", pl.ActionCard.Text)
+ assert.Equal(t, "#12 Fix bug", pl.ActionCard.Title)
+ assert.Equal(t, "view issue comment", pl.ActionCard.SingleTitle)
+ assert.Equal(t, "http://localhost:3000/test/repo/pulls/12#issuecomment-4", parseRealSingleURL(pl.ActionCard.SingleURL))
+ })
+
+ t.Run("Review", func(t *testing.T) {
+ p := pullRequestTestPayload()
+ p.Action = api.HookIssueReviewed
+
+ pl, err := dc.Review(p, webhook_module.HookEventPullRequestReviewApproved)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Pull request review approved : #12 Fix bug\r\n\r\ngood job", pl.ActionCard.Text)
+ assert.Equal(t, "[test/repo] Pull request review approved : #12 Fix bug", pl.ActionCard.Title)
+ assert.Equal(t, "view pull request", pl.ActionCard.SingleTitle)
+ assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", parseRealSingleURL(pl.ActionCard.SingleURL))
+ })
+
+ t.Run("Repository", func(t *testing.T) {
+ p := repositoryTestPayload()
+
+ pl, err := dc.Repository(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Repository created", pl.ActionCard.Text)
+ assert.Equal(t, "[test/repo] Repository created", pl.ActionCard.Title)
+ assert.Equal(t, "view repository", pl.ActionCard.SingleTitle)
+ assert.Equal(t, "http://localhost:3000/test/repo", parseRealSingleURL(pl.ActionCard.SingleURL))
+ })
+
+ t.Run("Package", func(t *testing.T) {
+ p := packageTestPayload()
+
+ pl, err := dc.Package(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "Package created: GiteaContainer:latest by user1", pl.ActionCard.Text)
+ assert.Equal(t, "Package created: GiteaContainer:latest by user1", pl.ActionCard.Title)
+ assert.Equal(t, "view package", pl.ActionCard.SingleTitle)
+ assert.Equal(t, "http://localhost:3000/user1/-/packages/container/GiteaContainer/latest", parseRealSingleURL(pl.ActionCard.SingleURL))
+ })
+
+ t.Run("Wiki", func(t *testing.T) {
+ p := wikiTestPayload()
+
+ p.Action = api.HookWikiCreated
+ pl, err := dc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment) by user1", pl.ActionCard.Text)
+ assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment) by user1", pl.ActionCard.Title)
+ assert.Equal(t, "view wiki", pl.ActionCard.SingleTitle)
+ assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", parseRealSingleURL(pl.ActionCard.SingleURL))
+
+ p.Action = api.HookWikiEdited
+ pl, err = dc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment) by user1", pl.ActionCard.Text)
+ assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment) by user1", pl.ActionCard.Title)
+ assert.Equal(t, "view wiki", pl.ActionCard.SingleTitle)
+ assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", parseRealSingleURL(pl.ActionCard.SingleURL))
+
+ p.Action = api.HookWikiDeleted
+ pl, err = dc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Wiki page 'index' deleted by user1", pl.ActionCard.Text)
+ assert.Equal(t, "[test/repo] Wiki page 'index' deleted by user1", pl.ActionCard.Title)
+ assert.Equal(t, "view wiki", pl.ActionCard.SingleTitle)
+ assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", parseRealSingleURL(pl.ActionCard.SingleURL))
+ })
+
+ t.Run("Release", func(t *testing.T) {
+ p := pullReleaseTestPayload()
+
+ pl, err := dc.Release(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Release created: v1.0 by user1", pl.ActionCard.Text)
+ assert.Equal(t, "[test/repo] Release created: v1.0 by user1", pl.ActionCard.Title)
+ assert.Equal(t, "view release", pl.ActionCard.SingleTitle)
+ assert.Equal(t, "http://localhost:3000/test/repo/releases/tag/v1.0", parseRealSingleURL(pl.ActionCard.SingleURL))
+ })
+}
+
+func TestDingTalkJSONPayload(t *testing.T) {
+ p := pushTestPayload()
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ hook := &webhook_model.Webhook{
+ RepoID: 3,
+ IsActive: true,
+ Type: webhook_module.DINGTALK,
+ URL: "https://dingtalk.example.com/",
+ Meta: ``,
+ HTTPMethod: "POST",
+ }
+ task := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventPush,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ req, reqBody, err := dingtalkHandler{}.NewRequest(context.Background(), hook, task)
+ require.NotNil(t, req)
+ require.NotNil(t, reqBody)
+ require.NoError(t, err)
+
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, "https://dingtalk.example.com/", req.URL.String())
+ assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256"))
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+ var body DingtalkPayload
+ err = json.NewDecoder(req.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", body.ActionCard.Text)
+}
diff --git a/services/webhook/discord.go b/services/webhook/discord.go
new file mode 100644
index 0000000..b0142b8
--- /dev/null
+++ b/services/webhook/discord.go
@@ -0,0 +1,367 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "html/template"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ gitea_context "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/webhook/shared"
+
+ "gitea.com/go-chi/binding"
+)
+
+type discordHandler struct{}
+
+func (discordHandler) Type() webhook_module.HookType { return webhook_module.DISCORD }
+func (discordHandler) Icon(size int) template.HTML { return shared.ImgIcon("discord.png", size) }
+
+type discordForm struct {
+ forms.WebhookCoreForm
+ PayloadURL string `binding:"Required;ValidUrl"`
+ Username string `binding:"Required;MaxSize(80)"`
+ IconURL string `binding:"ValidUrl"`
+}
+
+var _ binding.Validator = &discordForm{}
+
+// Validate implements binding.Validator.
+func (d *discordForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
+ ctx := gitea_context.GetWebContext(req)
+ if len([]rune(d.IconURL)) > 2048 {
+ errs = append(errs, binding.Error{
+ FieldNames: []string{"IconURL"},
+ Message: ctx.Locale.TrString("repo.settings.discord_icon_url.exceeds_max_length"),
+ })
+ }
+ return errs
+}
+
+func (discordHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
+ var form discordForm
+ bind(&form)
+
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: form.PayloadURL,
+ ContentType: webhook_model.ContentTypeJSON,
+ Secret: "",
+ HTTPMethod: http.MethodPost,
+ Metadata: &DiscordMeta{
+ Username: form.Username,
+ IconURL: form.IconURL,
+ },
+ }
+}
+
+type (
+ // DiscordEmbedFooter for Embed Footer Structure.
+ DiscordEmbedFooter struct {
+ Text string `json:"text,omitempty"`
+ }
+
+ // DiscordEmbedAuthor for Embed Author Structure
+ DiscordEmbedAuthor struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ IconURL string `json:"icon_url"`
+ }
+
+ // DiscordEmbedField for Embed Field Structure
+ DiscordEmbedField struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+ }
+
+ // DiscordEmbed is for Embed Structure
+ DiscordEmbed struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+ URL string `json:"url"`
+ Color int `json:"color"`
+ Footer DiscordEmbedFooter `json:"footer"`
+ Author DiscordEmbedAuthor `json:"author"`
+ Fields []DiscordEmbedField `json:"fields,omitempty"`
+ }
+
+ // DiscordPayload represents
+ DiscordPayload struct {
+ Wait bool `json:"-"`
+ Content string `json:"-"`
+ Username string `json:"username"`
+ AvatarURL string `json:"avatar_url,omitempty"`
+ TTS bool `json:"-"`
+ Embeds []DiscordEmbed `json:"embeds"`
+ }
+
+ // DiscordMeta contains the discord metadata
+ DiscordMeta struct {
+ Username string `json:"username"`
+ IconURL string `json:"icon_url"`
+ }
+)
+
+// Metadata returns discord metadata
+func (discordHandler) Metadata(w *webhook_model.Webhook) any {
+ s := &DiscordMeta{}
+ if err := json.Unmarshal([]byte(w.Meta), s); err != nil {
+ log.Error("discordHandler.Metadata(%d): %v", w.ID, err)
+ }
+ return s
+}
+
+func color(clr string) int {
+ if clr != "" {
+ clr = strings.TrimLeft(clr, "#")
+ if s, err := strconv.ParseInt(clr, 16, 32); err == nil {
+ return int(s)
+ }
+ }
+
+ return 0
+}
+
+var (
+ greenColor = color("1ac600")
+ greenColorLight = color("bfe5bf")
+ yellowColor = color("ffd930")
+ greyColor = color("4f545c")
+ purpleColor = color("7289da")
+ orangeColor = color("eb6420")
+ orangeColorLight = color("e68d60")
+ redColor = color("ff3232")
+)
+
+// Create implements PayloadConvertor Create method
+func (d discordConvertor) Create(p *api.CreatePayload) (DiscordPayload, error) {
+ // created tag/branch
+ refName := git.RefName(p.Ref).ShortName()
+ title := fmt.Sprintf("[%s] %s %s created", p.Repo.FullName, p.RefType, refName)
+
+ return d.createPayload(p.Sender, title, "", p.Repo.HTMLURL+"/src/"+util.PathEscapeSegments(refName), greenColor), nil
+}
+
+// Delete implements PayloadConvertor Delete method
+func (d discordConvertor) Delete(p *api.DeletePayload) (DiscordPayload, error) {
+ // deleted tag/branch
+ refName := git.RefName(p.Ref).ShortName()
+ title := fmt.Sprintf("[%s] %s %s deleted", p.Repo.FullName, p.RefType, refName)
+
+ return d.createPayload(p.Sender, title, "", p.Repo.HTMLURL+"/src/"+util.PathEscapeSegments(refName), redColor), nil
+}
+
+// Fork implements PayloadConvertor Fork method
+func (d discordConvertor) Fork(p *api.ForkPayload) (DiscordPayload, error) {
+ title := fmt.Sprintf("%s is forked to %s", p.Forkee.FullName, p.Repo.FullName)
+
+ return d.createPayload(p.Sender, title, "", p.Repo.HTMLURL, greenColor), nil
+}
+
+// Push implements PayloadConvertor Push method
+func (d discordConvertor) Push(p *api.PushPayload) (DiscordPayload, error) {
+ var (
+ branchName = git.RefName(p.Ref).ShortName()
+ commitDesc string
+ )
+
+ var titleLink string
+ if p.TotalCommits == 1 {
+ commitDesc = "1 new commit"
+ titleLink = p.Commits[0].URL
+ } else {
+ commitDesc = fmt.Sprintf("%d new commits", p.TotalCommits)
+ titleLink = p.CompareURL
+ }
+ if titleLink == "" {
+ titleLink = p.Repo.HTMLURL + "/src/" + util.PathEscapeSegments(branchName)
+ }
+
+ title := fmt.Sprintf("[%s:%s] %s", p.Repo.FullName, branchName, commitDesc)
+
+ var text string
+ // for each commit, generate attachment text
+ for i, commit := range p.Commits {
+ // limit the commit message display to just the summary, otherwise it would be hard to read
+ message := strings.TrimRight(strings.SplitN(commit.Message, "\n", 1)[0], "\r")
+
+ // a limit of 50 is set because GitHub does the same
+ if utf8.RuneCountInString(message) > 50 {
+ message = fmt.Sprintf("%.47s...", message)
+ }
+ text += fmt.Sprintf("[%s](%s) %s - %s", commit.ID[:7], commit.URL, message, commit.Author.Name)
+ // add linebreak to each commit but the last
+ if i < len(p.Commits)-1 {
+ text += "\n"
+ }
+ }
+
+ return d.createPayload(p.Sender, title, text, titleLink, greenColor), nil
+}
+
+// Issue implements PayloadConvertor Issue method
+func (d discordConvertor) Issue(p *api.IssuePayload) (DiscordPayload, error) {
+ title, _, text, color := getIssuesPayloadInfo(p, noneLinkFormatter, false)
+
+ return d.createPayload(p.Sender, title, text, p.Issue.HTMLURL, color), nil
+}
+
+// IssueComment implements PayloadConvertor IssueComment method
+func (d discordConvertor) IssueComment(p *api.IssueCommentPayload) (DiscordPayload, error) {
+ title, _, color := getIssueCommentPayloadInfo(p, noneLinkFormatter, false)
+
+ return d.createPayload(p.Sender, title, p.Comment.Body, p.Comment.HTMLURL, color), nil
+}
+
+// PullRequest implements PayloadConvertor PullRequest method
+func (d discordConvertor) PullRequest(p *api.PullRequestPayload) (DiscordPayload, error) {
+ title, _, text, color := getPullRequestPayloadInfo(p, noneLinkFormatter, false)
+
+ return d.createPayload(p.Sender, title, text, p.PullRequest.HTMLURL, color), nil
+}
+
+// Review implements PayloadConvertor Review method
+func (d discordConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (DiscordPayload, error) {
+ var text, title string
+ var color int
+ if p.Action == api.HookIssueReviewed {
+ action, err := parseHookPullRequestEventType(event)
+ if err != nil {
+ return DiscordPayload{}, err
+ }
+
+ title = fmt.Sprintf("[%s] Pull request review %s: #%d %s", p.Repository.FullName, action, p.Index, p.PullRequest.Title)
+ text = p.Review.Content
+
+ switch event {
+ case webhook_module.HookEventPullRequestReviewApproved:
+ color = greenColor
+ case webhook_module.HookEventPullRequestReviewRejected:
+ color = redColor
+ case webhook_module.HookEventPullRequestReviewComment:
+ color = greyColor
+ default:
+ color = yellowColor
+ }
+ }
+
+ return d.createPayload(p.Sender, title, text, p.PullRequest.HTMLURL, color), nil
+}
+
+// Repository implements PayloadConvertor Repository method
+func (d discordConvertor) Repository(p *api.RepositoryPayload) (DiscordPayload, error) {
+ var title, url string
+ var color int
+ switch p.Action {
+ case api.HookRepoCreated:
+ title = fmt.Sprintf("[%s] Repository created", p.Repository.FullName)
+ url = p.Repository.HTMLURL
+ color = greenColor
+ case api.HookRepoDeleted:
+ title = fmt.Sprintf("[%s] Repository deleted", p.Repository.FullName)
+ color = redColor
+ }
+
+ return d.createPayload(p.Sender, title, "", url, color), nil
+}
+
+// Wiki implements PayloadConvertor Wiki method
+func (d discordConvertor) Wiki(p *api.WikiPayload) (DiscordPayload, error) {
+ text, color, _ := getWikiPayloadInfo(p, noneLinkFormatter, false)
+ htmlLink := p.Repository.HTMLURL + "/wiki/" + url.PathEscape(p.Page)
+
+ var description string
+ if p.Action != api.HookWikiDeleted {
+ description = p.Comment
+ }
+
+ return d.createPayload(p.Sender, text, description, htmlLink, color), nil
+}
+
+// Release implements PayloadConvertor Release method
+func (d discordConvertor) Release(p *api.ReleasePayload) (DiscordPayload, error) {
+ text, color := getReleasePayloadInfo(p, noneLinkFormatter, false)
+
+ return d.createPayload(p.Sender, text, p.Release.Note, p.Release.HTMLURL, color), nil
+}
+
+func (d discordConvertor) Package(p *api.PackagePayload) (DiscordPayload, error) {
+ text, color := getPackagePayloadInfo(p, noneLinkFormatter, false)
+
+ return d.createPayload(p.Sender, text, "", p.Package.HTMLURL, color), nil
+}
+
+type discordConvertor struct {
+ Username string
+ AvatarURL string
+}
+
+var _ shared.PayloadConvertor[DiscordPayload] = discordConvertor{}
+
+func (discordHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
+ meta := &DiscordMeta{}
+ if err := json.Unmarshal([]byte(w.Meta), meta); err != nil {
+ return nil, nil, fmt.Errorf("discordHandler.NewRequest meta json: %w", err)
+ }
+ sc := discordConvertor{
+ Username: meta.Username,
+ AvatarURL: meta.IconURL,
+ }
+ return shared.NewJSONRequest(sc, w, t, true)
+}
+
+func parseHookPullRequestEventType(event webhook_module.HookEventType) (string, error) {
+ switch event {
+ case webhook_module.HookEventPullRequestReviewApproved:
+ return "approved", nil
+ case webhook_module.HookEventPullRequestReviewRejected:
+ return "rejected", nil
+ case webhook_module.HookEventPullRequestReviewComment:
+ return "comment", nil
+ default:
+ return "", errors.New("unknown event type")
+ }
+}
+
+func (d discordConvertor) createPayload(s *api.User, title, text, url string, color int) DiscordPayload {
+ if len([]rune(title)) > 256 {
+ title = fmt.Sprintf("%.253s...", title)
+ }
+ if len([]rune(text)) > 4096 {
+ text = fmt.Sprintf("%.4093s...", text)
+ }
+ return DiscordPayload{
+ Username: d.Username,
+ AvatarURL: d.AvatarURL,
+ Embeds: []DiscordEmbed{
+ {
+ Title: title,
+ Description: text,
+ URL: url,
+ Color: color,
+ Author: DiscordEmbedAuthor{
+ Name: s.UserName,
+ URL: setting.AppURL + s.UserName,
+ IconURL: s.AvatarURL,
+ },
+ },
+ },
+ }
+}
diff --git a/services/webhook/discord_test.go b/services/webhook/discord_test.go
new file mode 100644
index 0000000..680f780
--- /dev/null
+++ b/services/webhook/discord_test.go
@@ -0,0 +1,348 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "testing"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDiscordPayload(t *testing.T) {
+ dc := discordConvertor{}
+
+ t.Run("Create", func(t *testing.T) {
+ p := createTestPayload()
+
+ pl, err := dc.Create(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "[test/repo] branch test created", pl.Embeds[0].Title)
+ assert.Empty(t, pl.Embeds[0].Description)
+ assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.Embeds[0].URL)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ p := deleteTestPayload()
+
+ pl, err := dc.Delete(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "[test/repo] branch test deleted", pl.Embeds[0].Title)
+ assert.Empty(t, pl.Embeds[0].Description)
+ assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.Embeds[0].URL)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+ })
+
+ t.Run("Fork", func(t *testing.T) {
+ p := forkTestPayload()
+
+ pl, err := dc.Fork(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "test/repo2 is forked to test/repo", pl.Embeds[0].Title)
+ assert.Empty(t, pl.Embeds[0].Description)
+ assert.Equal(t, "http://localhost:3000/test/repo", pl.Embeds[0].URL)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+ })
+
+ t.Run("Push", func(t *testing.T) {
+ p := pushTestPayload()
+
+ pl, err := dc.Push(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "[test/repo:test] 2 new commits", pl.Embeds[0].Title)
+ assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", pl.Embeds[0].Description)
+ assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.Embeds[0].URL)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+ })
+
+ t.Run("PushWithLongCommitMessage", func(t *testing.T) {
+ p := pushTestMultilineCommitMessagePayload()
+
+ pl, err := dc.Push(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "[test/repo:test] 2 new commits", pl.Embeds[0].Title)
+ assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) This is a commit summary ⚠️⚠️⚠️⚠️ containing 你好... - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) This is a commit summary ⚠️⚠️⚠️⚠️ containing 你好... - user1", pl.Embeds[0].Description)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+ })
+
+ t.Run("Issue", func(t *testing.T) {
+ p := issueTestPayload()
+
+ p.Action = api.HookIssueOpened
+ pl, err := dc.Issue(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "[test/repo] Issue opened: #2 crash", pl.Embeds[0].Title)
+ assert.Equal(t, "issue body", pl.Embeds[0].Description)
+ assert.Equal(t, "http://localhost:3000/test/repo/issues/2", pl.Embeds[0].URL)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+
+ p.Action = api.HookIssueClosed
+ pl, err = dc.Issue(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "[test/repo] Issue closed: #2 crash", pl.Embeds[0].Title)
+ assert.Empty(t, pl.Embeds[0].Description)
+ assert.Equal(t, "http://localhost:3000/test/repo/issues/2", pl.Embeds[0].URL)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+
+ j, err := json.Marshal(pl)
+ require.NoError(t, err)
+
+ unsetFields := struct {
+ Content *string `json:"content"`
+ TTS *bool `json:"tts"`
+ Wait *bool `json:"wait"`
+ Fields []any `json:"fields"`
+ Footer struct {
+ Text *string `json:"text"`
+ } `json:"footer"`
+ }{}
+
+ err = json.Unmarshal(j, &unsetFields)
+ require.NoError(t, err)
+ assert.Nil(t, unsetFields.Content)
+ assert.Nil(t, unsetFields.TTS)
+ assert.Nil(t, unsetFields.Wait)
+ assert.Nil(t, unsetFields.Fields)
+ assert.Nil(t, unsetFields.Footer.Text)
+ })
+
+ t.Run("Issue with long title", func(t *testing.T) {
+ p := issueTestPayloadWithLongTitle()
+
+ p.Action = api.HookIssueOpened
+ pl, err := dc.Issue(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Len(t, pl.Embeds[0].Title, 256)
+ })
+
+ t.Run("Issue with long body", func(t *testing.T) {
+ p := issueTestPayloadWithLongBody()
+
+ p.Action = api.HookIssueOpened
+ pl, err := dc.Issue(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Len(t, pl.Embeds[0].Description, 4096)
+ })
+
+ t.Run("IssueComment", func(t *testing.T) {
+ p := issueCommentTestPayload()
+
+ pl, err := dc.IssueComment(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "[test/repo] New comment on issue #2 crash", pl.Embeds[0].Title)
+ assert.Equal(t, "more info needed", pl.Embeds[0].Description)
+ assert.Equal(t, "http://localhost:3000/test/repo/issues/2#issuecomment-4", pl.Embeds[0].URL)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+ })
+
+ t.Run("PullRequest", func(t *testing.T) {
+ p := pullRequestTestPayload()
+
+ pl, err := dc.PullRequest(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "[test/repo] Pull request opened: #12 Fix bug", pl.Embeds[0].Title)
+ assert.Equal(t, "fixes bug #2", pl.Embeds[0].Description)
+ assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", pl.Embeds[0].URL)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+ })
+
+ t.Run("PullRequestComment", func(t *testing.T) {
+ p := pullRequestCommentTestPayload()
+
+ pl, err := dc.IssueComment(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "[test/repo] New comment on pull request #12 Fix bug", pl.Embeds[0].Title)
+ assert.Equal(t, "changes requested", pl.Embeds[0].Description)
+ assert.Equal(t, "http://localhost:3000/test/repo/pulls/12#issuecomment-4", pl.Embeds[0].URL)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+ })
+
+ t.Run("Review", func(t *testing.T) {
+ p := pullRequestTestPayload()
+ p.Action = api.HookIssueReviewed
+
+ pl, err := dc.Review(p, webhook_module.HookEventPullRequestReviewApproved)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "[test/repo] Pull request review approved: #12 Fix bug", pl.Embeds[0].Title)
+ assert.Equal(t, "good job", pl.Embeds[0].Description)
+ assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", pl.Embeds[0].URL)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+ })
+
+ t.Run("Repository", func(t *testing.T) {
+ p := repositoryTestPayload()
+
+ pl, err := dc.Repository(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "[test/repo] Repository created", pl.Embeds[0].Title)
+ assert.Empty(t, pl.Embeds[0].Description)
+ assert.Equal(t, "http://localhost:3000/test/repo", pl.Embeds[0].URL)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+ })
+
+ t.Run("Package", func(t *testing.T) {
+ p := packageTestPayload()
+
+ pl, err := dc.Package(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "Package created: GiteaContainer:latest", pl.Embeds[0].Title)
+ assert.Empty(t, pl.Embeds[0].Description)
+ assert.Equal(t, "http://localhost:3000/user1/-/packages/container/GiteaContainer/latest", pl.Embeds[0].URL)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+ })
+
+ t.Run("Wiki", func(t *testing.T) {
+ p := wikiTestPayload()
+
+ p.Action = api.HookWikiCreated
+ pl, err := dc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment)", pl.Embeds[0].Title)
+ assert.Equal(t, "Wiki change comment", pl.Embeds[0].Description)
+ assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.Embeds[0].URL)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+
+ p.Action = api.HookWikiEdited
+ pl, err = dc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment)", pl.Embeds[0].Title)
+ assert.Equal(t, "Wiki change comment", pl.Embeds[0].Description)
+ assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.Embeds[0].URL)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+
+ p.Action = api.HookWikiDeleted
+ pl, err = dc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "[test/repo] Wiki page 'index' deleted", pl.Embeds[0].Title)
+ assert.Empty(t, pl.Embeds[0].Description)
+ assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.Embeds[0].URL)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+ })
+
+ t.Run("Release", func(t *testing.T) {
+ p := pullReleaseTestPayload()
+
+ pl, err := dc.Release(p)
+ require.NoError(t, err)
+
+ assert.Len(t, pl.Embeds, 1)
+ assert.Equal(t, "[test/repo] Release created: v1.0", pl.Embeds[0].Title)
+ assert.Equal(t, "Note of first stable release", pl.Embeds[0].Description)
+ assert.Equal(t, "http://localhost:3000/test/repo/releases/tag/v1.0", pl.Embeds[0].URL)
+ assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name)
+ assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL)
+ assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL)
+ })
+}
+
+func TestDiscordJSONPayload(t *testing.T) {
+ p := pushTestPayload()
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ hook := &webhook_model.Webhook{
+ RepoID: 3,
+ IsActive: true,
+ Type: webhook_module.DISCORD,
+ URL: "https://discord.example.com/",
+ Meta: `{}`,
+ HTTPMethod: "POST",
+ }
+ task := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventPush,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ req, reqBody, err := discordHandler{}.NewRequest(context.Background(), hook, task)
+ require.NotNil(t, req)
+ require.NotNil(t, reqBody)
+ require.NoError(t, err)
+
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, "https://discord.example.com/", req.URL.String())
+ assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256"))
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+ var body DiscordPayload
+ err = json.NewDecoder(req.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", body.Embeds[0].Description)
+}
diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go
new file mode 100644
index 0000000..f77c3bb
--- /dev/null
+++ b/services/webhook/feishu.go
@@ -0,0 +1,200 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "net/http"
+ "strings"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/git"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/webhook/shared"
+)
+
+type feishuHandler struct{}
+
+func (feishuHandler) Type() webhook_module.HookType { return webhook_module.FEISHU }
+func (feishuHandler) Icon(size int) template.HTML { return shared.ImgIcon("feishu.png", size) }
+
+func (feishuHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
+ var form struct {
+ forms.WebhookCoreForm
+ PayloadURL string `binding:"Required;ValidUrl"`
+ }
+ bind(&form)
+
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: form.PayloadURL,
+ ContentType: webhook_model.ContentTypeJSON,
+ Secret: "",
+ HTTPMethod: http.MethodPost,
+ Metadata: nil,
+ }
+}
+
+func (feishuHandler) Metadata(*webhook_model.Webhook) any { return nil }
+
+type (
+ // FeishuPayload represents
+ FeishuPayload struct {
+ MsgType string `json:"msg_type"` // text / post / image / share_chat / interactive / file /audio / media
+ Content struct {
+ Text string `json:"text"`
+ } `json:"content"`
+ }
+)
+
+func newFeishuTextPayload(text string) FeishuPayload {
+ return FeishuPayload{
+ MsgType: "text",
+ Content: struct {
+ Text string `json:"text"`
+ }{
+ Text: strings.TrimSpace(text),
+ },
+ }
+}
+
+// Create implements PayloadConvertor Create method
+func (fc feishuConvertor) Create(p *api.CreatePayload) (FeishuPayload, error) {
+ // created tag/branch
+ refName := git.RefName(p.Ref).ShortName()
+ text := fmt.Sprintf("[%s] %s %s created", p.Repo.FullName, p.RefType, refName)
+
+ return newFeishuTextPayload(text), nil
+}
+
+// Delete implements PayloadConvertor Delete method
+func (fc feishuConvertor) Delete(p *api.DeletePayload) (FeishuPayload, error) {
+ // created tag/branch
+ refName := git.RefName(p.Ref).ShortName()
+ text := fmt.Sprintf("[%s] %s %s deleted", p.Repo.FullName, p.RefType, refName)
+
+ return newFeishuTextPayload(text), nil
+}
+
+// Fork implements PayloadConvertor Fork method
+func (fc feishuConvertor) Fork(p *api.ForkPayload) (FeishuPayload, error) {
+ text := fmt.Sprintf("%s is forked to %s", p.Forkee.FullName, p.Repo.FullName)
+
+ return newFeishuTextPayload(text), nil
+}
+
+// Push implements PayloadConvertor Push method
+func (fc feishuConvertor) Push(p *api.PushPayload) (FeishuPayload, error) {
+ var (
+ branchName = git.RefName(p.Ref).ShortName()
+ commitDesc string
+ )
+
+ text := fmt.Sprintf("[%s:%s] %s\r\n", p.Repo.FullName, branchName, commitDesc)
+ // for each commit, generate attachment text
+ for i, commit := range p.Commits {
+ var authorName string
+ if commit.Author != nil {
+ authorName = " - " + commit.Author.Name
+ }
+ text += fmt.Sprintf("[%s](%s) %s", commit.ID[:7], commit.URL,
+ strings.TrimRight(commit.Message, "\r\n")) + authorName
+ // add linebreak to each commit but the last
+ if i < len(p.Commits)-1 {
+ text += "\r\n"
+ }
+ }
+
+ return newFeishuTextPayload(text), nil
+}
+
+// Issue implements PayloadConvertor Issue method
+func (fc feishuConvertor) Issue(p *api.IssuePayload) (FeishuPayload, error) {
+ title, link, by, operator, result, assignees := getIssuesInfo(p)
+ if assignees != "" {
+ if p.Action == api.HookIssueAssigned || p.Action == api.HookIssueUnassigned || p.Action == api.HookIssueMilestoned {
+ return newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, result, assignees, p.Issue.Body)), nil
+ }
+ return newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, assignees, p.Issue.Body)), nil
+ }
+ return newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, p.Issue.Body)), nil
+}
+
+// IssueComment implements PayloadConvertor IssueComment method
+func (fc feishuConvertor) IssueComment(p *api.IssueCommentPayload) (FeishuPayload, error) {
+ title, link, by, operator := getIssuesCommentInfo(p)
+ return newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, p.Comment.Body)), nil
+}
+
+// PullRequest implements PayloadConvertor PullRequest method
+func (fc feishuConvertor) PullRequest(p *api.PullRequestPayload) (FeishuPayload, error) {
+ title, link, by, operator, result, assignees := getPullRequestInfo(p)
+ if assignees != "" {
+ if p.Action == api.HookIssueAssigned || p.Action == api.HookIssueUnassigned || p.Action == api.HookIssueMilestoned {
+ return newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, result, assignees, p.PullRequest.Body)), nil
+ }
+ return newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, assignees, p.PullRequest.Body)), nil
+ }
+ return newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, p.PullRequest.Body)), nil
+}
+
+// Review implements PayloadConvertor Review method
+func (fc feishuConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (FeishuPayload, error) {
+ action, err := parseHookPullRequestEventType(event)
+ if err != nil {
+ return FeishuPayload{}, err
+ }
+
+ title := fmt.Sprintf("[%s] Pull request review %s : #%d %s", p.Repository.FullName, action, p.Index, p.PullRequest.Title)
+ text := p.Review.Content
+
+ return newFeishuTextPayload(title + "\r\n\r\n" + text), nil
+}
+
+// Repository implements PayloadConvertor Repository method
+func (fc feishuConvertor) Repository(p *api.RepositoryPayload) (FeishuPayload, error) {
+ var text string
+ switch p.Action {
+ case api.HookRepoCreated:
+ text = fmt.Sprintf("[%s] Repository created", p.Repository.FullName)
+ return newFeishuTextPayload(text), nil
+ case api.HookRepoDeleted:
+ text = fmt.Sprintf("[%s] Repository deleted", p.Repository.FullName)
+ return newFeishuTextPayload(text), nil
+ }
+
+ return FeishuPayload{}, nil
+}
+
+// Wiki implements PayloadConvertor Wiki method
+func (fc feishuConvertor) Wiki(p *api.WikiPayload) (FeishuPayload, error) {
+ text, _, _ := getWikiPayloadInfo(p, noneLinkFormatter, true)
+
+ return newFeishuTextPayload(text), nil
+}
+
+// Release implements PayloadConvertor Release method
+func (fc feishuConvertor) Release(p *api.ReleasePayload) (FeishuPayload, error) {
+ text, _ := getReleasePayloadInfo(p, noneLinkFormatter, true)
+
+ return newFeishuTextPayload(text), nil
+}
+
+func (fc feishuConvertor) Package(p *api.PackagePayload) (FeishuPayload, error) {
+ text, _ := getPackagePayloadInfo(p, noneLinkFormatter, true)
+
+ return newFeishuTextPayload(text), nil
+}
+
+type feishuConvertor struct{}
+
+var _ shared.PayloadConvertor[FeishuPayload] = feishuConvertor{}
+
+func (feishuHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
+ return shared.NewJSONRequest(feishuConvertor{}, w, t, true)
+}
diff --git a/services/webhook/feishu_test.go b/services/webhook/feishu_test.go
new file mode 100644
index 0000000..9744571
--- /dev/null
+++ b/services/webhook/feishu_test.go
@@ -0,0 +1,193 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "testing"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/json"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFeishuPayload(t *testing.T) {
+ fc := feishuConvertor{}
+ t.Run("Create", func(t *testing.T) {
+ p := createTestPayload()
+
+ pl, err := fc.Create(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `[test/repo] branch test created`, pl.Content.Text)
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ p := deleteTestPayload()
+
+ pl, err := fc.Delete(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `[test/repo] branch test deleted`, pl.Content.Text)
+ })
+
+ t.Run("Fork", func(t *testing.T) {
+ p := forkTestPayload()
+
+ pl, err := fc.Fork(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `test/repo2 is forked to test/repo`, pl.Content.Text)
+ })
+
+ t.Run("Push", func(t *testing.T) {
+ p := pushTestPayload()
+
+ pl, err := fc.Push(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo:test] \r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", pl.Content.Text)
+ })
+
+ t.Run("Issue", func(t *testing.T) {
+ p := issueTestPayload()
+
+ p.Action = api.HookIssueOpened
+ pl, err := fc.Issue(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[Issue-test/repo #2]: opened\ncrash\nhttp://localhost:3000/test/repo/issues/2\nIssue by user1\nOperator: user1\nAssignees: user1\n\nissue body", pl.Content.Text)
+
+ p.Action = api.HookIssueClosed
+ pl, err = fc.Issue(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[Issue-test/repo #2]: closed\ncrash\nhttp://localhost:3000/test/repo/issues/2\nIssue by user1\nOperator: user1\nAssignees: user1\n\nissue body", pl.Content.Text)
+ })
+
+ t.Run("IssueComment", func(t *testing.T) {
+ p := issueCommentTestPayload()
+
+ pl, err := fc.IssueComment(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[Comment-test/repo #2]: created\ncrash\nhttp://localhost:3000/test/repo/issues/2\nIssue by user1\nOperator: user1\n\nmore info needed", pl.Content.Text)
+ })
+
+ t.Run("PullRequest", func(t *testing.T) {
+ p := pullRequestTestPayload()
+
+ pl, err := fc.PullRequest(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[PullRequest-test/repo #12]: opened\nFix bug\nhttp://localhost:3000/test/repo/pulls/12\nPullRequest by user1\nOperator: user1\nAssignees: user1\n\nfixes bug #2", pl.Content.Text)
+ })
+
+ t.Run("PullRequestComment", func(t *testing.T) {
+ p := pullRequestCommentTestPayload()
+
+ pl, err := fc.IssueComment(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[Comment-test/repo #12]: created\nFix bug\nhttp://localhost:3000/test/repo/pulls/12\nPullRequest by user1\nOperator: user1\n\nchanges requested", pl.Content.Text)
+ })
+
+ t.Run("Review", func(t *testing.T) {
+ p := pullRequestTestPayload()
+ p.Action = api.HookIssueReviewed
+
+ pl, err := fc.Review(p, webhook_module.HookEventPullRequestReviewApproved)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Pull request review approved : #12 Fix bug\r\n\r\ngood job", pl.Content.Text)
+ })
+
+ t.Run("Repository", func(t *testing.T) {
+ p := repositoryTestPayload()
+
+ pl, err := fc.Repository(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Repository created", pl.Content.Text)
+ })
+
+ t.Run("Package", func(t *testing.T) {
+ p := packageTestPayload()
+
+ pl, err := fc.Package(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "Package created: GiteaContainer:latest by user1", pl.Content.Text)
+ })
+
+ t.Run("Wiki", func(t *testing.T) {
+ p := wikiTestPayload()
+
+ p.Action = api.HookWikiCreated
+ pl, err := fc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment) by user1", pl.Content.Text)
+
+ p.Action = api.HookWikiEdited
+ pl, err = fc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment) by user1", pl.Content.Text)
+
+ p.Action = api.HookWikiDeleted
+ pl, err = fc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Wiki page 'index' deleted by user1", pl.Content.Text)
+ })
+
+ t.Run("Release", func(t *testing.T) {
+ p := pullReleaseTestPayload()
+
+ pl, err := fc.Release(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Release created: v1.0 by user1", pl.Content.Text)
+ })
+}
+
+func TestFeishuJSONPayload(t *testing.T) {
+ p := pushTestPayload()
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ hook := &webhook_model.Webhook{
+ RepoID: 3,
+ IsActive: true,
+ Type: webhook_module.FEISHU,
+ URL: "https://feishu.example.com/",
+ Meta: `{}`,
+ HTTPMethod: "POST",
+ }
+ task := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventPush,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ req, reqBody, err := feishuHandler{}.NewRequest(context.Background(), hook, task)
+ require.NotNil(t, req)
+ require.NotNil(t, reqBody)
+ require.NoError(t, err)
+
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, "https://feishu.example.com/", req.URL.String())
+ assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256"))
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+ var body FeishuPayload
+ err = json.NewDecoder(req.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Equal(t, "[test/repo:test] \r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", body.Content.Text)
+}
diff --git a/services/webhook/general.go b/services/webhook/general.go
new file mode 100644
index 0000000..c41f58f
--- /dev/null
+++ b/services/webhook/general.go
@@ -0,0 +1,354 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "fmt"
+ "html"
+ "net/url"
+ "strings"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+)
+
+type linkFormatter = func(string, string) string
+
+// noneLinkFormatter does not create a link but just returns the text
+func noneLinkFormatter(url, text string) string {
+ return text
+}
+
+// htmlLinkFormatter creates a HTML link
+func htmlLinkFormatter(url, text string) string {
+ return fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(url), html.EscapeString(text))
+}
+
+// getPullRequestInfo gets the information for a pull request
+func getPullRequestInfo(p *api.PullRequestPayload) (title, link, by, operator, operateResult, assignees string) {
+ title = fmt.Sprintf("[PullRequest-%s #%d]: %s\n%s", p.Repository.FullName, p.PullRequest.Index, p.Action, p.PullRequest.Title)
+ assignList := p.PullRequest.Assignees
+ assignStringList := make([]string, len(assignList))
+
+ for i, user := range assignList {
+ assignStringList[i] = user.UserName
+ }
+ if p.Action == api.HookIssueAssigned {
+ operateResult = fmt.Sprintf("%s assign this to %s", p.Sender.UserName, assignList[len(assignList)-1].UserName)
+ } else if p.Action == api.HookIssueUnassigned {
+ operateResult = fmt.Sprintf("%s unassigned this for someone", p.Sender.UserName)
+ } else if p.Action == api.HookIssueMilestoned {
+ operateResult = fmt.Sprintf("%s/milestone/%d", p.Repository.HTMLURL, p.PullRequest.Milestone.ID)
+ }
+ link = p.PullRequest.HTMLURL
+ by = fmt.Sprintf("PullRequest by %s", p.PullRequest.Poster.UserName)
+ if len(assignStringList) > 0 {
+ assignees = fmt.Sprintf("Assignees: %s", strings.Join(assignStringList, ", "))
+ }
+ operator = fmt.Sprintf("Operator: %s", p.Sender.UserName)
+ return title, link, by, operator, operateResult, assignees
+}
+
+// getIssuesInfo gets the information for an issue
+func getIssuesInfo(p *api.IssuePayload) (issueTitle, link, by, operator, operateResult, assignees string) {
+ issueTitle = fmt.Sprintf("[Issue-%s #%d]: %s\n%s", p.Repository.FullName, p.Issue.Index, p.Action, p.Issue.Title)
+ assignList := p.Issue.Assignees
+ assignStringList := make([]string, len(assignList))
+
+ for i, user := range assignList {
+ assignStringList[i] = user.UserName
+ }
+ if p.Action == api.HookIssueAssigned {
+ operateResult = fmt.Sprintf("%s assign this to %s", p.Sender.UserName, assignList[len(assignList)-1].UserName)
+ } else if p.Action == api.HookIssueUnassigned {
+ operateResult = fmt.Sprintf("%s unassigned this for someone", p.Sender.UserName)
+ } else if p.Action == api.HookIssueMilestoned {
+ operateResult = fmt.Sprintf("%s/milestone/%d", p.Repository.HTMLURL, p.Issue.Milestone.ID)
+ }
+ link = p.Issue.HTMLURL
+ by = fmt.Sprintf("Issue by %s", p.Issue.Poster.UserName)
+ if len(assignStringList) > 0 {
+ assignees = fmt.Sprintf("Assignees: %s", strings.Join(assignStringList, ", "))
+ }
+ operator = fmt.Sprintf("Operator: %s", p.Sender.UserName)
+ return issueTitle, link, by, operator, operateResult, assignees
+}
+
+// getIssuesCommentInfo gets the information for a comment
+func getIssuesCommentInfo(p *api.IssueCommentPayload) (title, link, by, operator string) {
+ title = fmt.Sprintf("[Comment-%s #%d]: %s\n%s", p.Repository.FullName, p.Issue.Index, p.Action, p.Issue.Title)
+ link = p.Issue.HTMLURL
+ if p.IsPull {
+ by = fmt.Sprintf("PullRequest by %s", p.Issue.Poster.UserName)
+ } else {
+ by = fmt.Sprintf("Issue by %s", p.Issue.Poster.UserName)
+ }
+ operator = fmt.Sprintf("Operator: %s", p.Sender.UserName)
+ return title, link, by, operator
+}
+
+func getIssuesPayloadInfo(p *api.IssuePayload, linkFormatter linkFormatter, withSender bool) (string, string, string, int) {
+ repoLink := linkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
+ issueTitle := fmt.Sprintf("#%d %s", p.Index, p.Issue.Title)
+ titleLink := linkFormatter(fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Index), issueTitle)
+ var text string
+ color := yellowColor
+
+ switch p.Action {
+ case api.HookIssueOpened:
+ text = fmt.Sprintf("[%s] Issue opened: %s", repoLink, titleLink)
+ color = orangeColor
+ case api.HookIssueClosed:
+ text = fmt.Sprintf("[%s] Issue closed: %s", repoLink, titleLink)
+ color = redColor
+ case api.HookIssueReOpened:
+ text = fmt.Sprintf("[%s] Issue re-opened: %s", repoLink, titleLink)
+ case api.HookIssueEdited:
+ text = fmt.Sprintf("[%s] Issue edited: %s", repoLink, titleLink)
+ case api.HookIssueAssigned:
+ list := make([]string, len(p.Issue.Assignees))
+ for i, user := range p.Issue.Assignees {
+ list[i] = linkFormatter(setting.AppURL+url.PathEscape(user.UserName), user.UserName)
+ }
+ text = fmt.Sprintf("[%s] Issue assigned to %s: %s", repoLink, strings.Join(list, ", "), titleLink)
+ color = greenColor
+ case api.HookIssueUnassigned:
+ text = fmt.Sprintf("[%s] Issue unassigned: %s", repoLink, titleLink)
+ case api.HookIssueLabelUpdated:
+ text = fmt.Sprintf("[%s] Issue labels updated: %s", repoLink, titleLink)
+ case api.HookIssueLabelCleared:
+ text = fmt.Sprintf("[%s] Issue labels cleared: %s", repoLink, titleLink)
+ case api.HookIssueSynchronized:
+ text = fmt.Sprintf("[%s] Issue synchronized: %s", repoLink, titleLink)
+ case api.HookIssueMilestoned:
+ mileStoneLink := fmt.Sprintf("%s/milestone/%d", p.Repository.HTMLURL, p.Issue.Milestone.ID)
+ text = fmt.Sprintf("[%s] Issue milestoned to %s: %s", repoLink,
+ linkFormatter(mileStoneLink, p.Issue.Milestone.Title), titleLink)
+ case api.HookIssueDemilestoned:
+ text = fmt.Sprintf("[%s] Issue milestone cleared: %s", repoLink, titleLink)
+ }
+ if withSender {
+ text += fmt.Sprintf(" by %s", linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName))
+ }
+
+ var attachmentText string
+ if p.Action == api.HookIssueOpened || p.Action == api.HookIssueEdited {
+ attachmentText = p.Issue.Body
+ }
+
+ return text, issueTitle, attachmentText, color
+}
+
+func getPullRequestPayloadInfo(p *api.PullRequestPayload, linkFormatter linkFormatter, withSender bool) (string, string, string, int) {
+ repoLink := linkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
+ issueTitle := fmt.Sprintf("#%d %s", p.Index, p.PullRequest.Title)
+ titleLink := linkFormatter(p.PullRequest.URL, issueTitle)
+ var text string
+ var attachmentText string
+ color := yellowColor
+
+ switch p.Action {
+ case api.HookIssueOpened:
+ text = fmt.Sprintf("[%s] Pull request opened: %s", repoLink, titleLink)
+ attachmentText = p.PullRequest.Body
+ color = greenColor
+ case api.HookIssueClosed:
+ if p.PullRequest.HasMerged {
+ text = fmt.Sprintf("[%s] Pull request merged: %s", repoLink, titleLink)
+ color = purpleColor
+ } else {
+ text = fmt.Sprintf("[%s] Pull request closed: %s", repoLink, titleLink)
+ color = redColor
+ }
+ case api.HookIssueReOpened:
+ text = fmt.Sprintf("[%s] Pull request re-opened: %s", repoLink, titleLink)
+ case api.HookIssueEdited:
+ text = fmt.Sprintf("[%s] Pull request edited: %s", repoLink, titleLink)
+ attachmentText = p.PullRequest.Body
+ case api.HookIssueAssigned:
+ list := make([]string, len(p.PullRequest.Assignees))
+ for i, user := range p.PullRequest.Assignees {
+ list[i] = linkFormatter(setting.AppURL+user.UserName, user.UserName)
+ }
+ text = fmt.Sprintf("[%s] Pull request assigned to %s: %s", repoLink,
+ strings.Join(list, ", "), titleLink)
+ color = greenColor
+ case api.HookIssueUnassigned:
+ text = fmt.Sprintf("[%s] Pull request unassigned: %s", repoLink, titleLink)
+ case api.HookIssueLabelUpdated:
+ text = fmt.Sprintf("[%s] Pull request labels updated: %s", repoLink, titleLink)
+ case api.HookIssueLabelCleared:
+ text = fmt.Sprintf("[%s] Pull request labels cleared: %s", repoLink, titleLink)
+ case api.HookIssueSynchronized:
+ text = fmt.Sprintf("[%s] Pull request synchronized: %s", repoLink, titleLink)
+ case api.HookIssueMilestoned:
+ mileStoneLink := fmt.Sprintf("%s/milestone/%d", p.Repository.HTMLURL, p.PullRequest.Milestone.ID)
+ text = fmt.Sprintf("[%s] Pull request milestoned to %s: %s", repoLink,
+ linkFormatter(mileStoneLink, p.PullRequest.Milestone.Title), titleLink)
+ case api.HookIssueDemilestoned:
+ text = fmt.Sprintf("[%s] Pull request milestone cleared: %s", repoLink, titleLink)
+ case api.HookIssueReviewed:
+ text = fmt.Sprintf("[%s] Pull request reviewed: %s", repoLink, titleLink)
+ attachmentText = p.Review.Content
+ case api.HookIssueReviewRequested:
+ text = fmt.Sprintf("[%s] Pull request review requested: %s", repoLink, titleLink)
+ case api.HookIssueReviewRequestRemoved:
+ text = fmt.Sprintf("[%s] Pull request review request removed: %s", repoLink, titleLink)
+ }
+ if withSender {
+ text += fmt.Sprintf(" by %s", linkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName))
+ }
+
+ return text, issueTitle, attachmentText, color
+}
+
+func getReleasePayloadInfo(p *api.ReleasePayload, linkFormatter linkFormatter, withSender bool) (text string, color int) {
+ repoLink := linkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
+ refLink := linkFormatter(p.Repository.HTMLURL+"/releases/tag/"+util.PathEscapeSegments(p.Release.TagName), p.Release.TagName)
+
+ switch p.Action {
+ case api.HookReleasePublished:
+ text = fmt.Sprintf("[%s] Release created: %s", repoLink, refLink)
+ color = greenColor
+ case api.HookReleaseUpdated:
+ text = fmt.Sprintf("[%s] Release updated: %s", repoLink, refLink)
+ color = yellowColor
+ case api.HookReleaseDeleted:
+ text = fmt.Sprintf("[%s] Release deleted: %s", repoLink, refLink)
+ color = redColor
+ }
+ if withSender {
+ text += fmt.Sprintf(" by %s", linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName))
+ }
+
+ return text, color
+}
+
+func getWikiPayloadInfo(p *api.WikiPayload, linkFormatter linkFormatter, withSender bool) (string, int, string) {
+ repoLink := linkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
+ pageLink := linkFormatter(p.Repository.HTMLURL+"/wiki/"+url.PathEscape(p.Page), p.Page)
+
+ var text string
+ color := greenColor
+
+ switch p.Action {
+ case api.HookWikiCreated:
+ text = fmt.Sprintf("[%s] New wiki page '%s'", repoLink, pageLink)
+ case api.HookWikiEdited:
+ text = fmt.Sprintf("[%s] Wiki page '%s' edited", repoLink, pageLink)
+ color = yellowColor
+ case api.HookWikiDeleted:
+ text = fmt.Sprintf("[%s] Wiki page '%s' deleted", repoLink, pageLink)
+ color = redColor
+ }
+
+ if p.Action != api.HookWikiDeleted && p.Comment != "" {
+ text += fmt.Sprintf(" (%s)", p.Comment)
+ }
+
+ if withSender {
+ text += fmt.Sprintf(" by %s", linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName))
+ }
+
+ return text, color, pageLink
+}
+
+func getIssueCommentPayloadInfo(p *api.IssueCommentPayload, linkFormatter linkFormatter, withSender bool) (string, string, int) {
+ repoLink := linkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
+ issueTitle := fmt.Sprintf("#%d %s", p.Issue.Index, p.Issue.Title)
+
+ var text, typ, titleLink string
+ color := yellowColor
+
+ if p.IsPull {
+ typ = "pull request"
+ titleLink = linkFormatter(p.Comment.PRURL, issueTitle)
+ } else {
+ typ = "issue"
+ titleLink = linkFormatter(p.Comment.IssueURL, issueTitle)
+ }
+
+ switch p.Action {
+ case api.HookIssueCommentCreated:
+ text = fmt.Sprintf("[%s] New comment on %s %s", repoLink, typ, titleLink)
+ if p.IsPull {
+ color = greenColorLight
+ } else {
+ color = orangeColorLight
+ }
+ case api.HookIssueCommentEdited:
+ text = fmt.Sprintf("[%s] Comment edited on %s %s", repoLink, typ, titleLink)
+ case api.HookIssueCommentDeleted:
+ text = fmt.Sprintf("[%s] Comment deleted on %s %s", repoLink, typ, titleLink)
+ color = redColor
+ }
+ if withSender {
+ text += fmt.Sprintf(" by %s", linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName))
+ }
+
+ return text, issueTitle, color
+}
+
+func getPackagePayloadInfo(p *api.PackagePayload, linkFormatter linkFormatter, withSender bool) (text string, color int) {
+ refLink := linkFormatter(p.Package.HTMLURL, p.Package.Name+":"+p.Package.Version)
+
+ switch p.Action {
+ case api.HookPackageCreated:
+ text = fmt.Sprintf("Package created: %s", refLink)
+ color = greenColor
+ case api.HookPackageDeleted:
+ text = fmt.Sprintf("Package deleted: %s", refLink)
+ color = redColor
+ }
+ if withSender {
+ text += fmt.Sprintf(" by %s", linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName))
+ }
+
+ return text, color
+}
+
+// ToHook convert models.Webhook to api.Hook
+// This function is not part of the convert package to prevent an import cycle
+func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) {
+ // config is deprecated, but kept for compatibility
+ config := map[string]string{
+ "url": w.URL,
+ "content_type": w.ContentType.Name(),
+ }
+ if w.Type == webhook_module.SLACK {
+ if s, ok := (slackHandler{}.Metadata(w)).(*SlackMeta); ok {
+ config["channel"] = s.Channel
+ config["username"] = s.Username
+ config["icon_url"] = s.IconURL
+ config["color"] = s.Color
+ }
+ }
+
+ authorizationHeader, err := w.HeaderAuthorization()
+ if err != nil {
+ return nil, err
+ }
+ var metadata any
+ if handler := GetWebhookHandler(w.Type); handler != nil {
+ metadata = handler.Metadata(w)
+ }
+
+ return &api.Hook{
+ ID: w.ID,
+ Type: w.Type,
+ BranchFilter: w.BranchFilter,
+ URL: w.URL,
+ Config: config,
+ Events: w.EventsArray(),
+ AuthorizationHeader: authorizationHeader,
+ ContentType: w.ContentType.Name(),
+ Metadata: metadata,
+ Active: w.IsActive,
+ Updated: w.UpdatedUnix.AsTime(),
+ Created: w.CreatedUnix.AsTime(),
+ }, nil
+}
diff --git a/services/webhook/general_test.go b/services/webhook/general_test.go
new file mode 100644
index 0000000..6dcd787
--- /dev/null
+++ b/services/webhook/general_test.go
@@ -0,0 +1,673 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "strings"
+ "testing"
+
+ api "code.gitea.io/gitea/modules/structs"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func createTestPayload() *api.CreatePayload {
+ return &api.CreatePayload{
+ Sha: "2020558fe2e34debb818a514715839cabd25e777",
+ Ref: "refs/heads/test",
+ RefType: "branch",
+ Repo: &api.Repository{
+ HTMLURL: "http://localhost:3000/test/repo",
+ Name: "repo",
+ FullName: "test/repo",
+ },
+ Sender: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ }
+}
+
+func deleteTestPayload() *api.DeletePayload {
+ return &api.DeletePayload{
+ Ref: "refs/heads/test",
+ RefType: "branch",
+ Repo: &api.Repository{
+ HTMLURL: "http://localhost:3000/test/repo",
+ Name: "repo",
+ FullName: "test/repo",
+ },
+ Sender: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ }
+}
+
+func forkTestPayload() *api.ForkPayload {
+ return &api.ForkPayload{
+ Forkee: &api.Repository{
+ HTMLURL: "http://localhost:3000/test/repo2",
+ Name: "repo2",
+ FullName: "test/repo2",
+ },
+ Repo: &api.Repository{
+ HTMLURL: "http://localhost:3000/test/repo",
+ Name: "repo",
+ FullName: "test/repo",
+ },
+ Sender: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ }
+}
+
+func pushTestPayload() *api.PushPayload {
+ return pushTestPayloadWithCommitMessage("commit message")
+}
+
+func pushTestMultilineCommitMessagePayload() *api.PushPayload {
+ return pushTestPayloadWithCommitMessage("This is a commit summary ⚠️⚠️⚠️⚠️ containing 你好 ⚠️⚠️️\n\nThis is the message body.")
+}
+
+func pushTestPayloadWithCommitMessage(message string) *api.PushPayload {
+ commit := &api.PayloadCommit{
+ ID: "2020558fe2e34debb818a514715839cabd25e778",
+ Message: message,
+ URL: "http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778",
+ Author: &api.PayloadUser{
+ Name: "user1",
+ Email: "user1@localhost",
+ UserName: "user1",
+ },
+ Committer: &api.PayloadUser{
+ Name: "user1",
+ Email: "user1@localhost",
+ UserName: "user1",
+ },
+ }
+
+ return &api.PushPayload{
+ Ref: "refs/heads/test",
+ Before: "2020558fe2e34debb818a514715839cabd25e777",
+ After: "2020558fe2e34debb818a514715839cabd25e778",
+ CompareURL: "",
+ HeadCommit: commit,
+ Commits: []*api.PayloadCommit{commit, commit},
+ TotalCommits: 2,
+ Repo: &api.Repository{
+ HTMLURL: "http://localhost:3000/test/repo",
+ Name: "repo",
+ FullName: "test/repo",
+ },
+ Pusher: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ Sender: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ }
+}
+
+func issueTestPayload() *api.IssuePayload {
+ return issuePayloadWithTitleAndBody("crash", "issue body")
+}
+
+func issueTestPayloadWithLongBody() *api.IssuePayload {
+ return issuePayloadWithTitleAndBody("crash", strings.Repeat("issue body", 4097))
+}
+
+func issueTestPayloadWithLongTitle() *api.IssuePayload {
+ return issuePayloadWithTitleAndBody(strings.Repeat("a", 257), "issue body")
+}
+
+func issuePayloadWithTitleAndBody(title, body string) *api.IssuePayload {
+ return &api.IssuePayload{
+ Index: 2,
+ Sender: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ Repository: &api.Repository{
+ HTMLURL: "http://localhost:3000/test/repo",
+ Name: "repo",
+ FullName: "test/repo",
+ },
+ Issue: &api.Issue{
+ ID: 2,
+ Index: 2,
+ URL: "http://localhost:3000/api/v1/repos/test/repo/issues/2",
+ HTMLURL: "http://localhost:3000/test/repo/issues/2",
+ Title: title,
+ Body: body,
+ Poster: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ Assignees: []*api.User{
+ {
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ },
+ Milestone: &api.Milestone{
+ ID: 1,
+ Title: "Milestone Title",
+ Description: "Milestone Description",
+ },
+ },
+ }
+}
+
+func issueCommentTestPayload() *api.IssueCommentPayload {
+ return &api.IssueCommentPayload{
+ Action: api.HookIssueCommentCreated,
+ Sender: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ Repository: &api.Repository{
+ HTMLURL: "http://localhost:3000/test/repo",
+ Name: "repo",
+ FullName: "test/repo",
+ },
+ Comment: &api.Comment{
+ HTMLURL: "http://localhost:3000/test/repo/issues/2#issuecomment-4",
+ IssueURL: "http://localhost:3000/test/repo/issues/2",
+ Body: "more info needed",
+ },
+ Issue: &api.Issue{
+ ID: 2,
+ Index: 2,
+ URL: "http://localhost:3000/api/v1/repos/test/repo/issues/2",
+ HTMLURL: "http://localhost:3000/test/repo/issues/2",
+ Title: "crash",
+ Poster: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ Body: "this happened",
+ },
+ }
+}
+
+func pullRequestCommentTestPayload() *api.IssueCommentPayload {
+ return &api.IssueCommentPayload{
+ Action: api.HookIssueCommentCreated,
+ Sender: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ Repository: &api.Repository{
+ HTMLURL: "http://localhost:3000/test/repo",
+ Name: "repo",
+ FullName: "test/repo",
+ },
+ Comment: &api.Comment{
+ HTMLURL: "http://localhost:3000/test/repo/pulls/12#issuecomment-4",
+ PRURL: "http://localhost:3000/test/repo/pulls/12",
+ Body: "changes requested",
+ },
+ Issue: &api.Issue{
+ ID: 12,
+ Index: 12,
+ URL: "http://localhost:3000/api/v1/repos/test/repo/pulls/12",
+ HTMLURL: "http://localhost:3000/test/repo/pulls/12",
+ Title: "Fix bug",
+ Body: "fixes bug #2",
+ Poster: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ },
+ IsPull: true,
+ }
+}
+
+func wikiTestPayload() *api.WikiPayload {
+ return &api.WikiPayload{
+ Repository: &api.Repository{
+ HTMLURL: "http://localhost:3000/test/repo",
+ Name: "repo",
+ FullName: "test/repo",
+ },
+ Sender: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ Page: "index",
+ Comment: "Wiki change comment",
+ }
+}
+
+func pullReleaseTestPayload() *api.ReleasePayload {
+ return &api.ReleasePayload{
+ Action: api.HookReleasePublished,
+ Sender: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ Repository: &api.Repository{
+ HTMLURL: "http://localhost:3000/test/repo",
+ Name: "repo",
+ FullName: "test/repo",
+ },
+ Release: &api.Release{
+ TagName: "v1.0",
+ Target: "master",
+ Title: "First stable release",
+ Note: "Note of first stable release",
+ HTMLURL: "http://localhost:3000/test/repo/releases/tag/v1.0",
+ },
+ }
+}
+
+func pullRequestTestPayload() *api.PullRequestPayload {
+ return &api.PullRequestPayload{
+ Action: api.HookIssueOpened,
+ Index: 12,
+ Sender: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ Repository: &api.Repository{
+ HTMLURL: "http://localhost:3000/test/repo",
+ Name: "repo",
+ FullName: "test/repo",
+ },
+ PullRequest: &api.PullRequest{
+ ID: 12,
+ Index: 12,
+ URL: "http://localhost:3000/test/repo/pulls/12",
+ HTMLURL: "http://localhost:3000/test/repo/pulls/12",
+ Title: "Fix bug",
+ Body: "fixes bug #2",
+ Mergeable: true,
+ Poster: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ Assignees: []*api.User{
+ {
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ },
+ Milestone: &api.Milestone{
+ ID: 1,
+ Title: "Milestone Title",
+ Description: "Milestone Description",
+ },
+ Base: &api.PRBranchInfo{
+ Name: "branch1",
+ Ref: "refs/pull/2/head",
+ Sha: "4a357436d925b5c974181ff12a994538ddc5a269",
+ RepoID: 1,
+ Repository: &api.Repository{
+ HTMLURL: "http://localhost:3000/test/repo",
+ Name: "repo",
+ FullName: "test/repo",
+ },
+ },
+ },
+ Review: &api.ReviewPayload{
+ Content: "good job",
+ },
+ }
+}
+
+func repositoryTestPayload() *api.RepositoryPayload {
+ return &api.RepositoryPayload{
+ Action: api.HookRepoCreated,
+ Sender: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ Repository: &api.Repository{
+ HTMLURL: "http://localhost:3000/test/repo",
+ Name: "repo",
+ FullName: "test/repo",
+ },
+ }
+}
+
+func packageTestPayload() *api.PackagePayload {
+ return &api.PackagePayload{
+ Action: api.HookPackageCreated,
+ Sender: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ Repository: nil,
+ Organization: &api.User{
+ UserName: "org1",
+ AvatarURL: "http://localhost:3000/org1/avatar",
+ },
+ Package: &api.Package{
+ Owner: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ Repository: nil,
+ Creator: &api.User{
+ UserName: "user1",
+ AvatarURL: "http://localhost:3000/user1/avatar",
+ },
+ Type: "container",
+ Name: "GiteaContainer",
+ Version: "latest",
+ HTMLURL: "http://localhost:3000/user1/-/packages/container/GiteaContainer/latest",
+ },
+ }
+}
+
+func TestGetIssuesPayloadInfo(t *testing.T) {
+ p := issueTestPayload()
+
+ cases := []struct {
+ action api.HookIssueAction
+ text string
+ issueTitle string
+ attachmentText string
+ color int
+ }{
+ {
+ api.HookIssueOpened,
+ "[test/repo] Issue opened: #2 crash by user1",
+ "#2 crash",
+ "issue body",
+ orangeColor,
+ },
+ {
+ api.HookIssueClosed,
+ "[test/repo] Issue closed: #2 crash by user1",
+ "#2 crash",
+ "",
+ redColor,
+ },
+ {
+ api.HookIssueReOpened,
+ "[test/repo] Issue re-opened: #2 crash by user1",
+ "#2 crash",
+ "",
+ yellowColor,
+ },
+ {
+ api.HookIssueEdited,
+ "[test/repo] Issue edited: #2 crash by user1",
+ "#2 crash",
+ "issue body",
+ yellowColor,
+ },
+ {
+ api.HookIssueAssigned,
+ "[test/repo] Issue assigned to user1: #2 crash by user1",
+ "#2 crash",
+ "",
+ greenColor,
+ },
+ {
+ api.HookIssueUnassigned,
+ "[test/repo] Issue unassigned: #2 crash by user1",
+ "#2 crash",
+ "",
+ yellowColor,
+ },
+ {
+ api.HookIssueLabelUpdated,
+ "[test/repo] Issue labels updated: #2 crash by user1",
+ "#2 crash",
+ "",
+ yellowColor,
+ },
+ {
+ api.HookIssueLabelCleared,
+ "[test/repo] Issue labels cleared: #2 crash by user1",
+ "#2 crash",
+ "",
+ yellowColor,
+ },
+ {
+ api.HookIssueSynchronized,
+ "[test/repo] Issue synchronized: #2 crash by user1",
+ "#2 crash",
+ "",
+ yellowColor,
+ },
+ {
+ api.HookIssueMilestoned,
+ "[test/repo] Issue milestoned to Milestone Title: #2 crash by user1",
+ "#2 crash",
+ "",
+ yellowColor,
+ },
+ {
+ api.HookIssueDemilestoned,
+ "[test/repo] Issue milestone cleared: #2 crash by user1",
+ "#2 crash",
+ "",
+ yellowColor,
+ },
+ }
+
+ for i, c := range cases {
+ p.Action = c.action
+ text, issueTitle, attachmentText, color := getIssuesPayloadInfo(p, noneLinkFormatter, true)
+ assert.Equal(t, c.text, text, "case %d", i)
+ assert.Equal(t, c.issueTitle, issueTitle, "case %d", i)
+ assert.Equal(t, c.attachmentText, attachmentText, "case %d", i)
+ assert.Equal(t, c.color, color, "case %d", i)
+ }
+}
+
+func TestGetPullRequestPayloadInfo(t *testing.T) {
+ p := pullRequestTestPayload()
+
+ cases := []struct {
+ action api.HookIssueAction
+ text string
+ issueTitle string
+ attachmentText string
+ color int
+ }{
+ {
+ api.HookIssueOpened,
+ "[test/repo] Pull request opened: #12 Fix bug by user1",
+ "#12 Fix bug",
+ "fixes bug #2",
+ greenColor,
+ },
+ {
+ api.HookIssueClosed,
+ "[test/repo] Pull request closed: #12 Fix bug by user1",
+ "#12 Fix bug",
+ "",
+ redColor,
+ },
+ {
+ api.HookIssueReOpened,
+ "[test/repo] Pull request re-opened: #12 Fix bug by user1",
+ "#12 Fix bug",
+ "",
+ yellowColor,
+ },
+ {
+ api.HookIssueEdited,
+ "[test/repo] Pull request edited: #12 Fix bug by user1",
+ "#12 Fix bug",
+ "fixes bug #2",
+ yellowColor,
+ },
+ {
+ api.HookIssueAssigned,
+ "[test/repo] Pull request assigned to user1: #12 Fix bug by user1",
+ "#12 Fix bug",
+ "",
+ greenColor,
+ },
+ {
+ api.HookIssueUnassigned,
+ "[test/repo] Pull request unassigned: #12 Fix bug by user1",
+ "#12 Fix bug",
+ "",
+ yellowColor,
+ },
+ {
+ api.HookIssueLabelUpdated,
+ "[test/repo] Pull request labels updated: #12 Fix bug by user1",
+ "#12 Fix bug",
+ "",
+ yellowColor,
+ },
+ {
+ api.HookIssueLabelCleared,
+ "[test/repo] Pull request labels cleared: #12 Fix bug by user1",
+ "#12 Fix bug",
+ "",
+ yellowColor,
+ },
+ {
+ api.HookIssueSynchronized,
+ "[test/repo] Pull request synchronized: #12 Fix bug by user1",
+ "#12 Fix bug",
+ "",
+ yellowColor,
+ },
+ {
+ api.HookIssueMilestoned,
+ "[test/repo] Pull request milestoned to Milestone Title: #12 Fix bug by user1",
+ "#12 Fix bug",
+ "",
+ yellowColor,
+ },
+ {
+ api.HookIssueDemilestoned,
+ "[test/repo] Pull request milestone cleared: #12 Fix bug by user1",
+ "#12 Fix bug",
+ "",
+ yellowColor,
+ },
+ }
+
+ for i, c := range cases {
+ p.Action = c.action
+ text, issueTitle, attachmentText, color := getPullRequestPayloadInfo(p, noneLinkFormatter, true)
+ assert.Equal(t, c.text, text, "case %d", i)
+ assert.Equal(t, c.issueTitle, issueTitle, "case %d", i)
+ assert.Equal(t, c.attachmentText, attachmentText, "case %d", i)
+ assert.Equal(t, c.color, color, "case %d", i)
+ }
+}
+
+func TestGetWikiPayloadInfo(t *testing.T) {
+ p := wikiTestPayload()
+
+ cases := []struct {
+ action api.HookWikiAction
+ text string
+ color int
+ link string
+ }{
+ {
+ api.HookWikiCreated,
+ "[test/repo] New wiki page 'index' (Wiki change comment) by user1",
+ greenColor,
+ "index",
+ },
+ {
+ api.HookWikiEdited,
+ "[test/repo] Wiki page 'index' edited (Wiki change comment) by user1",
+ yellowColor,
+ "index",
+ },
+ {
+ api.HookWikiDeleted,
+ "[test/repo] Wiki page 'index' deleted by user1",
+ redColor,
+ "index",
+ },
+ }
+
+ for i, c := range cases {
+ p.Action = c.action
+ text, color, link := getWikiPayloadInfo(p, noneLinkFormatter, true)
+ assert.Equal(t, c.text, text, "case %d", i)
+ assert.Equal(t, c.color, color, "case %d", i)
+ assert.Equal(t, c.link, link, "case %d", i)
+ }
+}
+
+func TestGetReleasePayloadInfo(t *testing.T) {
+ p := pullReleaseTestPayload()
+
+ cases := []struct {
+ action api.HookReleaseAction
+ text string
+ color int
+ }{
+ {
+ api.HookReleasePublished,
+ "[test/repo] Release created: v1.0 by user1",
+ greenColor,
+ },
+ {
+ api.HookReleaseUpdated,
+ "[test/repo] Release updated: v1.0 by user1",
+ yellowColor,
+ },
+ {
+ api.HookReleaseDeleted,
+ "[test/repo] Release deleted: v1.0 by user1",
+ redColor,
+ },
+ }
+
+ for i, c := range cases {
+ p.Action = c.action
+ text, color := getReleasePayloadInfo(p, noneLinkFormatter, true)
+ assert.Equal(t, c.text, text, "case %d", i)
+ assert.Equal(t, c.color, color, "case %d", i)
+ }
+}
+
+func TestGetIssueCommentPayloadInfo(t *testing.T) {
+ p := pullRequestCommentTestPayload()
+
+ cases := []struct {
+ action api.HookIssueCommentAction
+ text string
+ issueTitle string
+ color int
+ }{
+ {
+ api.HookIssueCommentCreated,
+ "[test/repo] New comment on pull request #12 Fix bug by user1",
+ "#12 Fix bug",
+ greenColorLight,
+ },
+ {
+ api.HookIssueCommentEdited,
+ "[test/repo] Comment edited on pull request #12 Fix bug by user1",
+ "#12 Fix bug",
+ yellowColor,
+ },
+ {
+ api.HookIssueCommentDeleted,
+ "[test/repo] Comment deleted on pull request #12 Fix bug by user1",
+ "#12 Fix bug",
+ redColor,
+ },
+ }
+
+ for i, c := range cases {
+ p.Action = c.action
+ text, issueTitle, color := getIssueCommentPayloadInfo(p, noneLinkFormatter, true)
+ assert.Equal(t, c.text, text, "case %d", i)
+ assert.Equal(t, c.issueTitle, issueTitle, "case %d", i)
+ assert.Equal(t, c.color, color, "case %d", i)
+ }
+}
diff --git a/services/webhook/gogs.go b/services/webhook/gogs.go
new file mode 100644
index 0000000..7dbf643
--- /dev/null
+++ b/services/webhook/gogs.go
@@ -0,0 +1,42 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "html/template"
+ "net/http"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/webhook/shared"
+)
+
+type gogsHandler struct{ defaultHandler }
+
+func (gogsHandler) Type() webhook_module.HookType { return webhook_module.GOGS }
+func (gogsHandler) Icon(size int) template.HTML { return shared.ImgIcon("gogs.ico", size) }
+
+func (gogsHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
+ var form struct {
+ forms.WebhookCoreForm
+ PayloadURL string `binding:"Required;ValidUrl"`
+ ContentType int `binding:"Required"`
+ Secret string
+ }
+ bind(&form)
+
+ contentType := webhook_model.ContentTypeJSON
+ if webhook_model.HookContentType(form.ContentType) == webhook_model.ContentTypeForm {
+ contentType = webhook_model.ContentTypeForm
+ }
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: form.PayloadURL,
+ ContentType: contentType,
+ Secret: form.Secret,
+ HTTPMethod: http.MethodPost,
+ Metadata: nil,
+ }
+}
diff --git a/services/webhook/main_test.go b/services/webhook/main_test.go
new file mode 100644
index 0000000..756b9db
--- /dev/null
+++ b/services/webhook/main_test.go
@@ -0,0 +1,26 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/hostmatcher"
+ "code.gitea.io/gitea/modules/setting"
+
+ _ "code.gitea.io/gitea/models"
+ _ "code.gitea.io/gitea/models/actions"
+)
+
+func TestMain(m *testing.M) {
+ // for tests, allow only loopback IPs
+ setting.Webhook.AllowedHostList = hostmatcher.MatchBuiltinLoopback
+ unittest.MainTest(m, &unittest.TestOptions{
+ SetUp: func() error {
+ setting.LoadQueueSettings()
+ return Init()
+ },
+ })
+}
diff --git a/services/webhook/matrix.go b/services/webhook/matrix.go
new file mode 100644
index 0000000..e70e7a2
--- /dev/null
+++ b/services/webhook/matrix.go
@@ -0,0 +1,316 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha1"
+ "encoding/hex"
+ "fmt"
+ "html/template"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/svg"
+ "code.gitea.io/gitea/modules/util"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/webhook/shared"
+)
+
+type matrixHandler struct{}
+
+func (matrixHandler) Type() webhook_module.HookType { return webhook_module.MATRIX }
+
+func (matrixHandler) Icon(size int) template.HTML {
+ return svg.RenderHTML("gitea-matrix", size, "img")
+}
+
+func (matrixHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
+ var form struct {
+ forms.WebhookCoreForm
+ HomeserverURL string `binding:"Required;ValidUrl"`
+ RoomID string `binding:"Required"`
+ MessageType int
+ AccessToken string `binding:"Required"`
+ }
+ bind(&form)
+ form.AuthorizationHeader = "Bearer " + strings.TrimSpace(form.AccessToken)
+
+ // https://spec.matrix.org/v1.10/client-server-api/#sending-events-to-a-room
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message", form.HomeserverURL, url.PathEscape(form.RoomID)),
+ ContentType: webhook_model.ContentTypeJSON,
+ Secret: "",
+ HTTPMethod: http.MethodPut,
+ Metadata: &MatrixMeta{
+ HomeserverURL: form.HomeserverURL,
+ Room: form.RoomID,
+ MessageType: form.MessageType,
+ },
+ }
+}
+
+func (matrixHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
+ meta := &MatrixMeta{}
+ if err := json.Unmarshal([]byte(w.Meta), meta); err != nil {
+ return nil, nil, fmt.Errorf("matrixHandler.NewRequest meta json: %w", err)
+ }
+ mc := matrixConvertor{
+ MsgType: messageTypeText[meta.MessageType],
+ }
+ payload, err := shared.NewPayload(mc, []byte(t.PayloadContent), t.EventType)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ body, err := json.MarshalIndent(payload, "", " ")
+ if err != nil {
+ return nil, nil, err
+ }
+
+ txnID, err := getMatrixTxnID(body)
+ if err != nil {
+ return nil, nil, err
+ }
+ req, err := http.NewRequest(http.MethodPut, w.URL+"/"+txnID, bytes.NewReader(body))
+ if err != nil {
+ return nil, nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ return req, body, nil
+}
+
+const matrixPayloadSizeLimit = 1024 * 64
+
+// MatrixMeta contains the Matrix metadata
+type MatrixMeta struct {
+ HomeserverURL string `json:"homeserver_url"`
+ Room string `json:"room_id"`
+ MessageType int `json:"message_type"`
+}
+
+var messageTypeText = map[int]string{
+ 1: "m.notice",
+ 2: "m.text",
+}
+
+// Metadata returns Matrix metadata
+func (matrixHandler) Metadata(w *webhook_model.Webhook) any {
+ s := &MatrixMeta{}
+ if err := json.Unmarshal([]byte(w.Meta), s); err != nil {
+ log.Error("matrixHandler.Metadata(%d): %v", w.ID, err)
+ }
+ return s
+}
+
+// MatrixPayload contains payload for a Matrix room
+type MatrixPayload struct {
+ Body string `json:"body"`
+ MsgType string `json:"msgtype"`
+ Format string `json:"format"`
+ FormattedBody string `json:"formatted_body"`
+ Commits []*api.PayloadCommit `json:"io.gitea.commits,omitempty"`
+}
+
+var _ shared.PayloadConvertor[MatrixPayload] = matrixConvertor{}
+
+type matrixConvertor struct {
+ MsgType string
+}
+
+func (m matrixConvertor) newPayload(text string, commits ...*api.PayloadCommit) (MatrixPayload, error) {
+ return MatrixPayload{
+ Body: getMessageBody(text),
+ MsgType: m.MsgType,
+ Format: "org.matrix.custom.html",
+ FormattedBody: text,
+ Commits: commits,
+ }, nil
+}
+
+// Create implements payloadConvertor Create method
+func (m matrixConvertor) Create(p *api.CreatePayload) (MatrixPayload, error) {
+ repoLink := htmlLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
+ refLink := MatrixLinkToRef(p.Repo.HTMLURL, p.Ref)
+ text := fmt.Sprintf("[%s:%s] %s created by %s", repoLink, refLink, p.RefType, p.Sender.UserName)
+
+ return m.newPayload(text)
+}
+
+// Delete composes Matrix payload for delete a branch or tag.
+func (m matrixConvertor) Delete(p *api.DeletePayload) (MatrixPayload, error) {
+ refName := git.RefName(p.Ref).ShortName()
+ repoLink := htmlLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
+ text := fmt.Sprintf("[%s:%s] %s deleted by %s", repoLink, refName, p.RefType, p.Sender.UserName)
+
+ return m.newPayload(text)
+}
+
+// Fork composes Matrix payload for forked by a repository.
+func (m matrixConvertor) Fork(p *api.ForkPayload) (MatrixPayload, error) {
+ baseLink := htmlLinkFormatter(p.Forkee.HTMLURL, p.Forkee.FullName)
+ forkLink := htmlLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
+ text := fmt.Sprintf("%s is forked to %s", baseLink, forkLink)
+
+ return m.newPayload(text)
+}
+
+// Issue implements payloadConvertor Issue method
+func (m matrixConvertor) Issue(p *api.IssuePayload) (MatrixPayload, error) {
+ text, _, _, _ := getIssuesPayloadInfo(p, htmlLinkFormatter, true)
+
+ return m.newPayload(text)
+}
+
+// IssueComment implements payloadConvertor IssueComment method
+func (m matrixConvertor) IssueComment(p *api.IssueCommentPayload) (MatrixPayload, error) {
+ text, _, _ := getIssueCommentPayloadInfo(p, htmlLinkFormatter, true)
+
+ return m.newPayload(text)
+}
+
+// Wiki implements payloadConvertor Wiki method
+func (m matrixConvertor) Wiki(p *api.WikiPayload) (MatrixPayload, error) {
+ text, _, _ := getWikiPayloadInfo(p, htmlLinkFormatter, true)
+
+ return m.newPayload(text)
+}
+
+// Release implements payloadConvertor Release method
+func (m matrixConvertor) Release(p *api.ReleasePayload) (MatrixPayload, error) {
+ text, _ := getReleasePayloadInfo(p, htmlLinkFormatter, true)
+
+ return m.newPayload(text)
+}
+
+// Push implements payloadConvertor Push method
+func (m matrixConvertor) Push(p *api.PushPayload) (MatrixPayload, error) {
+ var commitDesc string
+
+ if p.TotalCommits == 1 {
+ commitDesc = "1 commit"
+ } else {
+ commitDesc = fmt.Sprintf("%d commits", p.TotalCommits)
+ }
+
+ repoLink := htmlLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
+ branchLink := MatrixLinkToRef(p.Repo.HTMLURL, p.Ref)
+ text := fmt.Sprintf("[%s] %s pushed %s to %s:<br>", repoLink, p.Pusher.UserName, commitDesc, branchLink)
+
+ // for each commit, generate a new line text
+ for i, commit := range p.Commits {
+ text += fmt.Sprintf("%s: %s - %s", htmlLinkFormatter(commit.URL, commit.ID[:7]), commit.Message, commit.Author.Name)
+ // add linebreak to each commit but the last
+ if i < len(p.Commits)-1 {
+ text += "<br>"
+ }
+ }
+
+ return m.newPayload(text, p.Commits...)
+}
+
+// PullRequest implements payloadConvertor PullRequest method
+func (m matrixConvertor) PullRequest(p *api.PullRequestPayload) (MatrixPayload, error) {
+ text, _, _, _ := getPullRequestPayloadInfo(p, htmlLinkFormatter, true)
+
+ return m.newPayload(text)
+}
+
+// Review implements payloadConvertor Review method
+func (m matrixConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (MatrixPayload, error) {
+ senderLink := htmlLinkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName)
+ title := fmt.Sprintf("#%d %s", p.Index, p.PullRequest.Title)
+ titleLink := htmlLinkFormatter(p.PullRequest.HTMLURL, title)
+ repoLink := htmlLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
+ var text string
+
+ if p.Action == api.HookIssueReviewed {
+ action, err := parseHookPullRequestEventType(event)
+ if err != nil {
+ return MatrixPayload{}, err
+ }
+
+ text = fmt.Sprintf("[%s] Pull request review %s: %s by %s", repoLink, action, titleLink, senderLink)
+ }
+
+ return m.newPayload(text)
+}
+
+// Repository implements payloadConvertor Repository method
+func (m matrixConvertor) Repository(p *api.RepositoryPayload) (MatrixPayload, error) {
+ senderLink := htmlLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)
+ repoLink := htmlLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
+ var text string
+
+ switch p.Action {
+ case api.HookRepoCreated:
+ text = fmt.Sprintf("[%s] Repository created by %s", repoLink, senderLink)
+ case api.HookRepoDeleted:
+ text = fmt.Sprintf("[%s] Repository deleted by %s", repoLink, senderLink)
+ }
+ return m.newPayload(text)
+}
+
+func (m matrixConvertor) Package(p *api.PackagePayload) (MatrixPayload, error) {
+ senderLink := htmlLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)
+ packageLink := htmlLinkFormatter(p.Package.HTMLURL, p.Package.Name)
+ var text string
+
+ switch p.Action {
+ case api.HookPackageCreated:
+ text = fmt.Sprintf("[%s] Package published by %s", packageLink, senderLink)
+ case api.HookPackageDeleted:
+ text = fmt.Sprintf("[%s] Package deleted by %s", packageLink, senderLink)
+ }
+
+ return m.newPayload(text)
+}
+
+var urlRegex = regexp.MustCompile(`<a [^>]*?href="([^">]*?)">(.*?)</a>`)
+
+func getMessageBody(htmlText string) string {
+ htmlText = urlRegex.ReplaceAllString(htmlText, "[$2]($1)")
+ htmlText = strings.ReplaceAll(htmlText, "<br>", "\n")
+ return htmlText
+}
+
+// getMatrixTxnID computes the transaction ID to ensure idempotency
+func getMatrixTxnID(payload []byte) (string, error) {
+ if len(payload) >= matrixPayloadSizeLimit {
+ return "", fmt.Errorf("getMatrixTxnID: payload size %d > %d", len(payload), matrixPayloadSizeLimit)
+ }
+
+ h := sha1.New()
+ _, err := h.Write(payload)
+ if err != nil {
+ return "", err
+ }
+
+ return hex.EncodeToString(h.Sum(nil)), nil
+}
+
+// MatrixLinkToRef Matrix-formatter link to a repo ref
+func MatrixLinkToRef(repoURL, ref string) string {
+ refName := git.RefName(ref).ShortName()
+ switch {
+ case strings.HasPrefix(ref, git.BranchPrefix):
+ return htmlLinkFormatter(repoURL+"/src/branch/"+util.PathEscapeSegments(refName), refName)
+ case strings.HasPrefix(ref, git.TagPrefix):
+ return htmlLinkFormatter(repoURL+"/src/tag/"+util.PathEscapeSegments(refName), refName)
+ default:
+ return htmlLinkFormatter(repoURL+"/src/commit/"+util.PathEscapeSegments(refName), refName)
+ }
+}
diff --git a/services/webhook/matrix_test.go b/services/webhook/matrix_test.go
new file mode 100644
index 0000000..6cedb15
--- /dev/null
+++ b/services/webhook/matrix_test.go
@@ -0,0 +1,255 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "testing"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/json"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMatrixPayload(t *testing.T) {
+ mc := matrixConvertor{
+ MsgType: "m.text",
+ }
+
+ t.Run("Create", func(t *testing.T) {
+ p := createTestPayload()
+
+ pl, err := mc.Create(p)
+ require.NoError(t, err)
+ require.NotNil(t, pl)
+
+ assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo):[test](http://localhost:3000/test/repo/src/branch/test)] branch created by user1", pl.Body)
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo">test/repo</a>:<a href="http://localhost:3000/test/repo/src/branch/test">test</a>] branch created by user1`, pl.FormattedBody)
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ p := deleteTestPayload()
+
+ pl, err := mc.Delete(p)
+ require.NoError(t, err)
+ require.NotNil(t, pl)
+
+ assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo):test] branch deleted by user1", pl.Body)
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo">test/repo</a>:test] branch deleted by user1`, pl.FormattedBody)
+ })
+
+ t.Run("Fork", func(t *testing.T) {
+ p := forkTestPayload()
+
+ pl, err := mc.Fork(p)
+ require.NoError(t, err)
+ require.NotNil(t, pl)
+
+ assert.Equal(t, "[test/repo2](http://localhost:3000/test/repo2) is forked to [test/repo](http://localhost:3000/test/repo)", pl.Body)
+ assert.Equal(t, `<a href="http://localhost:3000/test/repo2">test/repo2</a> is forked to <a href="http://localhost:3000/test/repo">test/repo</a>`, pl.FormattedBody)
+ })
+
+ t.Run("Push", func(t *testing.T) {
+ p := pushTestPayload()
+
+ pl, err := mc.Push(p)
+ require.NoError(t, err)
+ require.NotNil(t, pl)
+
+ assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] user1 pushed 2 commits to [test](http://localhost:3000/test/repo/src/branch/test):\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778): commit message - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778): commit message - user1", pl.Body)
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo">test/repo</a>] user1 pushed 2 commits to <a href="http://localhost:3000/test/repo/src/branch/test">test</a>:<br><a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778">2020558</a>: commit message - user1<br><a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778">2020558</a>: commit message - user1`, pl.FormattedBody)
+ })
+
+ t.Run("Issue", func(t *testing.T) {
+ p := issueTestPayload()
+
+ p.Action = api.HookIssueOpened
+ pl, err := mc.Issue(p)
+ require.NoError(t, err)
+ require.NotNil(t, pl)
+
+ assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Issue opened: [#2 crash](http://localhost:3000/test/repo/issues/2) by [user1](https://try.gitea.io/user1)", pl.Body)
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo">test/repo</a>] Issue opened: <a href="http://localhost:3000/test/repo/issues/2">#2 crash</a> by <a href="https://try.gitea.io/user1">user1</a>`, pl.FormattedBody)
+
+ p.Action = api.HookIssueClosed
+ pl, err = mc.Issue(p)
+ require.NoError(t, err)
+ require.NotNil(t, pl)
+
+ assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Issue closed: [#2 crash](http://localhost:3000/test/repo/issues/2) by [user1](https://try.gitea.io/user1)", pl.Body)
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo">test/repo</a>] Issue closed: <a href="http://localhost:3000/test/repo/issues/2">#2 crash</a> by <a href="https://try.gitea.io/user1">user1</a>`, pl.FormattedBody)
+ })
+
+ t.Run("IssueComment", func(t *testing.T) {
+ p := issueCommentTestPayload()
+
+ pl, err := mc.IssueComment(p)
+ require.NoError(t, err)
+ require.NotNil(t, pl)
+
+ assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] New comment on issue [#2 crash](http://localhost:3000/test/repo/issues/2) by [user1](https://try.gitea.io/user1)", pl.Body)
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo">test/repo</a>] New comment on issue <a href="http://localhost:3000/test/repo/issues/2">#2 crash</a> by <a href="https://try.gitea.io/user1">user1</a>`, pl.FormattedBody)
+ })
+
+ t.Run("PullRequest", func(t *testing.T) {
+ p := pullRequestTestPayload()
+
+ pl, err := mc.PullRequest(p)
+ require.NoError(t, err)
+ require.NotNil(t, pl)
+
+ assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Pull request opened: [#12 Fix bug](http://localhost:3000/test/repo/pulls/12) by [user1](https://try.gitea.io/user1)", pl.Body)
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo">test/repo</a>] Pull request opened: <a href="http://localhost:3000/test/repo/pulls/12">#12 Fix bug</a> by <a href="https://try.gitea.io/user1">user1</a>`, pl.FormattedBody)
+ })
+
+ t.Run("PullRequestComment", func(t *testing.T) {
+ p := pullRequestCommentTestPayload()
+
+ pl, err := mc.IssueComment(p)
+ require.NoError(t, err)
+ require.NotNil(t, pl)
+
+ assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] New comment on pull request [#12 Fix bug](http://localhost:3000/test/repo/pulls/12) by [user1](https://try.gitea.io/user1)", pl.Body)
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo">test/repo</a>] New comment on pull request <a href="http://localhost:3000/test/repo/pulls/12">#12 Fix bug</a> by <a href="https://try.gitea.io/user1">user1</a>`, pl.FormattedBody)
+ })
+
+ t.Run("Review", func(t *testing.T) {
+ p := pullRequestTestPayload()
+ p.Action = api.HookIssueReviewed
+
+ pl, err := mc.Review(p, webhook_module.HookEventPullRequestReviewApproved)
+ require.NoError(t, err)
+ require.NotNil(t, pl)
+
+ assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Pull request review approved: [#12 Fix bug](http://localhost:3000/test/repo/pulls/12) by [user1](https://try.gitea.io/user1)", pl.Body)
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo">test/repo</a>] Pull request review approved: <a href="http://localhost:3000/test/repo/pulls/12">#12 Fix bug</a> by <a href="https://try.gitea.io/user1">user1</a>`, pl.FormattedBody)
+ })
+
+ t.Run("Repository", func(t *testing.T) {
+ p := repositoryTestPayload()
+
+ pl, err := mc.Repository(p)
+ require.NoError(t, err)
+ require.NotNil(t, pl)
+
+ assert.Equal(t, `[[test/repo](http://localhost:3000/test/repo)] Repository created by [user1](https://try.gitea.io/user1)`, pl.Body)
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo">test/repo</a>] Repository created by <a href="https://try.gitea.io/user1">user1</a>`, pl.FormattedBody)
+ })
+
+ t.Run("Package", func(t *testing.T) {
+ p := packageTestPayload()
+
+ pl, err := mc.Package(p)
+ require.NoError(t, err)
+ require.NotNil(t, pl)
+
+ assert.Equal(t, `[[GiteaContainer](http://localhost:3000/user1/-/packages/container/GiteaContainer/latest)] Package published by [user1](https://try.gitea.io/user1)`, pl.Body)
+ assert.Equal(t, `[<a href="http://localhost:3000/user1/-/packages/container/GiteaContainer/latest">GiteaContainer</a>] Package published by <a href="https://try.gitea.io/user1">user1</a>`, pl.FormattedBody)
+ })
+
+ t.Run("Wiki", func(t *testing.T) {
+ p := wikiTestPayload()
+
+ p.Action = api.HookWikiCreated
+ pl, err := mc.Wiki(p)
+ require.NoError(t, err)
+ require.NotNil(t, pl)
+
+ assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] New wiki page '[index](http://localhost:3000/test/repo/wiki/index)' (Wiki change comment) by [user1](https://try.gitea.io/user1)", pl.Body)
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo">test/repo</a>] New wiki page '<a href="http://localhost:3000/test/repo/wiki/index">index</a>' (Wiki change comment) by <a href="https://try.gitea.io/user1">user1</a>`, pl.FormattedBody)
+
+ p.Action = api.HookWikiEdited
+ pl, err = mc.Wiki(p)
+ require.NoError(t, err)
+ require.NotNil(t, pl)
+
+ assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Wiki page '[index](http://localhost:3000/test/repo/wiki/index)' edited (Wiki change comment) by [user1](https://try.gitea.io/user1)", pl.Body)
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo">test/repo</a>] Wiki page '<a href="http://localhost:3000/test/repo/wiki/index">index</a>' edited (Wiki change comment) by <a href="https://try.gitea.io/user1">user1</a>`, pl.FormattedBody)
+
+ p.Action = api.HookWikiDeleted
+ pl, err = mc.Wiki(p)
+ require.NoError(t, err)
+ require.NotNil(t, pl)
+
+ assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Wiki page '[index](http://localhost:3000/test/repo/wiki/index)' deleted by [user1](https://try.gitea.io/user1)", pl.Body)
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo">test/repo</a>] Wiki page '<a href="http://localhost:3000/test/repo/wiki/index">index</a>' deleted by <a href="https://try.gitea.io/user1">user1</a>`, pl.FormattedBody)
+ })
+
+ t.Run("Release", func(t *testing.T) {
+ p := pullReleaseTestPayload()
+
+ pl, err := mc.Release(p)
+ require.NoError(t, err)
+ require.NotNil(t, pl)
+
+ assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Release created: [v1.0](http://localhost:3000/test/repo/releases/tag/v1.0) by [user1](https://try.gitea.io/user1)", pl.Body)
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo">test/repo</a>] Release created: <a href="http://localhost:3000/test/repo/releases/tag/v1.0">v1.0</a> by <a href="https://try.gitea.io/user1">user1</a>`, pl.FormattedBody)
+ })
+}
+
+func TestMatrixJSONPayload(t *testing.T) {
+ p := pushTestPayload()
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ hook := &webhook_model.Webhook{
+ RepoID: 3,
+ IsActive: true,
+ Type: webhook_module.MATRIX,
+ URL: "https://matrix.example.com/_matrix/client/v3/rooms/ROOM_ID/send/m.room.message",
+ Meta: `{"message_type":0}`, // text
+ }
+ task := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventPush,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ req, reqBody, err := matrixHandler{}.NewRequest(context.Background(), hook, task)
+ require.NotNil(t, req)
+ require.NotNil(t, reqBody)
+ require.NoError(t, err)
+
+ assert.Equal(t, "PUT", req.Method)
+ assert.Equal(t, "/_matrix/client/v3/rooms/ROOM_ID/send/m.room.message/6db5dc1e282529a8c162c7fe93dd2667494eeb51", req.URL.Path)
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+ var body MatrixPayload
+ err = json.NewDecoder(req.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] user1 pushed 2 commits to [test](http://localhost:3000/test/repo/src/branch/test):\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778): commit message - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778): commit message - user1", body.Body)
+}
+
+func Test_getTxnID(t *testing.T) {
+ type args struct {
+ payload []byte
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ wantErr bool
+ }{
+ {
+ name: "dummy payload",
+ args: args{payload: []byte("Hello World")},
+ want: "0a4d55a8d778e5022fab701977c5d840bbc486d0",
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := getMatrixTxnID(tt.args.payload)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("getMatrixTxnID() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
diff --git a/services/webhook/msteams.go b/services/webhook/msteams.go
new file mode 100644
index 0000000..736d084
--- /dev/null
+++ b/services/webhook/msteams.go
@@ -0,0 +1,377 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "net/http"
+ "net/url"
+ "strings"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/git"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/webhook/shared"
+)
+
+type msteamsHandler struct{}
+
+func (msteamsHandler) Type() webhook_module.HookType { return webhook_module.MSTEAMS }
+func (msteamsHandler) Metadata(*webhook_model.Webhook) any { return nil }
+func (msteamsHandler) Icon(size int) template.HTML { return shared.ImgIcon("msteams.png", size) }
+
+func (msteamsHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
+ var form struct {
+ forms.WebhookCoreForm
+ PayloadURL string `binding:"Required;ValidUrl"`
+ }
+ bind(&form)
+
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: form.PayloadURL,
+ ContentType: webhook_model.ContentTypeJSON,
+ Secret: "",
+ HTTPMethod: http.MethodPost,
+ Metadata: nil,
+ }
+}
+
+type (
+ // MSTeamsFact for Fact Structure
+ MSTeamsFact struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+ }
+
+ // MSTeamsSection is a MessageCard section
+ MSTeamsSection struct {
+ ActivityTitle string `json:"activityTitle"`
+ ActivitySubtitle string `json:"activitySubtitle"`
+ ActivityImage string `json:"activityImage"`
+ Facts []MSTeamsFact `json:"facts"`
+ Text string `json:"text"`
+ }
+
+ // MSTeamsAction is an action (creates buttons, links etc)
+ MSTeamsAction struct {
+ Type string `json:"@type"`
+ Name string `json:"name"`
+ Targets []MSTeamsActionTarget `json:"targets,omitempty"`
+ }
+
+ // MSTeamsActionTarget is the actual link to follow, etc
+ MSTeamsActionTarget struct {
+ Os string `json:"os"`
+ URI string `json:"uri"`
+ }
+
+ // MSTeamsPayload is the parent object
+ MSTeamsPayload struct {
+ Type string `json:"@type"`
+ Context string `json:"@context"`
+ ThemeColor string `json:"themeColor"`
+ Title string `json:"title"`
+ Summary string `json:"summary"`
+ Sections []MSTeamsSection `json:"sections"`
+ PotentialAction []MSTeamsAction `json:"potentialAction"`
+ }
+)
+
+// Create implements PayloadConvertor Create method
+func (m msteamsConvertor) Create(p *api.CreatePayload) (MSTeamsPayload, error) {
+ // created tag/branch
+ refName := git.RefName(p.Ref).ShortName()
+ title := fmt.Sprintf("[%s] %s %s created", p.Repo.FullName, p.RefType, refName)
+
+ return createMSTeamsPayload(
+ p.Repo,
+ p.Sender,
+ title,
+ "",
+ p.Repo.HTMLURL+"/src/"+util.PathEscapeSegments(refName),
+ greenColor,
+ &MSTeamsFact{fmt.Sprintf("%s:", p.RefType), refName},
+ ), nil
+}
+
+// Delete implements PayloadConvertor Delete method
+func (m msteamsConvertor) Delete(p *api.DeletePayload) (MSTeamsPayload, error) {
+ // deleted tag/branch
+ refName := git.RefName(p.Ref).ShortName()
+ title := fmt.Sprintf("[%s] %s %s deleted", p.Repo.FullName, p.RefType, refName)
+
+ return createMSTeamsPayload(
+ p.Repo,
+ p.Sender,
+ title,
+ "",
+ p.Repo.HTMLURL+"/src/"+util.PathEscapeSegments(refName),
+ yellowColor,
+ &MSTeamsFact{fmt.Sprintf("%s:", p.RefType), refName},
+ ), nil
+}
+
+// Fork implements PayloadConvertor Fork method
+func (m msteamsConvertor) Fork(p *api.ForkPayload) (MSTeamsPayload, error) {
+ title := fmt.Sprintf("%s is forked to %s", p.Forkee.FullName, p.Repo.FullName)
+
+ return createMSTeamsPayload(
+ p.Repo,
+ p.Sender,
+ title,
+ "",
+ p.Repo.HTMLURL,
+ greenColor,
+ &MSTeamsFact{"Forkee:", p.Forkee.FullName},
+ ), nil
+}
+
+// Push implements PayloadConvertor Push method
+func (m msteamsConvertor) Push(p *api.PushPayload) (MSTeamsPayload, error) {
+ var (
+ branchName = git.RefName(p.Ref).ShortName()
+ commitDesc string
+ )
+
+ var titleLink string
+ if p.TotalCommits == 1 {
+ commitDesc = "1 new commit"
+ titleLink = p.Commits[0].URL
+ } else {
+ commitDesc = fmt.Sprintf("%d new commits", p.TotalCommits)
+ titleLink = p.CompareURL
+ }
+ if titleLink == "" {
+ titleLink = p.Repo.HTMLURL + "/src/" + util.PathEscapeSegments(branchName)
+ }
+
+ title := fmt.Sprintf("[%s:%s] %s", p.Repo.FullName, branchName, commitDesc)
+
+ var text string
+ // for each commit, generate attachment text
+ for i, commit := range p.Commits {
+ text += fmt.Sprintf("[%s](%s) %s - %s", commit.ID[:7], commit.URL,
+ strings.TrimRight(commit.Message, "\r\n"), commit.Author.Name)
+ // add linebreak to each commit but the last
+ if i < len(p.Commits)-1 {
+ text += "\n\n"
+ }
+ }
+
+ return createMSTeamsPayload(
+ p.Repo,
+ p.Sender,
+ title,
+ text,
+ titleLink,
+ greenColor,
+ &MSTeamsFact{"Commit count:", fmt.Sprintf("%d", p.TotalCommits)},
+ ), nil
+}
+
+// Issue implements PayloadConvertor Issue method
+func (m msteamsConvertor) Issue(p *api.IssuePayload) (MSTeamsPayload, error) {
+ title, _, attachmentText, color := getIssuesPayloadInfo(p, noneLinkFormatter, false)
+
+ return createMSTeamsPayload(
+ p.Repository,
+ p.Sender,
+ title,
+ attachmentText,
+ p.Issue.HTMLURL,
+ color,
+ &MSTeamsFact{"Issue #:", fmt.Sprintf("%d", p.Issue.ID)},
+ ), nil
+}
+
+// IssueComment implements PayloadConvertor IssueComment method
+func (m msteamsConvertor) IssueComment(p *api.IssueCommentPayload) (MSTeamsPayload, error) {
+ title, _, color := getIssueCommentPayloadInfo(p, noneLinkFormatter, false)
+
+ return createMSTeamsPayload(
+ p.Repository,
+ p.Sender,
+ title,
+ p.Comment.Body,
+ p.Comment.HTMLURL,
+ color,
+ &MSTeamsFact{"Issue #:", fmt.Sprintf("%d", p.Issue.ID)},
+ ), nil
+}
+
+// PullRequest implements PayloadConvertor PullRequest method
+func (m msteamsConvertor) PullRequest(p *api.PullRequestPayload) (MSTeamsPayload, error) {
+ title, _, attachmentText, color := getPullRequestPayloadInfo(p, noneLinkFormatter, false)
+
+ return createMSTeamsPayload(
+ p.Repository,
+ p.Sender,
+ title,
+ attachmentText,
+ p.PullRequest.HTMLURL,
+ color,
+ &MSTeamsFact{"Pull request #:", fmt.Sprintf("%d", p.PullRequest.ID)},
+ ), nil
+}
+
+// Review implements PayloadConvertor Review method
+func (m msteamsConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (MSTeamsPayload, error) {
+ var text, title string
+ var color int
+ if p.Action == api.HookIssueReviewed {
+ action, err := parseHookPullRequestEventType(event)
+ if err != nil {
+ return MSTeamsPayload{}, err
+ }
+
+ title = fmt.Sprintf("[%s] Pull request review %s: #%d %s", p.Repository.FullName, action, p.Index, p.PullRequest.Title)
+ text = p.Review.Content
+
+ switch event {
+ case webhook_module.HookEventPullRequestReviewApproved:
+ color = greenColor
+ case webhook_module.HookEventPullRequestReviewRejected:
+ color = redColor
+ case webhook_module.HookEventPullRequestReviewComment:
+ color = greyColor
+ default:
+ color = yellowColor
+ }
+ }
+
+ return createMSTeamsPayload(
+ p.Repository,
+ p.Sender,
+ title,
+ text,
+ p.PullRequest.HTMLURL,
+ color,
+ &MSTeamsFact{"Pull request #:", fmt.Sprintf("%d", p.PullRequest.ID)},
+ ), nil
+}
+
+// Repository implements PayloadConvertor Repository method
+func (m msteamsConvertor) Repository(p *api.RepositoryPayload) (MSTeamsPayload, error) {
+ var title, url string
+ var color int
+ switch p.Action {
+ case api.HookRepoCreated:
+ title = fmt.Sprintf("[%s] Repository created", p.Repository.FullName)
+ url = p.Repository.HTMLURL
+ color = greenColor
+ case api.HookRepoDeleted:
+ title = fmt.Sprintf("[%s] Repository deleted", p.Repository.FullName)
+ color = yellowColor
+ }
+
+ return createMSTeamsPayload(
+ p.Repository,
+ p.Sender,
+ title,
+ "",
+ url,
+ color,
+ nil,
+ ), nil
+}
+
+// Wiki implements PayloadConvertor Wiki method
+func (m msteamsConvertor) Wiki(p *api.WikiPayload) (MSTeamsPayload, error) {
+ title, color, _ := getWikiPayloadInfo(p, noneLinkFormatter, false)
+
+ return createMSTeamsPayload(
+ p.Repository,
+ p.Sender,
+ title,
+ "",
+ p.Repository.HTMLURL+"/wiki/"+url.PathEscape(p.Page),
+ color,
+ &MSTeamsFact{"Repository:", p.Repository.FullName},
+ ), nil
+}
+
+// Release implements PayloadConvertor Release method
+func (m msteamsConvertor) Release(p *api.ReleasePayload) (MSTeamsPayload, error) {
+ title, color := getReleasePayloadInfo(p, noneLinkFormatter, false)
+
+ return createMSTeamsPayload(
+ p.Repository,
+ p.Sender,
+ title,
+ "",
+ p.Release.HTMLURL,
+ color,
+ &MSTeamsFact{"Tag:", p.Release.TagName},
+ ), nil
+}
+
+func (m msteamsConvertor) Package(p *api.PackagePayload) (MSTeamsPayload, error) {
+ title, color := getPackagePayloadInfo(p, noneLinkFormatter, false)
+
+ return createMSTeamsPayload(
+ p.Repository,
+ p.Sender,
+ title,
+ "",
+ p.Package.HTMLURL,
+ color,
+ &MSTeamsFact{"Package:", p.Package.Name},
+ ), nil
+}
+
+func createMSTeamsPayload(r *api.Repository, s *api.User, title, text, actionTarget string, color int, fact *MSTeamsFact) MSTeamsPayload {
+ facts := make([]MSTeamsFact, 0, 2)
+ if r != nil {
+ facts = append(facts, MSTeamsFact{
+ Name: "Repository:",
+ Value: r.FullName,
+ })
+ }
+ if fact != nil {
+ facts = append(facts, *fact)
+ }
+
+ return MSTeamsPayload{
+ Type: "MessageCard",
+ Context: "https://schema.org/extensions",
+ ThemeColor: fmt.Sprintf("%x", color),
+ Title: title,
+ Summary: title,
+ Sections: []MSTeamsSection{
+ {
+ ActivityTitle: s.FullName,
+ ActivitySubtitle: s.UserName,
+ ActivityImage: s.AvatarURL,
+ Text: text,
+ Facts: facts,
+ },
+ },
+ PotentialAction: []MSTeamsAction{
+ {
+ Type: "OpenUri",
+ Name: "View in Gitea",
+ Targets: []MSTeamsActionTarget{
+ {
+ Os: "default",
+ URI: actionTarget,
+ },
+ },
+ },
+ },
+ }
+}
+
+type msteamsConvertor struct{}
+
+var _ shared.PayloadConvertor[MSTeamsPayload] = msteamsConvertor{}
+
+func (msteamsHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
+ return shared.NewJSONRequest(msteamsConvertor{}, w, t, true)
+}
diff --git a/services/webhook/msteams_test.go b/services/webhook/msteams_test.go
new file mode 100644
index 0000000..a97e9f3
--- /dev/null
+++ b/services/webhook/msteams_test.go
@@ -0,0 +1,455 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "testing"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/json"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMSTeamsPayload(t *testing.T) {
+ mc := msteamsConvertor{}
+ t.Run("Create", func(t *testing.T) {
+ p := createTestPayload()
+
+ pl, err := mc.Create(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] branch test created", pl.Title)
+ assert.Equal(t, "[test/repo] branch test created", pl.Summary)
+ assert.Len(t, pl.Sections, 1)
+ assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle)
+ assert.Empty(t, pl.Sections[0].Text)
+ assert.Len(t, pl.Sections[0].Facts, 2)
+ for _, fact := range pl.Sections[0].Facts {
+ if fact.Name == "Repository:" {
+ assert.Equal(t, p.Repo.FullName, fact.Value)
+ } else if fact.Name == "branch:" {
+ assert.Equal(t, "test", fact.Value)
+ } else {
+ t.Fail()
+ }
+ }
+ assert.Len(t, pl.PotentialAction, 1)
+ assert.Len(t, pl.PotentialAction[0].Targets, 1)
+ assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.PotentialAction[0].Targets[0].URI)
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ p := deleteTestPayload()
+
+ pl, err := mc.Delete(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] branch test deleted", pl.Title)
+ assert.Equal(t, "[test/repo] branch test deleted", pl.Summary)
+ assert.Len(t, pl.Sections, 1)
+ assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle)
+ assert.Empty(t, pl.Sections[0].Text)
+ assert.Len(t, pl.Sections[0].Facts, 2)
+ for _, fact := range pl.Sections[0].Facts {
+ if fact.Name == "Repository:" {
+ assert.Equal(t, p.Repo.FullName, fact.Value)
+ } else if fact.Name == "branch:" {
+ assert.Equal(t, "test", fact.Value)
+ } else {
+ t.Fail()
+ }
+ }
+ assert.Len(t, pl.PotentialAction, 1)
+ assert.Len(t, pl.PotentialAction[0].Targets, 1)
+ assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.PotentialAction[0].Targets[0].URI)
+ })
+
+ t.Run("Fork", func(t *testing.T) {
+ p := forkTestPayload()
+
+ pl, err := mc.Fork(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "test/repo2 is forked to test/repo", pl.Title)
+ assert.Equal(t, "test/repo2 is forked to test/repo", pl.Summary)
+ assert.Len(t, pl.Sections, 1)
+ assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle)
+ assert.Empty(t, pl.Sections[0].Text)
+ assert.Len(t, pl.Sections[0].Facts, 2)
+ for _, fact := range pl.Sections[0].Facts {
+ if fact.Name == "Repository:" {
+ assert.Equal(t, p.Repo.FullName, fact.Value)
+ } else if fact.Name == "Forkee:" {
+ assert.Equal(t, p.Forkee.FullName, fact.Value)
+ } else {
+ t.Fail()
+ }
+ }
+ assert.Len(t, pl.PotentialAction, 1)
+ assert.Len(t, pl.PotentialAction[0].Targets, 1)
+ assert.Equal(t, "http://localhost:3000/test/repo", pl.PotentialAction[0].Targets[0].URI)
+ })
+
+ t.Run("Push", func(t *testing.T) {
+ p := pushTestPayload()
+
+ pl, err := mc.Push(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo:test] 2 new commits", pl.Title)
+ assert.Equal(t, "[test/repo:test] 2 new commits", pl.Summary)
+ assert.Len(t, pl.Sections, 1)
+ assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle)
+ assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\n\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", pl.Sections[0].Text)
+ assert.Len(t, pl.Sections[0].Facts, 2)
+ for _, fact := range pl.Sections[0].Facts {
+ if fact.Name == "Repository:" {
+ assert.Equal(t, p.Repo.FullName, fact.Value)
+ } else if fact.Name == "Commit count:" {
+ assert.Equal(t, "2", fact.Value)
+ } else {
+ t.Fail()
+ }
+ }
+ assert.Len(t, pl.PotentialAction, 1)
+ assert.Len(t, pl.PotentialAction[0].Targets, 1)
+ assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.PotentialAction[0].Targets[0].URI)
+ })
+
+ t.Run("Issue", func(t *testing.T) {
+ p := issueTestPayload()
+
+ p.Action = api.HookIssueOpened
+ pl, err := mc.Issue(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Issue opened: #2 crash", pl.Title)
+ assert.Equal(t, "[test/repo] Issue opened: #2 crash", pl.Summary)
+ assert.Len(t, pl.Sections, 1)
+ assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle)
+ assert.Equal(t, "issue body", pl.Sections[0].Text)
+ assert.Len(t, pl.Sections[0].Facts, 2)
+ for _, fact := range pl.Sections[0].Facts {
+ if fact.Name == "Repository:" {
+ assert.Equal(t, p.Repository.FullName, fact.Value)
+ } else if fact.Name == "Issue #:" {
+ assert.Equal(t, "2", fact.Value)
+ } else {
+ t.Fail()
+ }
+ }
+ assert.Len(t, pl.PotentialAction, 1)
+ assert.Len(t, pl.PotentialAction[0].Targets, 1)
+ assert.Equal(t, "http://localhost:3000/test/repo/issues/2", pl.PotentialAction[0].Targets[0].URI)
+
+ p.Action = api.HookIssueClosed
+ pl, err = mc.Issue(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Issue closed: #2 crash", pl.Title)
+ assert.Equal(t, "[test/repo] Issue closed: #2 crash", pl.Summary)
+ assert.Len(t, pl.Sections, 1)
+ assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle)
+ assert.Empty(t, pl.Sections[0].Text)
+ assert.Len(t, pl.Sections[0].Facts, 2)
+ for _, fact := range pl.Sections[0].Facts {
+ if fact.Name == "Repository:" {
+ assert.Equal(t, p.Repository.FullName, fact.Value)
+ } else if fact.Name == "Issue #:" {
+ assert.Equal(t, "2", fact.Value)
+ } else {
+ t.Fail()
+ }
+ }
+ assert.Len(t, pl.PotentialAction, 1)
+ assert.Len(t, pl.PotentialAction[0].Targets, 1)
+ assert.Equal(t, "http://localhost:3000/test/repo/issues/2", pl.PotentialAction[0].Targets[0].URI)
+ })
+
+ t.Run("IssueComment", func(t *testing.T) {
+ p := issueCommentTestPayload()
+
+ pl, err := mc.IssueComment(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] New comment on issue #2 crash", pl.Title)
+ assert.Equal(t, "[test/repo] New comment on issue #2 crash", pl.Summary)
+ assert.Len(t, pl.Sections, 1)
+ assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle)
+ assert.Equal(t, "more info needed", pl.Sections[0].Text)
+ assert.Len(t, pl.Sections[0].Facts, 2)
+ for _, fact := range pl.Sections[0].Facts {
+ if fact.Name == "Repository:" {
+ assert.Equal(t, p.Repository.FullName, fact.Value)
+ } else if fact.Name == "Issue #:" {
+ assert.Equal(t, "2", fact.Value)
+ } else {
+ t.Fail()
+ }
+ }
+ assert.Len(t, pl.PotentialAction, 1)
+ assert.Len(t, pl.PotentialAction[0].Targets, 1)
+ assert.Equal(t, "http://localhost:3000/test/repo/issues/2#issuecomment-4", pl.PotentialAction[0].Targets[0].URI)
+ })
+
+ t.Run("PullRequest", func(t *testing.T) {
+ p := pullRequestTestPayload()
+
+ pl, err := mc.PullRequest(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Pull request opened: #12 Fix bug", pl.Title)
+ assert.Equal(t, "[test/repo] Pull request opened: #12 Fix bug", pl.Summary)
+ assert.Len(t, pl.Sections, 1)
+ assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle)
+ assert.Equal(t, "fixes bug #2", pl.Sections[0].Text)
+ assert.Len(t, pl.Sections[0].Facts, 2)
+ for _, fact := range pl.Sections[0].Facts {
+ if fact.Name == "Repository:" {
+ assert.Equal(t, p.Repository.FullName, fact.Value)
+ } else if fact.Name == "Pull request #:" {
+ assert.Equal(t, "12", fact.Value)
+ } else {
+ t.Fail()
+ }
+ }
+ assert.Len(t, pl.PotentialAction, 1)
+ assert.Len(t, pl.PotentialAction[0].Targets, 1)
+ assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", pl.PotentialAction[0].Targets[0].URI)
+ })
+
+ t.Run("PullRequestComment", func(t *testing.T) {
+ p := pullRequestCommentTestPayload()
+
+ pl, err := mc.IssueComment(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] New comment on pull request #12 Fix bug", pl.Title)
+ assert.Equal(t, "[test/repo] New comment on pull request #12 Fix bug", pl.Summary)
+ assert.Len(t, pl.Sections, 1)
+ assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle)
+ assert.Equal(t, "changes requested", pl.Sections[0].Text)
+ assert.Len(t, pl.Sections[0].Facts, 2)
+ for _, fact := range pl.Sections[0].Facts {
+ if fact.Name == "Repository:" {
+ assert.Equal(t, p.Repository.FullName, fact.Value)
+ } else if fact.Name == "Issue #:" {
+ assert.Equal(t, "12", fact.Value)
+ } else {
+ t.Fail()
+ }
+ }
+ assert.Len(t, pl.PotentialAction, 1)
+ assert.Len(t, pl.PotentialAction[0].Targets, 1)
+ assert.Equal(t, "http://localhost:3000/test/repo/pulls/12#issuecomment-4", pl.PotentialAction[0].Targets[0].URI)
+ })
+
+ t.Run("Review", func(t *testing.T) {
+ p := pullRequestTestPayload()
+ p.Action = api.HookIssueReviewed
+
+ pl, err := mc.Review(p, webhook_module.HookEventPullRequestReviewApproved)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Pull request review approved: #12 Fix bug", pl.Title)
+ assert.Equal(t, "[test/repo] Pull request review approved: #12 Fix bug", pl.Summary)
+ assert.Len(t, pl.Sections, 1)
+ assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle)
+ assert.Equal(t, "good job", pl.Sections[0].Text)
+ assert.Len(t, pl.Sections[0].Facts, 2)
+ for _, fact := range pl.Sections[0].Facts {
+ if fact.Name == "Repository:" {
+ assert.Equal(t, p.Repository.FullName, fact.Value)
+ } else if fact.Name == "Pull request #:" {
+ assert.Equal(t, "12", fact.Value)
+ } else {
+ t.Fail()
+ }
+ }
+ assert.Len(t, pl.PotentialAction, 1)
+ assert.Len(t, pl.PotentialAction[0].Targets, 1)
+ assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", pl.PotentialAction[0].Targets[0].URI)
+ })
+
+ t.Run("Repository", func(t *testing.T) {
+ p := repositoryTestPayload()
+
+ pl, err := mc.Repository(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Repository created", pl.Title)
+ assert.Equal(t, "[test/repo] Repository created", pl.Summary)
+ assert.Len(t, pl.Sections, 1)
+ assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle)
+ assert.Empty(t, pl.Sections[0].Text)
+ assert.Len(t, pl.Sections[0].Facts, 1)
+ for _, fact := range pl.Sections[0].Facts {
+ if fact.Name == "Repository:" {
+ assert.Equal(t, p.Repository.FullName, fact.Value)
+ } else {
+ t.Fail()
+ }
+ }
+ assert.Len(t, pl.PotentialAction, 1)
+ assert.Len(t, pl.PotentialAction[0].Targets, 1)
+ assert.Equal(t, "http://localhost:3000/test/repo", pl.PotentialAction[0].Targets[0].URI)
+ })
+
+ t.Run("Package", func(t *testing.T) {
+ p := packageTestPayload()
+
+ pl, err := mc.Package(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "Package created: GiteaContainer:latest", pl.Title)
+ assert.Equal(t, "Package created: GiteaContainer:latest", pl.Summary)
+ assert.Len(t, pl.Sections, 1)
+ assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle)
+ assert.Empty(t, pl.Sections[0].Text)
+ assert.Len(t, pl.Sections[0].Facts, 1)
+ for _, fact := range pl.Sections[0].Facts {
+ if fact.Name == "Package:" {
+ assert.Equal(t, p.Package.Name, fact.Value)
+ } else {
+ t.Fail()
+ }
+ }
+ assert.Len(t, pl.PotentialAction, 1)
+ assert.Len(t, pl.PotentialAction[0].Targets, 1)
+ assert.Equal(t, "http://localhost:3000/user1/-/packages/container/GiteaContainer/latest", pl.PotentialAction[0].Targets[0].URI)
+ })
+
+ t.Run("Wiki", func(t *testing.T) {
+ p := wikiTestPayload()
+
+ p.Action = api.HookWikiCreated
+ pl, err := mc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment)", pl.Title)
+ assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment)", pl.Summary)
+ assert.Len(t, pl.Sections, 1)
+ assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle)
+ assert.Equal(t, "", pl.Sections[0].Text)
+ assert.Len(t, pl.Sections[0].Facts, 2)
+ for _, fact := range pl.Sections[0].Facts {
+ if fact.Name == "Repository:" {
+ assert.Equal(t, p.Repository.FullName, fact.Value)
+ } else {
+ t.Fail()
+ }
+ }
+ assert.Len(t, pl.PotentialAction, 1)
+ assert.Len(t, pl.PotentialAction[0].Targets, 1)
+ assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.PotentialAction[0].Targets[0].URI)
+
+ p.Action = api.HookWikiEdited
+ pl, err = mc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment)", pl.Title)
+ assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment)", pl.Summary)
+ assert.Len(t, pl.Sections, 1)
+ assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle)
+ assert.Equal(t, "", pl.Sections[0].Text)
+ assert.Len(t, pl.Sections[0].Facts, 2)
+ for _, fact := range pl.Sections[0].Facts {
+ if fact.Name == "Repository:" {
+ assert.Equal(t, p.Repository.FullName, fact.Value)
+ } else {
+ t.Fail()
+ }
+ }
+ assert.Len(t, pl.PotentialAction, 1)
+ assert.Len(t, pl.PotentialAction[0].Targets, 1)
+ assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.PotentialAction[0].Targets[0].URI)
+
+ p.Action = api.HookWikiDeleted
+ pl, err = mc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Wiki page 'index' deleted", pl.Title)
+ assert.Equal(t, "[test/repo] Wiki page 'index' deleted", pl.Summary)
+ assert.Len(t, pl.Sections, 1)
+ assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle)
+ assert.Empty(t, pl.Sections[0].Text)
+ assert.Len(t, pl.Sections[0].Facts, 2)
+ for _, fact := range pl.Sections[0].Facts {
+ if fact.Name == "Repository:" {
+ assert.Equal(t, p.Repository.FullName, fact.Value)
+ } else {
+ t.Fail()
+ }
+ }
+ assert.Len(t, pl.PotentialAction, 1)
+ assert.Len(t, pl.PotentialAction[0].Targets, 1)
+ assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.PotentialAction[0].Targets[0].URI)
+ })
+
+ t.Run("Release", func(t *testing.T) {
+ p := pullReleaseTestPayload()
+
+ pl, err := mc.Release(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[test/repo] Release created: v1.0", pl.Title)
+ assert.Equal(t, "[test/repo] Release created: v1.0", pl.Summary)
+ assert.Len(t, pl.Sections, 1)
+ assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle)
+ assert.Empty(t, pl.Sections[0].Text)
+ assert.Len(t, pl.Sections[0].Facts, 2)
+ for _, fact := range pl.Sections[0].Facts {
+ if fact.Name == "Repository:" {
+ assert.Equal(t, p.Repository.FullName, fact.Value)
+ } else if fact.Name == "Tag:" {
+ assert.Equal(t, "v1.0", fact.Value)
+ } else {
+ t.Fail()
+ }
+ }
+ assert.Len(t, pl.PotentialAction, 1)
+ assert.Len(t, pl.PotentialAction[0].Targets, 1)
+ assert.Equal(t, "http://localhost:3000/test/repo/releases/tag/v1.0", pl.PotentialAction[0].Targets[0].URI)
+ })
+}
+
+func TestMSTeamsJSONPayload(t *testing.T) {
+ p := pushTestPayload()
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ hook := &webhook_model.Webhook{
+ RepoID: 3,
+ IsActive: true,
+ Type: webhook_module.MSTEAMS,
+ URL: "https://msteams.example.com/",
+ Meta: ``,
+ HTTPMethod: "POST",
+ }
+ task := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventPush,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ req, reqBody, err := msteamsHandler{}.NewRequest(context.Background(), hook, task)
+ require.NotNil(t, req)
+ require.NotNil(t, reqBody)
+ require.NoError(t, err)
+
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, "https://msteams.example.com/", req.URL.String())
+ assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256"))
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+ var body MSTeamsPayload
+ err = json.NewDecoder(req.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Equal(t, "[test/repo:test] 2 new commits", body.Summary)
+}
diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go
new file mode 100644
index 0000000..a9b3422
--- /dev/null
+++ b/services/webhook/notifier.go
@@ -0,0 +1,887 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ packages_model "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/repository"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ "code.gitea.io/gitea/services/convert"
+ notify_service "code.gitea.io/gitea/services/notify"
+)
+
+func init() {
+ notify_service.RegisterNotifier(&webhookNotifier{})
+}
+
+type webhookNotifier struct {
+ notify_service.NullNotifier
+}
+
+var _ notify_service.Notifier = &webhookNotifier{}
+
+// NewNotifier create a new webhookNotifier notifier
+func NewNotifier() notify_service.Notifier {
+ return &webhookNotifier{}
+}
+
+func (m *webhookNotifier) IssueClearLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) {
+ if err := issue.LoadPoster(ctx); err != nil {
+ log.Error("LoadPoster: %v", err)
+ return
+ }
+
+ if err := issue.LoadRepo(ctx); err != nil {
+ log.Error("LoadRepo: %v", err)
+ return
+ }
+
+ permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster)
+ var err error
+ if issue.IsPull {
+ if err = issue.LoadPullRequest(ctx); err != nil {
+ log.Error("LoadPullRequest: %v", err)
+ return
+ }
+
+ err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventPullRequestLabel, &api.PullRequestPayload{
+ Action: api.HookIssueLabelCleared,
+ Index: issue.Index,
+ PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil),
+ Repository: convert.ToRepo(ctx, issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ })
+ } else {
+ err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventIssueLabel, &api.IssuePayload{
+ Action: api.HookIssueLabelCleared,
+ Index: issue.Index,
+ Issue: convert.ToAPIIssue(ctx, doer, issue),
+ Repository: convert.ToRepo(ctx, issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ })
+ }
+ if err != nil {
+ log.Error("PrepareWebhooks [is_pull: %v]: %v", issue.IsPull, err)
+ }
+}
+
+func (m *webhookNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) {
+ oldPermission, _ := access_model.GetUserRepoPermission(ctx, oldRepo, doer)
+ permission, _ := access_model.GetUserRepoPermission(ctx, repo, doer)
+
+ // forked webhook
+ if err := PrepareWebhooks(ctx, EventSource{Repository: oldRepo}, webhook_module.HookEventFork, &api.ForkPayload{
+ Forkee: convert.ToRepo(ctx, oldRepo, oldPermission),
+ Repo: convert.ToRepo(ctx, repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ }); err != nil {
+ log.Error("PrepareWebhooks [repo_id: %d]: %v", oldRepo.ID, err)
+ }
+
+ u := repo.MustOwner(ctx)
+
+ // Add to hook queue for created repo after session commit.
+ if u.IsOrganization() {
+ if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventRepository, &api.RepositoryPayload{
+ Action: api.HookRepoCreated,
+ Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
+ Organization: convert.ToUser(ctx, u, nil),
+ Sender: convert.ToUser(ctx, doer, nil),
+ }); err != nil {
+ log.Error("PrepareWebhooks [repo_id: %d]: %v", repo.ID, err)
+ }
+ }
+}
+
+func (m *webhookNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) {
+ // Add to hook queue for created repo after session commit.
+ if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventRepository, &api.RepositoryPayload{
+ Action: api.HookRepoCreated,
+ Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
+ Organization: convert.ToUser(ctx, u, nil),
+ Sender: convert.ToUser(ctx, doer, nil),
+ }); err != nil {
+ log.Error("PrepareWebhooks [repo_id: %d]: %v", repo.ID, err)
+ }
+}
+
+func (m *webhookNotifier) DeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository) {
+ if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventRepository, &api.RepositoryPayload{
+ Action: api.HookRepoDeleted,
+ Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
+ Organization: convert.ToUser(ctx, repo.MustOwner(ctx), nil),
+ Sender: convert.ToUser(ctx, doer, nil),
+ }); err != nil {
+ log.Error("PrepareWebhooks [repo_id: %d]: %v", repo.ID, err)
+ }
+}
+
+func (m *webhookNotifier) MigrateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) {
+ // Add to hook queue for created repo after session commit.
+ if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventRepository, &api.RepositoryPayload{
+ Action: api.HookRepoCreated,
+ Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
+ Organization: convert.ToUser(ctx, u, nil),
+ Sender: convert.ToUser(ctx, doer, nil),
+ }); err != nil {
+ log.Error("PrepareWebhooks [repo_id: %d]: %v", repo.ID, err)
+ }
+}
+
+func (m *webhookNotifier) IssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) {
+ if issue.IsPull {
+ permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, doer)
+
+ if err := issue.LoadPullRequest(ctx); err != nil {
+ log.Error("LoadPullRequest failed: %v", err)
+ return
+ }
+ apiPullRequest := &api.PullRequestPayload{
+ Index: issue.Index,
+ PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil),
+ Repository: convert.ToRepo(ctx, issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ }
+ if removed {
+ apiPullRequest.Action = api.HookIssueUnassigned
+ } else {
+ apiPullRequest.Action = api.HookIssueAssigned
+ }
+ // Assignee comment triggers a webhook
+ if err := PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventPullRequestAssign, apiPullRequest); err != nil {
+ log.Error("PrepareWebhooks [is_pull: %v, remove_assignee: %v]: %v", issue.IsPull, removed, err)
+ return
+ }
+ } else {
+ permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, doer)
+ apiIssue := &api.IssuePayload{
+ Index: issue.Index,
+ Issue: convert.ToAPIIssue(ctx, doer, issue),
+ Repository: convert.ToRepo(ctx, issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ }
+ if removed {
+ apiIssue.Action = api.HookIssueUnassigned
+ } else {
+ apiIssue.Action = api.HookIssueAssigned
+ }
+ // Assignee comment triggers a webhook
+ if err := PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventIssueAssign, apiIssue); err != nil {
+ log.Error("PrepareWebhooks [is_pull: %v, remove_assignee: %v]: %v", issue.IsPull, removed, err)
+ return
+ }
+ }
+}
+
+func (m *webhookNotifier) IssueChangeTitle(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldTitle string) {
+ permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster)
+ var err error
+ if issue.IsPull {
+ if err = issue.LoadPullRequest(ctx); err != nil {
+ log.Error("LoadPullRequest failed: %v", err)
+ return
+ }
+ err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventPullRequest, &api.PullRequestPayload{
+ Action: api.HookIssueEdited,
+ Index: issue.Index,
+ Changes: &api.ChangesPayload{
+ Title: &api.ChangesFromPayload{
+ From: oldTitle,
+ },
+ },
+ PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil),
+ Repository: convert.ToRepo(ctx, issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ })
+ } else {
+ err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventIssues, &api.IssuePayload{
+ Action: api.HookIssueEdited,
+ Index: issue.Index,
+ Changes: &api.ChangesPayload{
+ Title: &api.ChangesFromPayload{
+ From: oldTitle,
+ },
+ },
+ Issue: convert.ToAPIIssue(ctx, doer, issue),
+ Repository: convert.ToRepo(ctx, issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ })
+ }
+
+ if err != nil {
+ log.Error("PrepareWebhooks [is_pull: %v]: %v", issue.IsPull, err)
+ }
+}
+
+func (m *webhookNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, isClosed bool) {
+ permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster)
+ var err error
+ if issue.IsPull {
+ if err = issue.LoadPullRequest(ctx); err != nil {
+ log.Error("LoadPullRequest: %v", err)
+ return
+ }
+ // Merge pull request calls issue.changeStatus so we need to handle separately.
+ apiPullRequest := &api.PullRequestPayload{
+ Index: issue.Index,
+ PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil),
+ Repository: convert.ToRepo(ctx, issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ CommitID: commitID,
+ }
+ if isClosed {
+ apiPullRequest.Action = api.HookIssueClosed
+ } else {
+ apiPullRequest.Action = api.HookIssueReOpened
+ }
+ err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventPullRequest, apiPullRequest)
+ } else {
+ apiIssue := &api.IssuePayload{
+ Index: issue.Index,
+ Issue: convert.ToAPIIssue(ctx, doer, issue),
+ Repository: convert.ToRepo(ctx, issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ CommitID: commitID,
+ }
+ if isClosed {
+ apiIssue.Action = api.HookIssueClosed
+ } else {
+ apiIssue.Action = api.HookIssueReOpened
+ }
+ err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventIssues, apiIssue)
+ }
+ if err != nil {
+ log.Error("PrepareWebhooks [is_pull: %v, is_closed: %v]: %v", issue.IsPull, isClosed, err)
+ }
+}
+
+func (m *webhookNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) {
+ if err := issue.LoadRepo(ctx); err != nil {
+ log.Error("issue.LoadRepo: %v", err)
+ return
+ }
+ if err := issue.LoadPoster(ctx); err != nil {
+ log.Error("issue.LoadPoster: %v", err)
+ return
+ }
+
+ permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster)
+ if err := PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventIssues, &api.IssuePayload{
+ Action: api.HookIssueOpened,
+ Index: issue.Index,
+ Issue: convert.ToAPIIssue(ctx, issue.Poster, issue),
+ Repository: convert.ToRepo(ctx, issue.Repo, permission),
+ Sender: convert.ToUser(ctx, issue.Poster, nil),
+ }); err != nil {
+ log.Error("PrepareWebhooks: %v", err)
+ }
+}
+
+func (m *webhookNotifier) NewPullRequest(ctx context.Context, pull *issues_model.PullRequest, mentions []*user_model.User) {
+ if err := pull.LoadIssue(ctx); err != nil {
+ log.Error("pull.LoadIssue: %v", err)
+ return
+ }
+ if err := pull.Issue.LoadRepo(ctx); err != nil {
+ log.Error("pull.Issue.LoadRepo: %v", err)
+ return
+ }
+ if err := pull.Issue.LoadPoster(ctx); err != nil {
+ log.Error("pull.Issue.LoadPoster: %v", err)
+ return
+ }
+
+ permission, _ := access_model.GetUserRepoPermission(ctx, pull.Issue.Repo, pull.Issue.Poster)
+ if err := PrepareWebhooks(ctx, EventSource{Repository: pull.Issue.Repo}, webhook_module.HookEventPullRequest, &api.PullRequestPayload{
+ Action: api.HookIssueOpened,
+ Index: pull.Issue.Index,
+ PullRequest: convert.ToAPIPullRequest(ctx, pull, nil),
+ Repository: convert.ToRepo(ctx, pull.Issue.Repo, permission),
+ Sender: convert.ToUser(ctx, pull.Issue.Poster, nil),
+ }); err != nil {
+ log.Error("PrepareWebhooks: %v", err)
+ }
+}
+
+func (m *webhookNotifier) IssueChangeContent(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldContent string) {
+ if err := issue.LoadRepo(ctx); err != nil {
+ log.Error("LoadRepo: %v", err)
+ return
+ }
+
+ permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster)
+ var err error
+ if issue.IsPull {
+ if err := issue.LoadPullRequest(ctx); err != nil {
+ log.Error("LoadPullRequest: %v", err)
+ return
+ }
+ err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventPullRequest, &api.PullRequestPayload{
+ Action: api.HookIssueEdited,
+ Index: issue.Index,
+ Changes: &api.ChangesPayload{
+ Body: &api.ChangesFromPayload{
+ From: oldContent,
+ },
+ },
+ PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil),
+ Repository: convert.ToRepo(ctx, issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ })
+ } else {
+ err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventIssues, &api.IssuePayload{
+ Action: api.HookIssueEdited,
+ Index: issue.Index,
+ Changes: &api.ChangesPayload{
+ Body: &api.ChangesFromPayload{
+ From: oldContent,
+ },
+ },
+ Issue: convert.ToAPIIssue(ctx, doer, issue),
+ Repository: convert.ToRepo(ctx, issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ })
+ }
+ if err != nil {
+ log.Error("PrepareWebhooks [is_pull: %v]: %v", issue.IsPull, err)
+ }
+}
+
+func (m *webhookNotifier) UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment, oldContent string) {
+ if err := c.LoadPoster(ctx); err != nil {
+ log.Error("LoadPoster: %v", err)
+ return
+ }
+ if err := c.LoadIssue(ctx); err != nil {
+ log.Error("LoadIssue: %v", err)
+ return
+ }
+
+ if err := c.Issue.LoadAttributes(ctx); err != nil {
+ log.Error("LoadAttributes: %v", err)
+ return
+ }
+
+ var eventType webhook_module.HookEventType
+ if c.Issue.IsPull {
+ eventType = webhook_module.HookEventPullRequestComment
+ } else {
+ eventType = webhook_module.HookEventIssueComment
+ }
+
+ permission, _ := access_model.GetUserRepoPermission(ctx, c.Issue.Repo, doer)
+ if err := PrepareWebhooks(ctx, EventSource{Repository: c.Issue.Repo}, eventType, &api.IssueCommentPayload{
+ Action: api.HookIssueCommentEdited,
+ Issue: convert.ToAPIIssue(ctx, doer, c.Issue),
+ Comment: convert.ToAPIComment(ctx, c.Issue.Repo, c),
+ Changes: &api.ChangesPayload{
+ Body: &api.ChangesFromPayload{
+ From: oldContent,
+ },
+ },
+ Repository: convert.ToRepo(ctx, c.Issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ IsPull: c.Issue.IsPull,
+ }); err != nil {
+ log.Error("PrepareWebhooks [comment_id: %d]: %v", c.ID, err)
+ }
+}
+
+func (m *webhookNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository,
+ issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User,
+) {
+ var eventType webhook_module.HookEventType
+ if issue.IsPull {
+ eventType = webhook_module.HookEventPullRequestComment
+ } else {
+ eventType = webhook_module.HookEventIssueComment
+ }
+
+ permission, _ := access_model.GetUserRepoPermission(ctx, repo, doer)
+ if err := PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, eventType, &api.IssueCommentPayload{
+ Action: api.HookIssueCommentCreated,
+ Issue: convert.ToAPIIssue(ctx, doer, issue),
+ Comment: convert.ToAPIComment(ctx, repo, comment),
+ Repository: convert.ToRepo(ctx, repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ IsPull: issue.IsPull,
+ }); err != nil {
+ log.Error("PrepareWebhooks [comment_id: %d]: %v", comment.ID, err)
+ }
+}
+
+func (m *webhookNotifier) DeleteComment(ctx context.Context, doer *user_model.User, comment *issues_model.Comment) {
+ var err error
+
+ if err = comment.LoadPoster(ctx); err != nil {
+ log.Error("LoadPoster: %v", err)
+ return
+ }
+ if err = comment.LoadIssue(ctx); err != nil {
+ log.Error("LoadIssue: %v", err)
+ return
+ }
+
+ if err = comment.Issue.LoadAttributes(ctx); err != nil {
+ log.Error("LoadAttributes: %v", err)
+ return
+ }
+
+ var eventType webhook_module.HookEventType
+ if comment.Issue.IsPull {
+ eventType = webhook_module.HookEventPullRequestComment
+ } else {
+ eventType = webhook_module.HookEventIssueComment
+ }
+
+ permission, _ := access_model.GetUserRepoPermission(ctx, comment.Issue.Repo, doer)
+ if err := PrepareWebhooks(ctx, EventSource{Repository: comment.Issue.Repo}, eventType, &api.IssueCommentPayload{
+ Action: api.HookIssueCommentDeleted,
+ Issue: convert.ToAPIIssue(ctx, doer, comment.Issue),
+ Comment: convert.ToAPIComment(ctx, comment.Issue.Repo, comment),
+ Repository: convert.ToRepo(ctx, comment.Issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ IsPull: comment.Issue.IsPull,
+ }); err != nil {
+ log.Error("PrepareWebhooks [comment_id: %d]: %v", comment.ID, err)
+ }
+}
+
+func (m *webhookNotifier) NewWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) {
+ // Add to hook queue for created wiki page.
+ if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventWiki, &api.WikiPayload{
+ Action: api.HookWikiCreated,
+ Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
+ Sender: convert.ToUser(ctx, doer, nil),
+ Page: page,
+ Comment: comment,
+ }); err != nil {
+ log.Error("PrepareWebhooks [repo_id: %d]: %v", repo.ID, err)
+ }
+}
+
+func (m *webhookNotifier) EditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) {
+ // Add to hook queue for edit wiki page.
+ if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventWiki, &api.WikiPayload{
+ Action: api.HookWikiEdited,
+ Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
+ Sender: convert.ToUser(ctx, doer, nil),
+ Page: page,
+ Comment: comment,
+ }); err != nil {
+ log.Error("PrepareWebhooks [repo_id: %d]: %v", repo.ID, err)
+ }
+}
+
+func (m *webhookNotifier) DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page string) {
+ // Add to hook queue for edit wiki page.
+ if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventWiki, &api.WikiPayload{
+ Action: api.HookWikiDeleted,
+ Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
+ Sender: convert.ToUser(ctx, doer, nil),
+ Page: page,
+ }); err != nil {
+ log.Error("PrepareWebhooks [repo_id: %d]: %v", repo.ID, err)
+ }
+}
+
+func (m *webhookNotifier) IssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue,
+ addedLabels, removedLabels []*issues_model.Label,
+) {
+ var err error
+
+ if err = issue.LoadRepo(ctx); err != nil {
+ log.Error("LoadRepo: %v", err)
+ return
+ }
+
+ if err = issue.LoadPoster(ctx); err != nil {
+ log.Error("LoadPoster: %v", err)
+ return
+ }
+
+ permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster)
+ if issue.IsPull {
+ if err = issue.LoadPullRequest(ctx); err != nil {
+ log.Error("loadPullRequest: %v", err)
+ return
+ }
+ if err = issue.PullRequest.LoadIssue(ctx); err != nil {
+ log.Error("LoadIssue: %v", err)
+ return
+ }
+ err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventPullRequestLabel, &api.PullRequestPayload{
+ Action: api.HookIssueLabelUpdated,
+ Index: issue.Index,
+ PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil),
+ Repository: convert.ToRepo(ctx, issue.Repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
+ Sender: convert.ToUser(ctx, doer, nil),
+ })
+ } else {
+ err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventIssueLabel, &api.IssuePayload{
+ Action: api.HookIssueLabelUpdated,
+ Index: issue.Index,
+ Issue: convert.ToAPIIssue(ctx, doer, issue),
+ Repository: convert.ToRepo(ctx, issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ })
+ }
+ if err != nil {
+ log.Error("PrepareWebhooks [is_pull: %v]: %v", issue.IsPull, err)
+ }
+}
+
+func (m *webhookNotifier) IssueChangeMilestone(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldMilestoneID int64) {
+ var hookAction api.HookIssueAction
+ var err error
+ if issue.MilestoneID > 0 {
+ hookAction = api.HookIssueMilestoned
+ } else {
+ hookAction = api.HookIssueDemilestoned
+ }
+
+ if err = issue.LoadAttributes(ctx); err != nil {
+ log.Error("issue.LoadAttributes failed: %v", err)
+ return
+ }
+
+ permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, doer)
+ if issue.IsPull {
+ err = issue.PullRequest.LoadIssue(ctx)
+ if err != nil {
+ log.Error("LoadIssue: %v", err)
+ return
+ }
+ err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventPullRequestMilestone, &api.PullRequestPayload{
+ Action: hookAction,
+ Index: issue.Index,
+ PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil),
+ Repository: convert.ToRepo(ctx, issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ })
+ } else {
+ err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventIssueMilestone, &api.IssuePayload{
+ Action: hookAction,
+ Index: issue.Index,
+ Issue: convert.ToAPIIssue(ctx, doer, issue),
+ Repository: convert.ToRepo(ctx, issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ })
+ }
+ if err != nil {
+ log.Error("PrepareWebhooks [is_pull: %v]: %v", issue.IsPull, err)
+ }
+}
+
+func (m *webhookNotifier) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) {
+ apiPusher := convert.ToUser(ctx, pusher, nil)
+ apiCommits, apiHeadCommit, err := commits.ToAPIPayloadCommits(ctx, repo.RepoPath(), repo.HTMLURL())
+ if err != nil {
+ log.Error("commits.ToAPIPayloadCommits failed: %v", err)
+ return
+ }
+
+ if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventPush, &api.PushPayload{
+ Ref: opts.RefFullName.String(),
+ Before: opts.OldCommitID,
+ After: opts.NewCommitID,
+ CompareURL: setting.AppURL + commits.CompareURL,
+ Commits: apiCommits,
+ TotalCommits: commits.Len,
+ HeadCommit: apiHeadCommit,
+ Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
+ Pusher: apiPusher,
+ Sender: apiPusher,
+ }); err != nil {
+ log.Error("PrepareWebhooks: %v", err)
+ }
+}
+
+func (m *webhookNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
+ // just redirect to the MergePullRequest
+ m.MergePullRequest(ctx, doer, pr)
+}
+
+func (*webhookNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
+ // Reload pull request information.
+ if err := pr.LoadAttributes(ctx); err != nil {
+ log.Error("LoadAttributes: %v", err)
+ return
+ }
+
+ if err := pr.LoadIssue(ctx); err != nil {
+ log.Error("LoadIssue: %v", err)
+ return
+ }
+
+ if err := pr.Issue.LoadRepo(ctx); err != nil {
+ log.Error("pr.Issue.LoadRepo: %v", err)
+ return
+ }
+
+ permission, err := access_model.GetUserRepoPermission(ctx, pr.Issue.Repo, doer)
+ if err != nil {
+ log.Error("models.GetUserRepoPermission: %v", err)
+ return
+ }
+
+ // Merge pull request calls issue.changeStatus so we need to handle separately.
+ apiPullRequest := &api.PullRequestPayload{
+ Index: pr.Issue.Index,
+ PullRequest: convert.ToAPIPullRequest(ctx, pr, nil),
+ Repository: convert.ToRepo(ctx, pr.Issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ Action: api.HookIssueClosed,
+ }
+
+ if err := PrepareWebhooks(ctx, EventSource{Repository: pr.Issue.Repo}, webhook_module.HookEventPullRequest, apiPullRequest); err != nil {
+ log.Error("PrepareWebhooks: %v", err)
+ }
+}
+
+func (m *webhookNotifier) PullRequestChangeTargetBranch(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, oldBranch string) {
+ if err := pr.LoadIssue(ctx); err != nil {
+ log.Error("LoadIssue: %v", err)
+ return
+ }
+
+ issue := pr.Issue
+
+ mode, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster)
+ if err := PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventPullRequest, &api.PullRequestPayload{
+ Action: api.HookIssueEdited,
+ Index: issue.Index,
+ Changes: &api.ChangesPayload{
+ Ref: &api.ChangesFromPayload{
+ From: oldBranch,
+ },
+ },
+ PullRequest: convert.ToAPIPullRequest(ctx, pr, nil),
+ Repository: convert.ToRepo(ctx, issue.Repo, mode),
+ Sender: convert.ToUser(ctx, doer, nil),
+ }); err != nil {
+ log.Error("PrepareWebhooks [pr: %d]: %v", pr.ID, err)
+ }
+}
+
+func (m *webhookNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) {
+ var reviewHookType webhook_module.HookEventType
+
+ switch review.Type {
+ case issues_model.ReviewTypeApprove:
+ reviewHookType = webhook_module.HookEventPullRequestReviewApproved
+ case issues_model.ReviewTypeComment:
+ reviewHookType = webhook_module.HookEventPullRequestReviewComment
+ case issues_model.ReviewTypeReject:
+ reviewHookType = webhook_module.HookEventPullRequestReviewRejected
+ default:
+ // unsupported review webhook type here
+ log.Error("Unsupported review webhook type")
+ return
+ }
+
+ if err := pr.LoadIssue(ctx); err != nil {
+ log.Error("LoadIssue: %v", err)
+ return
+ }
+
+ permission, err := access_model.GetUserRepoPermission(ctx, review.Issue.Repo, review.Issue.Poster)
+ if err != nil {
+ log.Error("models.GetUserRepoPermission: %v", err)
+ return
+ }
+ if err := PrepareWebhooks(ctx, EventSource{Repository: review.Issue.Repo}, reviewHookType, &api.PullRequestPayload{
+ Action: api.HookIssueReviewed,
+ Index: review.Issue.Index,
+ PullRequest: convert.ToAPIPullRequest(ctx, pr, nil),
+ Repository: convert.ToRepo(ctx, review.Issue.Repo, permission),
+ Sender: convert.ToUser(ctx, review.Reviewer, nil),
+ Review: &api.ReviewPayload{
+ Type: string(reviewHookType),
+ Content: review.Content,
+ },
+ }); err != nil {
+ log.Error("PrepareWebhooks: %v", err)
+ }
+}
+
+func (m *webhookNotifier) PullRequestReviewRequest(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, reviewer *user_model.User, isRequest bool, comment *issues_model.Comment) {
+ if !issue.IsPull {
+ log.Warn("PullRequestReviewRequest: issue is not a pull request: %v", issue.ID)
+ return
+ }
+ permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, doer)
+ if err := issue.LoadPullRequest(ctx); err != nil {
+ log.Error("LoadPullRequest failed: %v", err)
+ return
+ }
+ apiPullRequest := &api.PullRequestPayload{
+ Index: issue.Index,
+ PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil),
+ RequestedReviewer: convert.ToUser(ctx, reviewer, nil),
+ Repository: convert.ToRepo(ctx, issue.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ }
+ if isRequest {
+ apiPullRequest.Action = api.HookIssueReviewRequested
+ } else {
+ apiPullRequest.Action = api.HookIssueReviewRequestRemoved
+ }
+ if err := PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventPullRequestReviewRequest, apiPullRequest); err != nil {
+ log.Error("PrepareWebhooks [review_requested: %v]: %v", isRequest, err)
+ return
+ }
+}
+
+func (m *webhookNotifier) CreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) {
+ apiPusher := convert.ToUser(ctx, pusher, nil)
+ apiRepo := convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone})
+
+ if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventCreate, &api.CreatePayload{
+ Ref: refFullName.String(),
+ Sha: refID,
+ RefType: refFullName.RefType(),
+ Repo: apiRepo,
+ Sender: apiPusher,
+ }); err != nil {
+ log.Error("PrepareWebhooks: %v", err)
+ }
+}
+
+func (m *webhookNotifier) PullRequestSynchronized(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
+ if err := pr.LoadIssue(ctx); err != nil {
+ log.Error("LoadIssue: %v", err)
+ return
+ }
+ if err := pr.Issue.LoadAttributes(ctx); err != nil {
+ log.Error("LoadAttributes: %v", err)
+ return
+ }
+
+ if err := PrepareWebhooks(ctx, EventSource{Repository: pr.Issue.Repo}, webhook_module.HookEventPullRequestSync, &api.PullRequestPayload{
+ Action: api.HookIssueSynchronized,
+ Index: pr.Issue.Index,
+ PullRequest: convert.ToAPIPullRequest(ctx, pr, nil),
+ Repository: convert.ToRepo(ctx, pr.Issue.Repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
+ Sender: convert.ToUser(ctx, doer, nil),
+ }); err != nil {
+ log.Error("PrepareWebhooks [pull_id: %v]: %v", pr.ID, err)
+ }
+}
+
+func (m *webhookNotifier) DeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName) {
+ apiPusher := convert.ToUser(ctx, pusher, nil)
+ apiRepo := convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner})
+
+ if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventDelete, &api.DeletePayload{
+ Ref: refFullName.String(),
+ RefType: refFullName.RefType(),
+ PusherType: api.PusherTypeUser,
+ Repo: apiRepo,
+ Sender: apiPusher,
+ }); err != nil {
+ log.Error("PrepareWebhooks.(delete %s): %v", refFullName.RefType(), err)
+ }
+}
+
+func sendReleaseHook(ctx context.Context, doer *user_model.User, rel *repo_model.Release, action api.HookReleaseAction) {
+ if err := rel.LoadAttributes(ctx); err != nil {
+ log.Error("LoadAttributes: %v", err)
+ return
+ }
+
+ permission, _ := access_model.GetUserRepoPermission(ctx, rel.Repo, doer)
+ if err := PrepareWebhooks(ctx, EventSource{Repository: rel.Repo}, webhook_module.HookEventRelease, &api.ReleasePayload{
+ Action: action,
+ Release: convert.ToAPIRelease(ctx, rel.Repo, rel),
+ Repository: convert.ToRepo(ctx, rel.Repo, permission),
+ Sender: convert.ToUser(ctx, doer, nil),
+ }); err != nil {
+ log.Error("PrepareWebhooks: %v", err)
+ }
+}
+
+func (m *webhookNotifier) NewRelease(ctx context.Context, rel *repo_model.Release) {
+ sendReleaseHook(ctx, rel.Publisher, rel, api.HookReleasePublished)
+}
+
+func (m *webhookNotifier) UpdateRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) {
+ sendReleaseHook(ctx, doer, rel, api.HookReleaseUpdated)
+}
+
+func (m *webhookNotifier) DeleteRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) {
+ sendReleaseHook(ctx, doer, rel, api.HookReleaseDeleted)
+}
+
+func (m *webhookNotifier) SyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) {
+ apiPusher := convert.ToUser(ctx, pusher, nil)
+ apiCommits, apiHeadCommit, err := commits.ToAPIPayloadCommits(ctx, repo.RepoPath(), repo.HTMLURL())
+ if err != nil {
+ log.Error("commits.ToAPIPayloadCommits failed: %v", err)
+ return
+ }
+
+ if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventPush, &api.PushPayload{
+ Ref: opts.RefFullName.String(),
+ Before: opts.OldCommitID,
+ After: opts.NewCommitID,
+ CompareURL: setting.AppURL + commits.CompareURL,
+ Commits: apiCommits,
+ TotalCommits: commits.Len,
+ HeadCommit: apiHeadCommit,
+ Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
+ Pusher: apiPusher,
+ Sender: apiPusher,
+ }); err != nil {
+ log.Error("PrepareWebhooks: %v", err)
+ }
+}
+
+func (m *webhookNotifier) SyncCreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) {
+ m.CreateRef(ctx, pusher, repo, refFullName, refID)
+}
+
+func (m *webhookNotifier) SyncDeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName) {
+ m.DeleteRef(ctx, pusher, repo, refFullName)
+}
+
+func (m *webhookNotifier) PackageCreate(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) {
+ notifyPackage(ctx, doer, pd, api.HookPackageCreated)
+}
+
+func (m *webhookNotifier) PackageDelete(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) {
+ notifyPackage(ctx, doer, pd, api.HookPackageDeleted)
+}
+
+func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_model.PackageDescriptor, action api.HookPackageAction) {
+ source := EventSource{
+ Repository: pd.Repository,
+ Owner: pd.Owner,
+ }
+
+ apiPackage, err := convert.ToPackage(ctx, pd, sender)
+ if err != nil {
+ log.Error("Error converting package: %v", err)
+ return
+ }
+
+ if err := PrepareWebhooks(ctx, source, webhook_module.HookEventPackage, &api.PackagePayload{
+ Action: action,
+ Package: apiPackage,
+ Sender: convert.ToUser(ctx, sender, nil),
+ }); err != nil {
+ log.Error("PrepareWebhooks: %v", err)
+ }
+}
diff --git a/services/webhook/packagist.go b/services/webhook/packagist.go
new file mode 100644
index 0000000..9831a4e
--- /dev/null
+++ b/services/webhook/packagist.go
@@ -0,0 +1,90 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "net/http"
+ "net/url"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/webhook/shared"
+)
+
+type packagistHandler struct{}
+
+func (packagistHandler) Type() webhook_module.HookType { return webhook_module.PACKAGIST }
+func (packagistHandler) Icon(size int) template.HTML { return shared.ImgIcon("packagist.png", size) }
+
+func (packagistHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
+ var form struct {
+ forms.WebhookCoreForm
+ Username string `binding:"Required"`
+ APIToken string `binding:"Required"`
+ PackageURL string `binding:"Required;ValidUrl"`
+ }
+ bind(&form)
+
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: fmt.Sprintf("https://packagist.org/api/update-package?username=%s&apiToken=%s", url.QueryEscape(form.Username), url.QueryEscape(form.APIToken)),
+ ContentType: webhook_model.ContentTypeJSON,
+ Secret: "",
+ HTTPMethod: http.MethodPost,
+ Metadata: &PackagistMeta{
+ Username: form.Username,
+ APIToken: form.APIToken,
+ PackageURL: form.PackageURL,
+ },
+ }
+}
+
+type (
+ // PackagistPayload represents a packagist payload
+ // as expected by https://packagist.org/about
+ PackagistPayload struct {
+ PackagistRepository struct {
+ URL string `json:"url"`
+ } `json:"repository"`
+ }
+
+ // PackagistMeta contains the metadata for the webhook
+ PackagistMeta struct {
+ Username string `json:"username"`
+ APIToken string `json:"api_token"`
+ PackageURL string `json:"package_url"`
+ }
+)
+
+// Metadata returns packagist metadata
+func (packagistHandler) Metadata(w *webhook_model.Webhook) any {
+ s := &PackagistMeta{}
+ if err := json.Unmarshal([]byte(w.Meta), s); err != nil {
+ log.Error("packagistHandler.Metadata(%d): %v", w.ID, err)
+ }
+ return s
+}
+
+// newPackagistRequest creates a request with the [PackagistPayload] for packagist (same payload for all events).
+func (packagistHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
+ meta := &PackagistMeta{}
+ if err := json.Unmarshal([]byte(w.Meta), meta); err != nil {
+ return nil, nil, fmt.Errorf("packagistHandler.NewRequest meta json: %w", err)
+ }
+
+ payload := PackagistPayload{
+ PackagistRepository: struct {
+ URL string `json:"url"`
+ }{
+ URL: meta.PackageURL,
+ },
+ }
+ return shared.NewJSONRequestWithPayload(payload, w, t, false)
+}
diff --git a/services/webhook/packagist_test.go b/services/webhook/packagist_test.go
new file mode 100644
index 0000000..320c1c8
--- /dev/null
+++ b/services/webhook/packagist_test.go
@@ -0,0 +1,70 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/json"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackagistPayload(t *testing.T) {
+ payloads := []api.Payloader{
+ createTestPayload(),
+ deleteTestPayload(),
+ forkTestPayload(),
+ pushTestPayload(),
+ issueTestPayload(),
+ issueCommentTestPayload(),
+ pullRequestCommentTestPayload(),
+ pullRequestTestPayload(),
+ repositoryTestPayload(),
+ packageTestPayload(),
+ wikiTestPayload(),
+ pullReleaseTestPayload(),
+ }
+
+ for _, payloader := range payloads {
+ t.Run(fmt.Sprintf("%T", payloader), func(t *testing.T) {
+ data, err := payloader.JSONPayload()
+ require.NoError(t, err)
+
+ hook := &webhook_model.Webhook{
+ RepoID: 3,
+ IsActive: true,
+ Type: webhook_module.PACKAGIST,
+ URL: "https://packagist.org/api/update-package?username=THEUSERNAME&apiToken=TOPSECRETAPITOKEN",
+ Meta: `{"package_url":"https://packagist.org/packages/example"}`,
+ HTTPMethod: "POST",
+ }
+ task := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventPush,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ req, reqBody, err := packagistHandler{}.NewRequest(context.Background(), hook, task)
+ require.NotNil(t, req)
+ require.NotNil(t, reqBody)
+ require.NoError(t, err)
+
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, "https://packagist.org/api/update-package?username=THEUSERNAME&apiToken=TOPSECRETAPITOKEN", req.URL.String())
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+ var body PackagistPayload
+ err = json.NewDecoder(req.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Equal(t, "https://packagist.org/packages/example", body.PackagistRepository.URL)
+ })
+ }
+}
diff --git a/services/webhook/shared/img.go b/services/webhook/shared/img.go
new file mode 100644
index 0000000..2d65ba4
--- /dev/null
+++ b/services/webhook/shared/img.go
@@ -0,0 +1,15 @@
+package shared
+
+import (
+ "html"
+ "html/template"
+ "strconv"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func ImgIcon(name string, size int) template.HTML {
+ s := strconv.Itoa(size)
+ src := html.EscapeString(setting.StaticURLPrefix + "/assets/img/" + name)
+ return template.HTML(`<img width="` + s + `" height="` + s + `" src="` + src + `">`)
+}
diff --git a/services/webhook/shared/payloader.go b/services/webhook/shared/payloader.go
new file mode 100644
index 0000000..cf0bfa8
--- /dev/null
+++ b/services/webhook/shared/payloader.go
@@ -0,0 +1,161 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package shared
+
+import (
+ "bytes"
+ "crypto/hmac"
+ "crypto/sha1"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/json"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+)
+
+var ErrPayloadTypeNotSupported = errors.New("unsupported webhook event")
+
+// PayloadConvertor defines the interface to convert system payload to webhook payload
+type PayloadConvertor[T any] interface {
+ Create(*api.CreatePayload) (T, error)
+ Delete(*api.DeletePayload) (T, error)
+ Fork(*api.ForkPayload) (T, error)
+ Issue(*api.IssuePayload) (T, error)
+ IssueComment(*api.IssueCommentPayload) (T, error)
+ Push(*api.PushPayload) (T, error)
+ PullRequest(*api.PullRequestPayload) (T, error)
+ Review(*api.PullRequestPayload, webhook_module.HookEventType) (T, error)
+ Repository(*api.RepositoryPayload) (T, error)
+ Release(*api.ReleasePayload) (T, error)
+ Wiki(*api.WikiPayload) (T, error)
+ Package(*api.PackagePayload) (T, error)
+}
+
+func convertUnmarshalledJSON[T, P any](convert func(P) (T, error), data []byte) (T, error) {
+ var p P
+ if err := json.Unmarshal(data, &p); err != nil {
+ var t T
+ return t, fmt.Errorf("could not unmarshal payload: %w", err)
+ }
+ return convert(p)
+}
+
+func NewPayload[T any](rc PayloadConvertor[T], data []byte, event webhook_module.HookEventType) (T, error) {
+ switch event {
+ case webhook_module.HookEventCreate:
+ return convertUnmarshalledJSON(rc.Create, data)
+ case webhook_module.HookEventDelete:
+ return convertUnmarshalledJSON(rc.Delete, data)
+ case webhook_module.HookEventFork:
+ return convertUnmarshalledJSON(rc.Fork, data)
+ case webhook_module.HookEventIssues, webhook_module.HookEventIssueAssign, webhook_module.HookEventIssueLabel, webhook_module.HookEventIssueMilestone:
+ return convertUnmarshalledJSON(rc.Issue, data)
+ case webhook_module.HookEventIssueComment, webhook_module.HookEventPullRequestComment:
+ // previous code sometimes sent s.PullRequest(p.(*api.PullRequestPayload))
+ // however I couldn't find in notifier.go such a payload with an HookEvent***Comment event
+
+ // History (most recent first):
+ // - refactored in https://github.com/go-gitea/gitea/pull/12310
+ // - assertion added in https://github.com/go-gitea/gitea/pull/12046
+ // - issue raised in https://github.com/go-gitea/gitea/issues/11940#issuecomment-645713996
+ // > That's because for HookEventPullRequestComment event, some places use IssueCommentPayload and others use PullRequestPayload
+
+ // In modules/actions/workflows.go:183 the type assertion is always payload.(*api.IssueCommentPayload)
+ return convertUnmarshalledJSON(rc.IssueComment, data)
+ case webhook_module.HookEventPush:
+ return convertUnmarshalledJSON(rc.Push, data)
+ case webhook_module.HookEventPullRequest, webhook_module.HookEventPullRequestAssign, webhook_module.HookEventPullRequestLabel,
+ webhook_module.HookEventPullRequestMilestone, webhook_module.HookEventPullRequestSync, webhook_module.HookEventPullRequestReviewRequest:
+ return convertUnmarshalledJSON(rc.PullRequest, data)
+ case webhook_module.HookEventPullRequestReviewApproved, webhook_module.HookEventPullRequestReviewRejected, webhook_module.HookEventPullRequestReviewComment:
+ return convertUnmarshalledJSON(func(p *api.PullRequestPayload) (T, error) {
+ return rc.Review(p, event)
+ }, data)
+ case webhook_module.HookEventRepository:
+ return convertUnmarshalledJSON(rc.Repository, data)
+ case webhook_module.HookEventRelease:
+ return convertUnmarshalledJSON(rc.Release, data)
+ case webhook_module.HookEventWiki:
+ return convertUnmarshalledJSON(rc.Wiki, data)
+ case webhook_module.HookEventPackage:
+ return convertUnmarshalledJSON(rc.Package, data)
+ }
+ var t T
+ return t, fmt.Errorf("newPayload unsupported event: %s", event)
+}
+
+func NewJSONRequest[T any](pc PayloadConvertor[T], w *webhook_model.Webhook, t *webhook_model.HookTask, withDefaultHeaders bool) (*http.Request, []byte, error) {
+ payload, err := NewPayload(pc, []byte(t.PayloadContent), t.EventType)
+ if err != nil {
+ return nil, nil, err
+ }
+ return NewJSONRequestWithPayload(payload, w, t, withDefaultHeaders)
+}
+
+func NewJSONRequestWithPayload(payload any, w *webhook_model.Webhook, t *webhook_model.HookTask, withDefaultHeaders bool) (*http.Request, []byte, error) {
+ body, err := json.MarshalIndent(payload, "", " ")
+ if err != nil {
+ return nil, nil, err
+ }
+
+ method := w.HTTPMethod
+ if method == "" {
+ method = http.MethodPost
+ }
+
+ req, err := http.NewRequest(method, w.URL, bytes.NewReader(body))
+ if err != nil {
+ return nil, nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ if withDefaultHeaders {
+ return req, body, AddDefaultHeaders(req, []byte(w.Secret), t, body)
+ }
+ return req, body, nil
+}
+
+// AddDefaultHeaders adds the X-Forgejo, X-Gitea, X-Gogs, X-Hub, X-GitHub headers to the given request
+func AddDefaultHeaders(req *http.Request, secret []byte, t *webhook_model.HookTask, payloadContent []byte) error {
+ var signatureSHA1 string
+ var signatureSHA256 string
+ if len(secret) > 0 {
+ sig1 := hmac.New(sha1.New, secret)
+ sig256 := hmac.New(sha256.New, secret)
+ _, err := io.MultiWriter(sig1, sig256).Write(payloadContent)
+ if err != nil {
+ // this error should never happen, since the hashes are writing to []byte and always return a nil error.
+ return fmt.Errorf("prepareWebhooks.sigWrite: %w", err)
+ }
+ signatureSHA1 = hex.EncodeToString(sig1.Sum(nil))
+ signatureSHA256 = hex.EncodeToString(sig256.Sum(nil))
+ }
+
+ event := t.EventType.Event()
+ eventType := string(t.EventType)
+ req.Header.Add("X-Forgejo-Delivery", t.UUID)
+ req.Header.Add("X-Forgejo-Event", event)
+ req.Header.Add("X-Forgejo-Event-Type", eventType)
+ req.Header.Add("X-Forgejo-Signature", signatureSHA256)
+ req.Header.Add("X-Gitea-Delivery", t.UUID)
+ req.Header.Add("X-Gitea-Event", event)
+ req.Header.Add("X-Gitea-Event-Type", eventType)
+ req.Header.Add("X-Gitea-Signature", signatureSHA256)
+ req.Header.Add("X-Gogs-Delivery", t.UUID)
+ req.Header.Add("X-Gogs-Event", event)
+ req.Header.Add("X-Gogs-Event-Type", eventType)
+ req.Header.Add("X-Gogs-Signature", signatureSHA256)
+ req.Header.Add("X-Hub-Signature", "sha1="+signatureSHA1)
+ req.Header.Add("X-Hub-Signature-256", "sha256="+signatureSHA256)
+ req.Header["X-GitHub-Delivery"] = []string{t.UUID}
+ req.Header["X-GitHub-Event"] = []string{event}
+ req.Header["X-GitHub-Event-Type"] = []string{eventType}
+ return nil
+}
diff --git a/services/webhook/slack.go b/services/webhook/slack.go
new file mode 100644
index 0000000..af93976
--- /dev/null
+++ b/services/webhook/slack.go
@@ -0,0 +1,361 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "net/http"
+ "regexp"
+ "strings"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ gitea_context "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/webhook/shared"
+
+ "gitea.com/go-chi/binding"
+)
+
+type slackHandler struct{}
+
+func (slackHandler) Type() webhook_module.HookType { return webhook_module.SLACK }
+func (slackHandler) Icon(size int) template.HTML { return shared.ImgIcon("slack.png", size) }
+
+type slackForm struct {
+ forms.WebhookCoreForm
+ PayloadURL string `binding:"Required;ValidUrl"`
+ Channel string `binding:"Required"`
+ Username string
+ IconURL string
+ Color string
+}
+
+var _ binding.Validator = &slackForm{}
+
+// Validate implements binding.Validator.
+func (s *slackForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
+ ctx := gitea_context.GetWebContext(req)
+ if !IsValidSlackChannel(strings.TrimSpace(s.Channel)) {
+ errs = append(errs, binding.Error{
+ FieldNames: []string{"Channel"},
+ Classification: "",
+ Message: ctx.Locale.TrString("repo.settings.add_webhook.invalid_channel_name"),
+ })
+ }
+ return errs
+}
+
+func (slackHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
+ var form slackForm
+ bind(&form)
+
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: form.PayloadURL,
+ ContentType: webhook_model.ContentTypeJSON,
+ Secret: "",
+ HTTPMethod: http.MethodPost,
+ Metadata: &SlackMeta{
+ Channel: strings.TrimSpace(form.Channel),
+ Username: form.Username,
+ IconURL: form.IconURL,
+ Color: form.Color,
+ },
+ }
+}
+
+// SlackMeta contains the slack metadata
+type SlackMeta struct {
+ Channel string `json:"channel"`
+ Username string `json:"username"`
+ IconURL string `json:"icon_url"`
+ Color string `json:"color"`
+}
+
+// Metadata returns slack metadata
+func (slackHandler) Metadata(w *webhook_model.Webhook) any {
+ s := &SlackMeta{}
+ if err := json.Unmarshal([]byte(w.Meta), s); err != nil {
+ log.Error("slackHandler.Metadata(%d): %v", w.ID, err)
+ }
+ return s
+}
+
+// SlackPayload contains the information about the slack channel
+type SlackPayload struct {
+ Channel string `json:"channel"`
+ Text string `json:"text"`
+ Username string `json:"username"`
+ IconURL string `json:"icon_url"`
+ UnfurlLinks int `json:"unfurl_links"`
+ LinkNames int `json:"link_names"`
+ Attachments []SlackAttachment `json:"attachments"`
+}
+
+// SlackAttachment contains the slack message
+type SlackAttachment struct {
+ Fallback string `json:"fallback"`
+ Color string `json:"color"`
+ Title string `json:"title"`
+ TitleLink string `json:"title_link"`
+ Text string `json:"text"`
+}
+
+// SlackTextFormatter replaces &, <, > with HTML characters
+// see: https://api.slack.com/docs/formatting
+func SlackTextFormatter(s string) string {
+ // replace & < >
+ s = strings.ReplaceAll(s, "&", "&amp;")
+ s = strings.ReplaceAll(s, "<", "&lt;")
+ s = strings.ReplaceAll(s, ">", "&gt;")
+ return s
+}
+
+// SlackShortTextFormatter replaces &, <, > with HTML characters
+func SlackShortTextFormatter(s string) string {
+ s = strings.Split(s, "\n")[0]
+ // replace & < >
+ s = strings.ReplaceAll(s, "&", "&amp;")
+ s = strings.ReplaceAll(s, "<", "&lt;")
+ s = strings.ReplaceAll(s, ">", "&gt;")
+ return s
+}
+
+// SlackLinkFormatter creates a link compatible with slack
+func SlackLinkFormatter(url, text string) string {
+ return fmt.Sprintf("<%s|%s>", url, SlackTextFormatter(text))
+}
+
+// SlackLinkToRef slack-formatter link to a repo ref
+func SlackLinkToRef(repoURL, ref string) string {
+ // FIXME: SHA1 hardcoded here
+ url := git.RefURL(repoURL, ref)
+ refName := git.RefName(ref).ShortName()
+ return SlackLinkFormatter(url, refName)
+}
+
+// Create implements payloadConvertor Create method
+func (s slackConvertor) Create(p *api.CreatePayload) (SlackPayload, error) {
+ repoLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
+ refLink := SlackLinkToRef(p.Repo.HTMLURL, p.Ref)
+ text := fmt.Sprintf("[%s:%s] %s created by %s", repoLink, refLink, p.RefType, p.Sender.UserName)
+
+ return s.createPayload(text, nil), nil
+}
+
+// Delete composes Slack payload for delete a branch or tag.
+func (s slackConvertor) Delete(p *api.DeletePayload) (SlackPayload, error) {
+ refName := git.RefName(p.Ref).ShortName()
+ repoLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
+ text := fmt.Sprintf("[%s:%s] %s deleted by %s", repoLink, refName, p.RefType, p.Sender.UserName)
+
+ return s.createPayload(text, nil), nil
+}
+
+// Fork composes Slack payload for forked by a repository.
+func (s slackConvertor) Fork(p *api.ForkPayload) (SlackPayload, error) {
+ baseLink := SlackLinkFormatter(p.Forkee.HTMLURL, p.Forkee.FullName)
+ forkLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
+ text := fmt.Sprintf("%s is forked to %s", baseLink, forkLink)
+
+ return s.createPayload(text, nil), nil
+}
+
+// Issue implements payloadConvertor Issue method
+func (s slackConvertor) Issue(p *api.IssuePayload) (SlackPayload, error) {
+ text, issueTitle, attachmentText, color := getIssuesPayloadInfo(p, SlackLinkFormatter, true)
+
+ var attachments []SlackAttachment
+ if attachmentText != "" {
+ attachmentText = SlackTextFormatter(attachmentText)
+ issueTitle = SlackTextFormatter(issueTitle)
+ attachments = append(attachments, SlackAttachment{
+ Color: fmt.Sprintf("%x", color),
+ Title: issueTitle,
+ TitleLink: p.Issue.HTMLURL,
+ Text: attachmentText,
+ })
+ }
+
+ return s.createPayload(text, attachments), nil
+}
+
+// IssueComment implements payloadConvertor IssueComment method
+func (s slackConvertor) IssueComment(p *api.IssueCommentPayload) (SlackPayload, error) {
+ text, issueTitle, color := getIssueCommentPayloadInfo(p, SlackLinkFormatter, true)
+
+ return s.createPayload(text, []SlackAttachment{{
+ Color: fmt.Sprintf("%x", color),
+ Title: issueTitle,
+ TitleLink: p.Comment.HTMLURL,
+ Text: SlackTextFormatter(p.Comment.Body),
+ }}), nil
+}
+
+// Wiki implements payloadConvertor Wiki method
+func (s slackConvertor) Wiki(p *api.WikiPayload) (SlackPayload, error) {
+ text, _, _ := getWikiPayloadInfo(p, SlackLinkFormatter, true)
+
+ return s.createPayload(text, nil), nil
+}
+
+// Release implements payloadConvertor Release method
+func (s slackConvertor) Release(p *api.ReleasePayload) (SlackPayload, error) {
+ text, _ := getReleasePayloadInfo(p, SlackLinkFormatter, true)
+
+ return s.createPayload(text, nil), nil
+}
+
+func (s slackConvertor) Package(p *api.PackagePayload) (SlackPayload, error) {
+ text, _ := getPackagePayloadInfo(p, SlackLinkFormatter, true)
+
+ return s.createPayload(text, nil), nil
+}
+
+// Push implements payloadConvertor Push method
+func (s slackConvertor) Push(p *api.PushPayload) (SlackPayload, error) {
+ // n new commits
+ var (
+ commitDesc string
+ commitString string
+ )
+
+ if p.TotalCommits == 1 {
+ commitDesc = "1 new commit"
+ } else {
+ commitDesc = fmt.Sprintf("%d new commits", p.TotalCommits)
+ }
+ if len(p.CompareURL) > 0 {
+ commitString = SlackLinkFormatter(p.CompareURL, commitDesc)
+ } else {
+ commitString = commitDesc
+ }
+
+ repoLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
+ branchLink := SlackLinkToRef(p.Repo.HTMLURL, p.Ref)
+ text := fmt.Sprintf("[%s:%s] %s pushed by %s", repoLink, branchLink, commitString, p.Pusher.UserName)
+
+ var attachmentText string
+ // for each commit, generate attachment text
+ for i, commit := range p.Commits {
+ attachmentText += fmt.Sprintf("%s: %s - %s", SlackLinkFormatter(commit.URL, commit.ID[:7]), SlackShortTextFormatter(commit.Message), SlackTextFormatter(commit.Author.Name))
+ // add linebreak to each commit but the last
+ if i < len(p.Commits)-1 {
+ attachmentText += "\n"
+ }
+ }
+
+ return s.createPayload(text, []SlackAttachment{{
+ Color: s.Color,
+ Title: p.Repo.HTMLURL,
+ TitleLink: p.Repo.HTMLURL,
+ Text: attachmentText,
+ }}), nil
+}
+
+// PullRequest implements payloadConvertor PullRequest method
+func (s slackConvertor) PullRequest(p *api.PullRequestPayload) (SlackPayload, error) {
+ text, issueTitle, attachmentText, color := getPullRequestPayloadInfo(p, SlackLinkFormatter, true)
+
+ var attachments []SlackAttachment
+ if attachmentText != "" {
+ attachmentText = SlackTextFormatter(p.PullRequest.Body)
+ issueTitle = SlackTextFormatter(issueTitle)
+ attachments = append(attachments, SlackAttachment{
+ Color: fmt.Sprintf("%x", color),
+ Title: issueTitle,
+ TitleLink: p.PullRequest.HTMLURL,
+ Text: attachmentText,
+ })
+ }
+
+ return s.createPayload(text, attachments), nil
+}
+
+// Review implements payloadConvertor Review method
+func (s slackConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (SlackPayload, error) {
+ senderLink := SlackLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)
+ title := fmt.Sprintf("#%d %s", p.Index, p.PullRequest.Title)
+ titleLink := fmt.Sprintf("%s/pulls/%d", p.Repository.HTMLURL, p.Index)
+ repoLink := SlackLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
+ var text string
+
+ if p.Action == api.HookIssueReviewed {
+ action, err := parseHookPullRequestEventType(event)
+ if err != nil {
+ return SlackPayload{}, err
+ }
+
+ text = fmt.Sprintf("[%s] Pull request review %s: [%s](%s) by %s", repoLink, action, title, titleLink, senderLink)
+ }
+
+ return s.createPayload(text, nil), nil
+}
+
+// Repository implements payloadConvertor Repository method
+func (s slackConvertor) Repository(p *api.RepositoryPayload) (SlackPayload, error) {
+ senderLink := SlackLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)
+ repoLink := SlackLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
+ var text string
+
+ switch p.Action {
+ case api.HookRepoCreated:
+ text = fmt.Sprintf("[%s] Repository created by %s", repoLink, senderLink)
+ case api.HookRepoDeleted:
+ text = fmt.Sprintf("[%s] Repository deleted by %s", repoLink, senderLink)
+ }
+
+ return s.createPayload(text, nil), nil
+}
+
+func (s slackConvertor) createPayload(text string, attachments []SlackAttachment) SlackPayload {
+ return SlackPayload{
+ Channel: s.Channel,
+ Text: text,
+ Username: s.Username,
+ IconURL: s.IconURL,
+ Attachments: attachments,
+ }
+}
+
+type slackConvertor struct {
+ Channel string
+ Username string
+ IconURL string
+ Color string
+}
+
+var _ shared.PayloadConvertor[SlackPayload] = slackConvertor{}
+
+func (slackHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
+ meta := &SlackMeta{}
+ if err := json.Unmarshal([]byte(w.Meta), meta); err != nil {
+ return nil, nil, fmt.Errorf("slackHandler.NewRequest meta json: %w", err)
+ }
+ sc := slackConvertor{
+ Channel: meta.Channel,
+ Username: meta.Username,
+ IconURL: meta.IconURL,
+ Color: meta.Color,
+ }
+ return shared.NewJSONRequest(sc, w, t, true)
+}
+
+var slackChannel = regexp.MustCompile(`^#?[a-z0-9_-]{1,80}$`)
+
+// IsValidSlackChannel validates a channel name conforms to what slack expects:
+// https://api.slack.com/methods/conversations.rename#naming
+// Conversation names can only contain lowercase letters, numbers, hyphens, and underscores, and must be 80 characters or less.
+// Forgejo accepts if it starts with a #.
+func IsValidSlackChannel(name string) bool {
+ return slackChannel.MatchString(name)
+}
diff --git a/services/webhook/slack_test.go b/services/webhook/slack_test.go
new file mode 100644
index 0000000..3d80184
--- /dev/null
+++ b/services/webhook/slack_test.go
@@ -0,0 +1,265 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "testing"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/json"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSlackPayload(t *testing.T) {
+ sc := slackConvertor{}
+
+ t.Run("Create", func(t *testing.T) {
+ p := createTestPayload()
+
+ pl, err := sc.Create(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>:<http://localhost:3000/test/repo/src/branch/test|test>] branch created by user1", pl.Text)
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ p := deleteTestPayload()
+
+ pl, err := sc.Delete(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>:test] branch deleted by user1", pl.Text)
+ })
+
+ t.Run("Fork", func(t *testing.T) {
+ p := forkTestPayload()
+
+ pl, err := sc.Fork(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "<http://localhost:3000/test/repo2|test/repo2> is forked to <http://localhost:3000/test/repo|test/repo>", pl.Text)
+ })
+
+ t.Run("Push", func(t *testing.T) {
+ p := pushTestPayload()
+
+ pl, err := sc.Push(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>:<http://localhost:3000/test/repo/src/branch/test|test>] 2 new commits pushed by user1", pl.Text)
+ })
+
+ t.Run("Issue", func(t *testing.T) {
+ p := issueTestPayload()
+
+ p.Action = api.HookIssueOpened
+ pl, err := sc.Issue(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] Issue opened: <http://localhost:3000/test/repo/issues/2|#2 crash> by <https://try.gitea.io/user1|user1>", pl.Text)
+
+ p.Action = api.HookIssueClosed
+ pl, err = sc.Issue(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] Issue closed: <http://localhost:3000/test/repo/issues/2|#2 crash> by <https://try.gitea.io/user1|user1>", pl.Text)
+ })
+
+ t.Run("IssueComment", func(t *testing.T) {
+ p := issueCommentTestPayload()
+
+ pl, err := sc.IssueComment(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] New comment on issue <http://localhost:3000/test/repo/issues/2|#2 crash> by <https://try.gitea.io/user1|user1>", pl.Text)
+ })
+
+ t.Run("PullRequest", func(t *testing.T) {
+ p := pullRequestTestPayload()
+
+ pl, err := sc.PullRequest(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] Pull request opened: <http://localhost:3000/test/repo/pulls/12|#12 Fix bug> by <https://try.gitea.io/user1|user1>", pl.Text)
+ })
+
+ t.Run("PullRequestComment", func(t *testing.T) {
+ p := pullRequestCommentTestPayload()
+
+ pl, err := sc.IssueComment(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] New comment on pull request <http://localhost:3000/test/repo/pulls/12|#12 Fix bug> by <https://try.gitea.io/user1|user1>", pl.Text)
+ })
+
+ t.Run("Review", func(t *testing.T) {
+ p := pullRequestTestPayload()
+ p.Action = api.HookIssueReviewed
+
+ pl, err := sc.Review(p, webhook_module.HookEventPullRequestReviewApproved)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] Pull request review approved: [#12 Fix bug](http://localhost:3000/test/repo/pulls/12) by <https://try.gitea.io/user1|user1>", pl.Text)
+ })
+
+ t.Run("Repository", func(t *testing.T) {
+ p := repositoryTestPayload()
+
+ pl, err := sc.Repository(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] Repository created by <https://try.gitea.io/user1|user1>", pl.Text)
+ })
+
+ t.Run("Package", func(t *testing.T) {
+ p := packageTestPayload()
+
+ pl, err := sc.Package(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "Package created: <http://localhost:3000/user1/-/packages/container/GiteaContainer/latest|GiteaContainer:latest> by <https://try.gitea.io/user1|user1>", pl.Text)
+ })
+
+ t.Run("Wiki", func(t *testing.T) {
+ p := wikiTestPayload()
+
+ p.Action = api.HookWikiCreated
+ pl, err := sc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] New wiki page '<http://localhost:3000/test/repo/wiki/index|index>' (Wiki change comment) by <https://try.gitea.io/user1|user1>", pl.Text)
+
+ p.Action = api.HookWikiEdited
+ pl, err = sc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] Wiki page '<http://localhost:3000/test/repo/wiki/index|index>' edited (Wiki change comment) by <https://try.gitea.io/user1|user1>", pl.Text)
+
+ p.Action = api.HookWikiDeleted
+ pl, err = sc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] Wiki page '<http://localhost:3000/test/repo/wiki/index|index>' deleted by <https://try.gitea.io/user1|user1>", pl.Text)
+ })
+
+ t.Run("Release", func(t *testing.T) {
+ p := pullReleaseTestPayload()
+
+ pl, err := sc.Release(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] Release created: <http://localhost:3000/test/repo/releases/tag/v1.0|v1.0> by <https://try.gitea.io/user1|user1>", pl.Text)
+ })
+}
+
+func TestSlackJSONPayload(t *testing.T) {
+ p := pushTestPayload()
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ hook := &webhook_model.Webhook{
+ RepoID: 3,
+ IsActive: true,
+ Type: webhook_module.SLACK,
+ URL: "https://slack.example.com/",
+ Meta: `{}`,
+ HTTPMethod: "POST",
+ }
+ task := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventPush,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ req, reqBody, err := slackHandler{}.NewRequest(context.Background(), hook, task)
+ require.NotNil(t, req)
+ require.NotNil(t, reqBody)
+ require.NoError(t, err)
+
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, "https://slack.example.com/", req.URL.String())
+ assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256"))
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+ var body SlackPayload
+ err = json.NewDecoder(req.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>:<http://localhost:3000/test/repo/src/branch/test|test>] 2 new commits pushed by user1", body.Text)
+}
+
+func TestIsValidSlackChannel(t *testing.T) {
+ tt := []struct {
+ channelName string
+ expected bool
+ }{
+ {"gitea", true},
+ {"#gitea", true},
+ {" ", false},
+ {"#", false},
+ {" #", false},
+ {"gitea ", false},
+ {" gitea", false},
+ }
+
+ for _, v := range tt {
+ assert.Equal(t, v.expected, IsValidSlackChannel(v.channelName))
+ }
+}
+
+func TestSlackMetadata(t *testing.T) {
+ w := &webhook_model.Webhook{
+ Meta: `{"channel": "foo", "username": "username", "color": "blue"}`,
+ }
+ slackHook := slackHandler{}.Metadata(w)
+ assert.Equal(t, SlackMeta{
+ Channel: "foo",
+ Username: "username",
+ Color: "blue",
+ },
+ *slackHook.(*SlackMeta))
+}
+
+func TestSlackToHook(t *testing.T) {
+ w := &webhook_model.Webhook{
+ Type: webhook_module.SLACK,
+ ContentType: webhook_model.ContentTypeJSON,
+ URL: "https://slack.example.com",
+ Meta: `{"channel": "foo", "username": "username", "color": "blue"}`,
+ HookEvent: &webhook_module.HookEvent{
+ PushOnly: true,
+ SendEverything: false,
+ ChooseEvents: false,
+ HookEvents: webhook_module.HookEvents{
+ Create: false,
+ Push: true,
+ PullRequest: false,
+ },
+ },
+ }
+ h, err := ToHook("repoLink", w)
+ require.NoError(t, err)
+
+ assert.Equal(t, map[string]string{
+ "url": "https://slack.example.com",
+ "content_type": "json",
+
+ "channel": "foo",
+ "color": "blue",
+ "icon_url": "",
+ "username": "username",
+ }, h.Config)
+ assert.Equal(t, "https://slack.example.com", h.URL)
+ assert.Equal(t, "json", h.ContentType)
+ assert.Equal(t, &SlackMeta{
+ Channel: "foo",
+ Username: "username",
+ IconURL: "",
+ Color: "blue",
+ }, h.Metadata)
+}
diff --git a/services/webhook/sourcehut/builds.go b/services/webhook/sourcehut/builds.go
new file mode 100644
index 0000000..7b7ace1
--- /dev/null
+++ b/services/webhook/sourcehut/builds.go
@@ -0,0 +1,301 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package sourcehut
+
+import (
+ "cmp"
+ "context"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "net/http"
+ "strings"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ gitea_context "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/webhook/shared"
+
+ "gitea.com/go-chi/binding"
+ "gopkg.in/yaml.v3"
+)
+
+type BuildsHandler struct{}
+
+func (BuildsHandler) Type() webhook_module.HookType { return webhook_module.SOURCEHUT_BUILDS }
+func (BuildsHandler) Metadata(w *webhook_model.Webhook) any {
+ s := &BuildsMeta{}
+ if err := json.Unmarshal([]byte(w.Meta), s); err != nil {
+ log.Error("sourcehut.BuildsHandler.Metadata(%d): %v", w.ID, err)
+ }
+ return s
+}
+
+func (BuildsHandler) Icon(size int) template.HTML {
+ return shared.ImgIcon("sourcehut.svg", size)
+}
+
+type buildsForm struct {
+ forms.WebhookCoreForm
+ PayloadURL string `binding:"Required;ValidUrl"`
+ ManifestPath string `binding:"Required"`
+ Visibility string `binding:"Required;In(PUBLIC,UNLISTED,PRIVATE)"`
+ Secrets bool
+ AccessToken string `binding:"Required"`
+}
+
+var _ binding.Validator = &buildsForm{}
+
+// Validate implements binding.Validator.
+func (f *buildsForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
+ ctx := gitea_context.GetWebContext(req)
+ if !fs.ValidPath(f.ManifestPath) {
+ errs = append(errs, binding.Error{
+ FieldNames: []string{"ManifestPath"},
+ Classification: "",
+ Message: ctx.Locale.TrString("repo.settings.add_webhook.invalid_path"),
+ })
+ }
+ f.AuthorizationHeader = "Bearer " + strings.TrimSpace(f.AccessToken)
+ return errs
+}
+
+func (BuildsHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
+ var form buildsForm
+ bind(&form)
+
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: form.PayloadURL,
+ ContentType: webhook_model.ContentTypeJSON,
+ Secret: "",
+ HTTPMethod: http.MethodPost,
+ Metadata: &BuildsMeta{
+ ManifestPath: form.ManifestPath,
+ Visibility: form.Visibility,
+ Secrets: form.Secrets,
+ },
+ }
+}
+
+type (
+ graphqlPayload[V any] struct {
+ Query string `json:"query,omitempty"`
+ Error string `json:"error,omitempty"`
+ Variables V `json:"variables,omitempty"`
+ }
+ // buildsVariables according to https://man.sr.ht/builds.sr.ht/graphql.md
+ buildsVariables struct {
+ Manifest string `json:"manifest"`
+ Tags []string `json:"tags"`
+ Note string `json:"note"`
+ Secrets bool `json:"secrets"`
+ Execute bool `json:"execute"`
+ Visibility string `json:"visibility"`
+ }
+
+ // BuildsMeta contains the metadata for the webhook
+ BuildsMeta struct {
+ ManifestPath string `json:"manifest_path"`
+ Visibility string `json:"visibility"`
+ Secrets bool `json:"secrets"`
+ }
+)
+
+type sourcehutConvertor struct {
+ ctx context.Context
+ meta BuildsMeta
+}
+
+var _ shared.PayloadConvertor[graphqlPayload[buildsVariables]] = sourcehutConvertor{}
+
+func (BuildsHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
+ meta := BuildsMeta{}
+ if err := json.Unmarshal([]byte(w.Meta), &meta); err != nil {
+ return nil, nil, fmt.Errorf("newSourcehutRequest meta json: %w", err)
+ }
+ pc := sourcehutConvertor{
+ ctx: ctx,
+ meta: meta,
+ }
+ return shared.NewJSONRequest(pc, w, t, false)
+}
+
+// Create implements PayloadConvertor Create method
+func (pc sourcehutConvertor) Create(p *api.CreatePayload) (graphqlPayload[buildsVariables], error) {
+ return pc.newPayload(p.Repo, p.Sha, p.Ref, p.RefType+" "+git.RefName(p.Ref).ShortName()+" created", true)
+}
+
+// Delete implements PayloadConvertor Delete method
+func (pc sourcehutConvertor) Delete(_ *api.DeletePayload) (graphqlPayload[buildsVariables], error) {
+ return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// Fork implements PayloadConvertor Fork method
+func (pc sourcehutConvertor) Fork(_ *api.ForkPayload) (graphqlPayload[buildsVariables], error) {
+ return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// Push implements PayloadConvertor Push method
+func (pc sourcehutConvertor) Push(p *api.PushPayload) (graphqlPayload[buildsVariables], error) {
+ return pc.newPayload(p.Repo, p.HeadCommit.ID, p.Ref, p.HeadCommit.Message, true)
+}
+
+// Issue implements PayloadConvertor Issue method
+func (pc sourcehutConvertor) Issue(_ *api.IssuePayload) (graphqlPayload[buildsVariables], error) {
+ return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// IssueComment implements PayloadConvertor IssueComment method
+func (pc sourcehutConvertor) IssueComment(_ *api.IssueCommentPayload) (graphqlPayload[buildsVariables], error) {
+ return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// PullRequest implements PayloadConvertor PullRequest method
+func (pc sourcehutConvertor) PullRequest(_ *api.PullRequestPayload) (graphqlPayload[buildsVariables], error) {
+ // TODO
+ return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// Review implements PayloadConvertor Review method
+func (pc sourcehutConvertor) Review(_ *api.PullRequestPayload, _ webhook_module.HookEventType) (graphqlPayload[buildsVariables], error) {
+ return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// Repository implements PayloadConvertor Repository method
+func (pc sourcehutConvertor) Repository(_ *api.RepositoryPayload) (graphqlPayload[buildsVariables], error) {
+ return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// Wiki implements PayloadConvertor Wiki method
+func (pc sourcehutConvertor) Wiki(_ *api.WikiPayload) (graphqlPayload[buildsVariables], error) {
+ return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// Release implements PayloadConvertor Release method
+func (pc sourcehutConvertor) Release(_ *api.ReleasePayload) (graphqlPayload[buildsVariables], error) {
+ return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+func (pc sourcehutConvertor) Package(_ *api.PackagePayload) (graphqlPayload[buildsVariables], error) {
+ return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// mustBuildManifest adjusts the manifest to submit to the builds service
+//
+// in case of an error the Error field will be set, to be visible by the end-user under recent deliveries
+func (pc sourcehutConvertor) newPayload(repo *api.Repository, commitID, ref, note string, trusted bool) (graphqlPayload[buildsVariables], error) {
+ manifest, err := pc.buildManifest(repo, commitID, ref)
+ if err != nil {
+ if len(manifest) == 0 {
+ return graphqlPayload[buildsVariables]{}, err
+ }
+ // the manifest contains an error for the user: log the actual error and construct the payload
+ // the error will be visible under the "recent deliveries" of the webhook settings.
+ log.Warn("sourcehut.builds: could not construct manifest for %s: %v", repo.FullName, err)
+ msg := fmt.Sprintf("%s:%s %s", repo.FullName, ref, manifest)
+ return graphqlPayload[buildsVariables]{
+ Error: msg,
+ }, nil
+ }
+
+ gitRef := git.RefName(ref)
+ return graphqlPayload[buildsVariables]{
+ Query: `mutation (
+ $manifest: String!
+ $tags: [String!]
+ $note: String!
+ $secrets: Boolean!
+ $execute: Boolean!
+ $visibility: Visibility!
+) {
+ submit(
+ manifest: $manifest
+ tags: $tags
+ note: $note
+ secrets: $secrets
+ execute: $execute
+ visibility: $visibility
+ ) {
+ id
+ }
+}`, Variables: buildsVariables{
+ Manifest: string(manifest),
+ Tags: []string{repo.FullName, gitRef.RefType() + "/" + gitRef.ShortName(), pc.meta.ManifestPath},
+ Note: note,
+ Secrets: pc.meta.Secrets && trusted,
+ Execute: trusted,
+ Visibility: cmp.Or(pc.meta.Visibility, "PRIVATE"),
+ },
+ }, nil
+}
+
+// buildManifest adjusts the manifest to submit to the builds service
+// in case of an error the []byte might contain an error that can be displayed to the user
+func (pc sourcehutConvertor) buildManifest(repo *api.Repository, commitID, gitRef string) ([]byte, error) {
+ gitRepo, err := gitrepo.OpenRepository(pc.ctx, repo)
+ if err != nil {
+ msg := "could not open repository"
+ return []byte(msg), fmt.Errorf(msg+": %w", err)
+ }
+ defer gitRepo.Close()
+
+ commit, err := gitRepo.GetCommit(commitID)
+ if err != nil {
+ msg := fmt.Sprintf("could not get commit %q", commitID)
+ return []byte(msg), fmt.Errorf(msg+": %w", err)
+ }
+ entry, err := commit.GetTreeEntryByPath(pc.meta.ManifestPath)
+ if err != nil {
+ msg := fmt.Sprintf("could not open manifest %q", pc.meta.ManifestPath)
+ return []byte(msg), fmt.Errorf(msg+": %w", err)
+ }
+ r, err := entry.Blob().DataAsync()
+ if err != nil {
+ msg := fmt.Sprintf("could not read manifest %q", pc.meta.ManifestPath)
+ return []byte(msg), fmt.Errorf(msg+": %w", err)
+ }
+ defer r.Close()
+
+ // reference: https://man.sr.ht/builds.sr.ht/manifest.md
+ var manifest struct {
+ Sources []string `yaml:"sources"`
+ Environment map[string]string `yaml:"environment"`
+
+ Rest map[string]yaml.Node `yaml:",inline"`
+ }
+ if err := yaml.NewDecoder(r).Decode(&manifest); err != nil {
+ msg := fmt.Sprintf("could not decode manifest %q", pc.meta.ManifestPath)
+ return []byte(msg), fmt.Errorf(msg+": %w", err)
+ }
+
+ if manifest.Environment == nil {
+ manifest.Environment = make(map[string]string)
+ }
+ manifest.Environment["BUILD_SUBMITTER"] = "forgejo"
+ manifest.Environment["BUILD_SUBMITTER_URL"] = setting.AppURL
+ manifest.Environment["GIT_REF"] = gitRef
+
+ source := repo.CloneURL + "#" + commitID
+ found := false
+ for i, s := range manifest.Sources {
+ if s == repo.CloneURL {
+ manifest.Sources[i] = source
+ found = true
+ break
+ }
+ }
+ if !found {
+ manifest.Sources = append(manifest.Sources, source)
+ }
+
+ return yaml.Marshal(manifest)
+}
diff --git a/services/webhook/sourcehut/builds_test.go b/services/webhook/sourcehut/builds_test.go
new file mode 100644
index 0000000..1a37279
--- /dev/null
+++ b/services/webhook/sourcehut/builds_test.go
@@ -0,0 +1,386 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package sourcehut
+
+import (
+ "context"
+ "testing"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ "code.gitea.io/gitea/services/webhook/shared"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func gitInit(t testing.TB) {
+ if setting.Git.HomePath != "" {
+ return
+ }
+ t.Cleanup(test.MockVariableValue(&setting.Git.HomePath, t.TempDir()))
+ require.NoError(t, git.InitSimple(context.Background()))
+}
+
+func TestSourcehutBuildsPayload(t *testing.T) {
+ gitInit(t)
+ defer test.MockVariableValue(&setting.RepoRootPath, ".")()
+ defer test.MockVariableValue(&setting.AppURL, "https://example.forgejo.org/")()
+
+ repo := &api.Repository{
+ HTMLURL: "http://localhost:3000/testdata/repo",
+ Name: "repo",
+ FullName: "testdata/repo",
+ Owner: &api.User{
+ UserName: "testdata",
+ },
+ CloneURL: "http://localhost:3000/testdata/repo.git",
+ }
+
+ pc := sourcehutConvertor{
+ ctx: git.DefaultContext,
+ meta: BuildsMeta{
+ ManifestPath: "adjust me in each test",
+ Visibility: "UNLISTED",
+ Secrets: true,
+ },
+ }
+ t.Run("Create/branch", func(t *testing.T) {
+ p := &api.CreatePayload{
+ Sha: "58771003157b81abc6bf41df0c5db4147a3e3c83",
+ Ref: "refs/heads/test",
+ RefType: "branch",
+ Repo: repo,
+ }
+
+ pc.meta.ManifestPath = "simple.yml"
+ pl, err := pc.Create(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `sources:
+ - http://localhost:3000/testdata/repo.git#58771003157b81abc6bf41df0c5db4147a3e3c83
+environment:
+ BUILD_SUBMITTER: forgejo
+ BUILD_SUBMITTER_URL: https://example.forgejo.org/
+ GIT_REF: refs/heads/test
+image: alpine/edge
+tasks:
+ - say-hello: |
+ echo hello
+ - say-world: echo world
+`, pl.Variables.Manifest)
+ assert.Equal(t, buildsVariables{
+ Manifest: pl.Variables.Manifest, // the manifest correctness is checked above, for nicer diff on error
+ Note: "branch test created",
+ Tags: []string{"testdata/repo", "branch/test", "simple.yml"},
+ Secrets: true,
+ Execute: true,
+ Visibility: "UNLISTED",
+ }, pl.Variables)
+ })
+ t.Run("Create/tag", func(t *testing.T) {
+ p := &api.CreatePayload{
+ Sha: "58771003157b81abc6bf41df0c5db4147a3e3c83",
+ Ref: "refs/tags/v1.0.0",
+ RefType: "tag",
+ Repo: repo,
+ }
+
+ pc.meta.ManifestPath = "simple.yml"
+ pl, err := pc.Create(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `sources:
+ - http://localhost:3000/testdata/repo.git#58771003157b81abc6bf41df0c5db4147a3e3c83
+environment:
+ BUILD_SUBMITTER: forgejo
+ BUILD_SUBMITTER_URL: https://example.forgejo.org/
+ GIT_REF: refs/tags/v1.0.0
+image: alpine/edge
+tasks:
+ - say-hello: |
+ echo hello
+ - say-world: echo world
+`, pl.Variables.Manifest)
+ assert.Equal(t, buildsVariables{
+ Manifest: pl.Variables.Manifest, // the manifest correctness is checked above, for nicer diff on error
+ Note: "tag v1.0.0 created",
+ Tags: []string{"testdata/repo", "tag/v1.0.0", "simple.yml"},
+ Secrets: true,
+ Execute: true,
+ Visibility: "UNLISTED",
+ }, pl.Variables)
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ p := &api.DeletePayload{}
+
+ pl, err := pc.Delete(p)
+ require.Equal(t, shared.ErrPayloadTypeNotSupported, err)
+ require.Equal(t, graphqlPayload[buildsVariables]{}, pl)
+ })
+
+ t.Run("Fork", func(t *testing.T) {
+ p := &api.ForkPayload{}
+
+ pl, err := pc.Fork(p)
+ require.Equal(t, shared.ErrPayloadTypeNotSupported, err)
+ require.Equal(t, graphqlPayload[buildsVariables]{}, pl)
+ })
+
+ t.Run("Push/simple", func(t *testing.T) {
+ p := &api.PushPayload{
+ Ref: "refs/heads/main",
+ HeadCommit: &api.PayloadCommit{
+ ID: "58771003157b81abc6bf41df0c5db4147a3e3c83",
+ Message: "add simple",
+ },
+ Repo: repo,
+ }
+
+ pc.meta.ManifestPath = "simple.yml"
+ pl, err := pc.Push(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `sources:
+ - http://localhost:3000/testdata/repo.git#58771003157b81abc6bf41df0c5db4147a3e3c83
+environment:
+ BUILD_SUBMITTER: forgejo
+ BUILD_SUBMITTER_URL: https://example.forgejo.org/
+ GIT_REF: refs/heads/main
+image: alpine/edge
+tasks:
+ - say-hello: |
+ echo hello
+ - say-world: echo world
+`, pl.Variables.Manifest)
+ assert.Equal(t, buildsVariables{
+ Manifest: pl.Variables.Manifest, // the manifest correctness is checked above, for nicer diff on error
+ Note: "add simple",
+ Tags: []string{"testdata/repo", "branch/main", "simple.yml"},
+ Secrets: true,
+ Execute: true,
+ Visibility: "UNLISTED",
+ }, pl.Variables)
+ })
+ t.Run("Push/complex", func(t *testing.T) {
+ p := &api.PushPayload{
+ Ref: "refs/heads/main",
+ HeadCommit: &api.PayloadCommit{
+ ID: "b0404943256a1f5a50c3726f4378756b4c1e5704",
+ Message: "replace simple with complex",
+ },
+ Repo: repo,
+ }
+
+ pc.meta.ManifestPath = "complex.yaml"
+ pc.meta.Visibility = "PRIVATE"
+ pc.meta.Secrets = false
+ pl, err := pc.Push(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `sources:
+ - http://localhost:3000/testdata/repo.git#b0404943256a1f5a50c3726f4378756b4c1e5704
+environment:
+ BUILD_SUBMITTER: forgejo
+ BUILD_SUBMITTER_URL: https://example.forgejo.org/
+ GIT_REF: refs/heads/main
+ deploy: synapse@synapse-bt.org
+image: archlinux
+packages:
+ - nodejs
+ - npm
+ - rsync
+secrets:
+ - 7ebab768-e5e4-4c9d-ba57-ec41a72c5665
+tasks: []
+triggers:
+ - condition: failure
+ action: email
+ to: Jim Jimson <jim@example.org>
+ # report back the status
+ - condition: always
+ action: webhook
+ url: https://hook.example.org
+`, pl.Variables.Manifest)
+ assert.Equal(t, buildsVariables{
+ Manifest: pl.Variables.Manifest, // the manifest correctness is checked above, for nicer diff on error
+ Note: "replace simple with complex",
+ Tags: []string{"testdata/repo", "branch/main", "complex.yaml"},
+ Secrets: false,
+ Execute: true,
+ Visibility: "PRIVATE",
+ }, pl.Variables)
+ })
+
+ t.Run("Push/error", func(t *testing.T) {
+ p := &api.PushPayload{
+ Ref: "refs/heads/main",
+ HeadCommit: &api.PayloadCommit{
+ ID: "58771003157b81abc6bf41df0c5db4147a3e3c83",
+ Message: "add simple",
+ },
+ Repo: repo,
+ }
+
+ pc.meta.ManifestPath = "non-existing.yml"
+ pl, err := pc.Push(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, graphqlPayload[buildsVariables]{
+ Error: "testdata/repo:refs/heads/main could not open manifest \"non-existing.yml\"",
+ }, pl)
+ })
+
+ t.Run("Issue", func(t *testing.T) {
+ p := &api.IssuePayload{}
+
+ p.Action = api.HookIssueOpened
+ pl, err := pc.Issue(p)
+ require.Equal(t, shared.ErrPayloadTypeNotSupported, err)
+ require.Equal(t, graphqlPayload[buildsVariables]{}, pl)
+
+ p.Action = api.HookIssueClosed
+ pl, err = pc.Issue(p)
+ require.Equal(t, shared.ErrPayloadTypeNotSupported, err)
+ require.Equal(t, graphqlPayload[buildsVariables]{}, pl)
+ })
+
+ t.Run("IssueComment", func(t *testing.T) {
+ p := &api.IssueCommentPayload{}
+
+ pl, err := pc.IssueComment(p)
+ require.Equal(t, shared.ErrPayloadTypeNotSupported, err)
+ require.Equal(t, graphqlPayload[buildsVariables]{}, pl)
+ })
+
+ t.Run("PullRequest", func(t *testing.T) {
+ p := &api.PullRequestPayload{}
+
+ pl, err := pc.PullRequest(p)
+ require.Equal(t, shared.ErrPayloadTypeNotSupported, err)
+ require.Equal(t, graphqlPayload[buildsVariables]{}, pl)
+ })
+
+ t.Run("PullRequestComment", func(t *testing.T) {
+ p := &api.IssueCommentPayload{
+ IsPull: true,
+ }
+
+ pl, err := pc.IssueComment(p)
+ require.Equal(t, shared.ErrPayloadTypeNotSupported, err)
+ require.Equal(t, graphqlPayload[buildsVariables]{}, pl)
+ })
+
+ t.Run("Review", func(t *testing.T) {
+ p := &api.PullRequestPayload{}
+ p.Action = api.HookIssueReviewed
+
+ pl, err := pc.Review(p, webhook_module.HookEventPullRequestReviewApproved)
+ require.Equal(t, shared.ErrPayloadTypeNotSupported, err)
+ require.Equal(t, graphqlPayload[buildsVariables]{}, pl)
+ })
+
+ t.Run("Repository", func(t *testing.T) {
+ p := &api.RepositoryPayload{}
+
+ pl, err := pc.Repository(p)
+ require.Equal(t, shared.ErrPayloadTypeNotSupported, err)
+ require.Equal(t, graphqlPayload[buildsVariables]{}, pl)
+ })
+
+ t.Run("Package", func(t *testing.T) {
+ p := &api.PackagePayload{}
+
+ pl, err := pc.Package(p)
+ require.Equal(t, shared.ErrPayloadTypeNotSupported, err)
+ require.Equal(t, graphqlPayload[buildsVariables]{}, pl)
+ })
+
+ t.Run("Wiki", func(t *testing.T) {
+ p := &api.WikiPayload{}
+
+ p.Action = api.HookWikiCreated
+ pl, err := pc.Wiki(p)
+ require.Equal(t, shared.ErrPayloadTypeNotSupported, err)
+ require.Equal(t, graphqlPayload[buildsVariables]{}, pl)
+
+ p.Action = api.HookWikiEdited
+ pl, err = pc.Wiki(p)
+ require.Equal(t, shared.ErrPayloadTypeNotSupported, err)
+ require.Equal(t, graphqlPayload[buildsVariables]{}, pl)
+
+ p.Action = api.HookWikiDeleted
+ pl, err = pc.Wiki(p)
+ require.Equal(t, shared.ErrPayloadTypeNotSupported, err)
+ require.Equal(t, graphqlPayload[buildsVariables]{}, pl)
+ })
+
+ t.Run("Release", func(t *testing.T) {
+ p := &api.ReleasePayload{}
+
+ pl, err := pc.Release(p)
+ require.Equal(t, shared.ErrPayloadTypeNotSupported, err)
+ require.Equal(t, graphqlPayload[buildsVariables]{}, pl)
+ })
+}
+
+func TestSourcehutJSONPayload(t *testing.T) {
+ gitInit(t)
+ defer test.MockVariableValue(&setting.RepoRootPath, ".")()
+ defer test.MockVariableValue(&setting.AppURL, "https://example.forgejo.org/")()
+
+ repo := &api.Repository{
+ HTMLURL: "http://localhost:3000/testdata/repo",
+ Name: "repo",
+ FullName: "testdata/repo",
+ Owner: &api.User{
+ UserName: "testdata",
+ },
+ CloneURL: "http://localhost:3000/testdata/repo.git",
+ }
+
+ p := &api.PushPayload{
+ Ref: "refs/heads/main",
+ HeadCommit: &api.PayloadCommit{
+ ID: "58771003157b81abc6bf41df0c5db4147a3e3c83",
+ Message: "json test",
+ },
+ Repo: repo,
+ }
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ hook := &webhook_model.Webhook{
+ RepoID: 3,
+ IsActive: true,
+ Type: webhook_module.MATRIX,
+ URL: "https://sourcehut.example.com/api/jobs",
+ Meta: `{"manifest_path":"simple.yml"}`,
+ }
+ task := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventPush,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ req, reqBody, err := BuildsHandler{}.NewRequest(context.Background(), hook, task)
+ require.NoError(t, err)
+ require.NotNil(t, req)
+ require.NotNil(t, reqBody)
+
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, "/api/jobs", req.URL.Path)
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+ var body graphqlPayload[buildsVariables]
+ err = json.NewDecoder(req.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Equal(t, "json test", body.Variables.Note)
+}
diff --git a/services/webhook/sourcehut/testdata/repo.git/HEAD b/services/webhook/sourcehut/testdata/repo.git/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/services/webhook/sourcehut/testdata/repo.git/config b/services/webhook/sourcehut/testdata/repo.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/services/webhook/sourcehut/testdata/repo.git/description b/services/webhook/sourcehut/testdata/repo.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/services/webhook/sourcehut/testdata/repo.git/info/exclude b/services/webhook/sourcehut/testdata/repo.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/3c/3d4b799b3933ba687b263eeef2034300a5315e b/services/webhook/sourcehut/testdata/repo.git/objects/3c/3d4b799b3933ba687b263eeef2034300a5315e
new file mode 100644
index 0000000..f03b45d
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/3c/3d4b799b3933ba687b263eeef2034300a5315e
Binary files differ
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/58/771003157b81abc6bf41df0c5db4147a3e3c83 b/services/webhook/sourcehut/testdata/repo.git/objects/58/771003157b81abc6bf41df0c5db4147a3e3c83
new file mode 100644
index 0000000..e9ff0d0
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/58/771003157b81abc6bf41df0c5db4147a3e3c83
@@ -0,0 +1,2 @@
+x=0D=+nBXhVk%?_Pm̔b C̠D{
+;F&qm<5e8|[/ O5 GYK)\ iOKJ3 PƝjU>VX܃絈7\p; \ No newline at end of file
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/69/b217caa89166a02b8cd368b64fb83a44720e14 b/services/webhook/sourcehut/testdata/repo.git/objects/69/b217caa89166a02b8cd368b64fb83a44720e14
new file mode 100644
index 0000000..1aed811
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/69/b217caa89166a02b8cd368b64fb83a44720e14
@@ -0,0 +1 @@
+x=n {)^Z ,EUN}&TAy6aT=ŵĢ5O \m\uFTG׈F;NQ^[֓aQokiW~+ppui ha3J?:7([VK|͙TI7uİӑ>sP =C}ˢO \ No newline at end of file
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/99/fb389b232e5497f0dcdb1c1065eac1d10d3794 b/services/webhook/sourcehut/testdata/repo.git/objects/99/fb389b232e5497f0dcdb1c1065eac1d10d3794
new file mode 100644
index 0000000..43dd885
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/99/fb389b232e5497f0dcdb1c1065eac1d10d3794
Binary files differ
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/9e/4b777f81b316a1c75a0797b33add68ee49b0d0 b/services/webhook/sourcehut/testdata/repo.git/objects/9e/4b777f81b316a1c75a0797b33add68ee49b0d0
new file mode 100644
index 0000000..081cfcd
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/9e/4b777f81b316a1c75a0797b33add68ee49b0d0
Binary files differ
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/a5/4082fdb8e55055382725f10a81bb4dc2b13029 b/services/webhook/sourcehut/testdata/repo.git/objects/a5/4082fdb8e55055382725f10a81bb4dc2b13029
new file mode 100644
index 0000000..071f79e
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/a5/4082fdb8e55055382725f10a81bb4dc2b13029
@@ -0,0 +1,4 @@
+xUn0 wk
+l4z0 %fm~@Dc<(ŝ% m]NjDR
+A閌9Xxu{;Nȅ4(Gy:QO?/9 lh|0cΌl8*$?dԻ**>7ȖXomUJItmKqrh8>)ҺڋF,77,8 {:0zZfya)
+5 ʴ狉7ΑLܯ)z yivoQ78J}臤 \ No newline at end of file
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/aa/3905af404394f576f88f00e7f0919b4b97453f b/services/webhook/sourcehut/testdata/repo.git/objects/aa/3905af404394f576f88f00e7f0919b4b97453f
new file mode 100644
index 0000000..cc96171
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/aa/3905af404394f576f88f00e7f0919b4b97453f
Binary files differ
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/b0/404943256a1f5a50c3726f4378756b4c1e5704 b/services/webhook/sourcehut/testdata/repo.git/objects/b0/404943256a1f5a50c3726f4378756b4c1e5704
new file mode 100644
index 0000000..a2cff63
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/b0/404943256a1f5a50c3726f4378756b4c1e5704
Binary files differ
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/d2/e0862c8b8097ba4bdd72946c20479751d307a0 b/services/webhook/sourcehut/testdata/repo.git/objects/d2/e0862c8b8097ba4bdd72946c20479751d307a0
new file mode 100644
index 0000000..f57ab8a
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/d2/e0862c8b8097ba4bdd72946c20479751d307a0
@@ -0,0 +1,4 @@
+xENIn0 YD#ȁ ۍ,
+"$\f9ئ9~,+L-㒶ɀ=og#&OUo߷jU!,꺮DGP
+e>L狡t[
+#?C~ z2!,qCtQZ<.@78\I \ No newline at end of file
diff --git a/services/webhook/sourcehut/testdata/repo.git/refs/heads/main b/services/webhook/sourcehut/testdata/repo.git/refs/heads/main
new file mode 100644
index 0000000..a7ab419
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/refs/heads/main
@@ -0,0 +1 @@
+b0404943256a1f5a50c3726f4378756b4c1e5704
diff --git a/services/webhook/telegram.go b/services/webhook/telegram.go
new file mode 100644
index 0000000..bacfa64
--- /dev/null
+++ b/services/webhook/telegram.go
@@ -0,0 +1,228 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "net/http"
+ "net/url"
+ "strings"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/markup"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/webhook/shared"
+)
+
+type telegramHandler struct{}
+
+func (telegramHandler) Type() webhook_module.HookType { return webhook_module.TELEGRAM }
+func (telegramHandler) Icon(size int) template.HTML { return shared.ImgIcon("telegram.png", size) }
+
+func (telegramHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
+ var form struct {
+ forms.WebhookCoreForm
+ BotToken string `binding:"Required"`
+ ChatID string `binding:"Required"`
+ ThreadID string
+ }
+ bind(&form)
+
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage?chat_id=%s&message_thread_id=%s", url.PathEscape(form.BotToken), url.QueryEscape(form.ChatID), url.QueryEscape(form.ThreadID)),
+ ContentType: webhook_model.ContentTypeJSON,
+ Secret: "",
+ HTTPMethod: http.MethodPost,
+ Metadata: &TelegramMeta{
+ BotToken: form.BotToken,
+ ChatID: form.ChatID,
+ ThreadID: form.ThreadID,
+ },
+ }
+}
+
+type (
+ // TelegramPayload represents
+ TelegramPayload struct {
+ Message string `json:"text"`
+ ParseMode string `json:"parse_mode"`
+ DisableWebPreview bool `json:"disable_web_page_preview"`
+ }
+
+ // TelegramMeta contains the telegram metadata
+ TelegramMeta struct {
+ BotToken string `json:"bot_token"`
+ ChatID string `json:"chat_id"`
+ ThreadID string `json:"thread_id"`
+ }
+)
+
+// Metadata returns telegram metadata
+func (telegramHandler) Metadata(w *webhook_model.Webhook) any {
+ s := &TelegramMeta{}
+ if err := json.Unmarshal([]byte(w.Meta), s); err != nil {
+ log.Error("telegramHandler.Metadata(%d): %v", w.ID, err)
+ }
+ return s
+}
+
+// Create implements PayloadConvertor Create method
+func (t telegramConvertor) Create(p *api.CreatePayload) (TelegramPayload, error) {
+ // created tag/branch
+ refName := git.RefName(p.Ref).ShortName()
+ title := fmt.Sprintf(`[<a href="%s">%s</a>] %s <a href="%s">%s</a> created`, p.Repo.HTMLURL, p.Repo.FullName, p.RefType,
+ p.Repo.HTMLURL+"/src/"+refName, refName)
+
+ return createTelegramPayload(title), nil
+}
+
+// Delete implements PayloadConvertor Delete method
+func (t telegramConvertor) Delete(p *api.DeletePayload) (TelegramPayload, error) {
+ // created tag/branch
+ refName := git.RefName(p.Ref).ShortName()
+ title := fmt.Sprintf(`[<a href="%s">%s</a>] %s <a href="%s">%s</a> deleted`, p.Repo.HTMLURL, p.Repo.FullName, p.RefType,
+ p.Repo.HTMLURL+"/src/"+refName, refName)
+
+ return createTelegramPayload(title), nil
+}
+
+// Fork implements PayloadConvertor Fork method
+func (t telegramConvertor) Fork(p *api.ForkPayload) (TelegramPayload, error) {
+ title := fmt.Sprintf(`%s is forked to <a href="%s">%s</a>`, p.Forkee.FullName, p.Repo.HTMLURL, p.Repo.FullName)
+
+ return createTelegramPayload(title), nil
+}
+
+// Push implements PayloadConvertor Push method
+func (t telegramConvertor) Push(p *api.PushPayload) (TelegramPayload, error) {
+ var (
+ branchName = git.RefName(p.Ref).ShortName()
+ commitDesc string
+ )
+
+ var titleLink string
+ if p.TotalCommits == 1 {
+ commitDesc = "1 new commit"
+ titleLink = p.Commits[0].URL
+ } else {
+ commitDesc = fmt.Sprintf("%d new commits", p.TotalCommits)
+ titleLink = p.CompareURL
+ }
+ if titleLink == "" {
+ titleLink = p.Repo.HTMLURL + "/src/" + branchName
+ }
+ title := fmt.Sprintf(`[<a href="%s">%s</a>:<a href="%s">%s</a>] %s`, p.Repo.HTMLURL, p.Repo.FullName, titleLink, branchName, commitDesc)
+
+ var text string
+ // for each commit, generate attachment text
+ for i, commit := range p.Commits {
+ var authorName string
+ if commit.Author != nil {
+ authorName = " - " + commit.Author.Name
+ }
+ text += fmt.Sprintf(`[<a href="%s">%s</a>] %s`, commit.URL, commit.ID[:7],
+ strings.TrimRight(commit.Message, "\r\n")) + authorName
+ // add linebreak to each commit but the last
+ if i < len(p.Commits)-1 {
+ text += "\n"
+ }
+ }
+
+ return createTelegramPayload(title + "\n" + text), nil
+}
+
+// Issue implements PayloadConvertor Issue method
+func (t telegramConvertor) Issue(p *api.IssuePayload) (TelegramPayload, error) {
+ text, _, attachmentText, _ := getIssuesPayloadInfo(p, htmlLinkFormatter, true)
+
+ return createTelegramPayload(text + "\n\n" + attachmentText), nil
+}
+
+// IssueComment implements PayloadConvertor IssueComment method
+func (t telegramConvertor) IssueComment(p *api.IssueCommentPayload) (TelegramPayload, error) {
+ text, _, _ := getIssueCommentPayloadInfo(p, htmlLinkFormatter, true)
+
+ return createTelegramPayload(text + "\n" + p.Comment.Body), nil
+}
+
+// PullRequest implements PayloadConvertor PullRequest method
+func (t telegramConvertor) PullRequest(p *api.PullRequestPayload) (TelegramPayload, error) {
+ text, _, attachmentText, _ := getPullRequestPayloadInfo(p, htmlLinkFormatter, true)
+
+ return createTelegramPayload(text + "\n" + attachmentText), nil
+}
+
+// Review implements PayloadConvertor Review method
+func (t telegramConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (TelegramPayload, error) {
+ var text, attachmentText string
+ if p.Action == api.HookIssueReviewed {
+ action, err := parseHookPullRequestEventType(event)
+ if err != nil {
+ return TelegramPayload{}, err
+ }
+
+ text = fmt.Sprintf("[%s] Pull request review %s: #%d %s", p.Repository.FullName, action, p.Index, p.PullRequest.Title)
+ attachmentText = p.Review.Content
+ }
+
+ return createTelegramPayload(text + "\n" + attachmentText), nil
+}
+
+// Repository implements PayloadConvertor Repository method
+func (t telegramConvertor) Repository(p *api.RepositoryPayload) (TelegramPayload, error) {
+ var title string
+ switch p.Action {
+ case api.HookRepoCreated:
+ title = fmt.Sprintf(`[<a href="%s">%s</a>] Repository created`, p.Repository.HTMLURL, p.Repository.FullName)
+ return createTelegramPayload(title), nil
+ case api.HookRepoDeleted:
+ title = fmt.Sprintf("[%s] Repository deleted", p.Repository.FullName)
+ return createTelegramPayload(title), nil
+ }
+ return TelegramPayload{}, nil
+}
+
+// Wiki implements PayloadConvertor Wiki method
+func (t telegramConvertor) Wiki(p *api.WikiPayload) (TelegramPayload, error) {
+ text, _, _ := getWikiPayloadInfo(p, htmlLinkFormatter, true)
+
+ return createTelegramPayload(text), nil
+}
+
+// Release implements PayloadConvertor Release method
+func (t telegramConvertor) Release(p *api.ReleasePayload) (TelegramPayload, error) {
+ text, _ := getReleasePayloadInfo(p, htmlLinkFormatter, true)
+
+ return createTelegramPayload(text), nil
+}
+
+func (t telegramConvertor) Package(p *api.PackagePayload) (TelegramPayload, error) {
+ text, _ := getPackagePayloadInfo(p, htmlLinkFormatter, true)
+
+ return createTelegramPayload(text), nil
+}
+
+func createTelegramPayload(message string) TelegramPayload {
+ return TelegramPayload{
+ Message: markup.Sanitize(strings.TrimSpace(message)),
+ ParseMode: "HTML",
+ DisableWebPreview: true,
+ }
+}
+
+type telegramConvertor struct{}
+
+var _ shared.PayloadConvertor[TelegramPayload] = telegramConvertor{}
+
+func (telegramHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
+ return shared.NewJSONRequest(telegramConvertor{}, w, t, true)
+}
diff --git a/services/webhook/telegram_test.go b/services/webhook/telegram_test.go
new file mode 100644
index 0000000..0e27535
--- /dev/null
+++ b/services/webhook/telegram_test.go
@@ -0,0 +1,212 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "testing"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/json"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTelegramPayload(t *testing.T) {
+ tc := telegramConvertor{}
+
+ t.Run("Correct webhook params", func(t *testing.T) {
+ p := createTelegramPayload("testMsg ")
+
+ assert.Equal(t, "HTML", p.ParseMode)
+ assert.True(t, p.DisableWebPreview)
+ assert.Equal(t, "testMsg", p.Message)
+ })
+
+ t.Run("Create", func(t *testing.T) {
+ p := createTestPayload()
+
+ pl, err := tc.Create(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] branch <a href="http://localhost:3000/test/repo/src/test" rel="nofollow">test</a> created`, pl.Message)
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ p := deleteTestPayload()
+
+ pl, err := tc.Delete(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] branch <a href="http://localhost:3000/test/repo/src/test" rel="nofollow">test</a> deleted`, pl.Message)
+ })
+
+ t.Run("Fork", func(t *testing.T) {
+ p := forkTestPayload()
+
+ pl, err := tc.Fork(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `test/repo2 is forked to <a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>`, pl.Message)
+ })
+
+ t.Run("Push", func(t *testing.T) {
+ p := pushTestPayload()
+
+ pl, err := tc.Push(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>:<a href="http://localhost:3000/test/repo/src/test" rel="nofollow">test</a>] 2 new commits
+[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1
+[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1`, pl.Message)
+ })
+
+ t.Run("Issue", func(t *testing.T) {
+ p := issueTestPayload()
+
+ p.Action = api.HookIssueOpened
+ pl, err := tc.Issue(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Issue opened: <a href="http://localhost:3000/test/repo/issues/2" rel="nofollow">#2 crash</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>
+
+issue body`, pl.Message)
+
+ p.Action = api.HookIssueClosed
+ pl, err = tc.Issue(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Issue closed: <a href="http://localhost:3000/test/repo/issues/2" rel="nofollow">#2 crash</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.Message)
+ })
+
+ t.Run("IssueComment", func(t *testing.T) {
+ p := issueCommentTestPayload()
+
+ pl, err := tc.IssueComment(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] New comment on issue <a href="http://localhost:3000/test/repo/issues/2" rel="nofollow">#2 crash</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>
+more info needed`, pl.Message)
+ })
+
+ t.Run("PullRequest", func(t *testing.T) {
+ p := pullRequestTestPayload()
+
+ pl, err := tc.PullRequest(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Pull request opened: <a href="http://localhost:3000/test/repo/pulls/12" rel="nofollow">#12 Fix bug</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>
+fixes bug #2`, pl.Message)
+ })
+
+ t.Run("PullRequestComment", func(t *testing.T) {
+ p := pullRequestCommentTestPayload()
+
+ pl, err := tc.IssueComment(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] New comment on pull request <a href="http://localhost:3000/test/repo/pulls/12" rel="nofollow">#12 Fix bug</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>
+changes requested`, pl.Message)
+ })
+
+ t.Run("Review", func(t *testing.T) {
+ p := pullRequestTestPayload()
+ p.Action = api.HookIssueReviewed
+
+ pl, err := tc.Review(p, webhook_module.HookEventPullRequestReviewApproved)
+ require.NoError(t, err)
+
+ assert.Equal(t, `[test/repo] Pull request review approved: #12 Fix bug
+good job`, pl.Message)
+ })
+
+ t.Run("Repository", func(t *testing.T) {
+ p := repositoryTestPayload()
+
+ pl, err := tc.Repository(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Repository created`, pl.Message)
+ })
+
+ t.Run("Package", func(t *testing.T) {
+ p := packageTestPayload()
+
+ pl, err := tc.Package(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `Package created: <a href="http://localhost:3000/user1/-/packages/container/GiteaContainer/latest" rel="nofollow">GiteaContainer:latest</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.Message)
+ })
+
+ t.Run("Wiki", func(t *testing.T) {
+ p := wikiTestPayload()
+
+ p.Action = api.HookWikiCreated
+ pl, err := tc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] New wiki page &#39;<a href="http://localhost:3000/test/repo/wiki/index" rel="nofollow">index</a>&#39; (Wiki change comment) by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.Message)
+
+ p.Action = api.HookWikiEdited
+ pl, err = tc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Wiki page &#39;<a href="http://localhost:3000/test/repo/wiki/index" rel="nofollow">index</a>&#39; edited (Wiki change comment) by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.Message)
+
+ p.Action = api.HookWikiDeleted
+ pl, err = tc.Wiki(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Wiki page &#39;<a href="http://localhost:3000/test/repo/wiki/index" rel="nofollow">index</a>&#39; deleted by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.Message)
+ })
+
+ t.Run("Release", func(t *testing.T) {
+ p := pullReleaseTestPayload()
+
+ pl, err := tc.Release(p)
+ require.NoError(t, err)
+
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Release created: <a href="http://localhost:3000/test/repo/releases/tag/v1.0" rel="nofollow">v1.0</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>`, pl.Message)
+ })
+}
+
+func TestTelegramJSONPayload(t *testing.T) {
+ p := pushTestPayload()
+ data, err := p.JSONPayload()
+ require.NoError(t, err)
+
+ hook := &webhook_model.Webhook{
+ RepoID: 3,
+ IsActive: true,
+ Type: webhook_module.TELEGRAM,
+ URL: "https://telegram.example.com/",
+ Meta: ``,
+ HTTPMethod: "POST",
+ }
+ task := &webhook_model.HookTask{
+ HookID: hook.ID,
+ EventType: webhook_module.HookEventPush,
+ PayloadContent: string(data),
+ PayloadVersion: 2,
+ }
+
+ req, reqBody, err := telegramHandler{}.NewRequest(context.Background(), hook, task)
+ require.NotNil(t, req)
+ require.NotNil(t, reqBody)
+ require.NoError(t, err)
+
+ assert.Equal(t, "POST", req.Method)
+ assert.Equal(t, "https://telegram.example.com/", req.URL.String())
+ assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256"))
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+ var body TelegramPayload
+ err = json.NewDecoder(req.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>:<a href="http://localhost:3000/test/repo/src/test" rel="nofollow">test</a>] 2 new commits
+[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1
+[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1`, body.Message)
+}
diff --git a/services/webhook/webhook.go b/services/webhook/webhook.go
new file mode 100644
index 0000000..1366ea8
--- /dev/null
+++ b/services/webhook/webhook.go
@@ -0,0 +1,270 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "html/template"
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/queue"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/webhook/sourcehut"
+
+ "github.com/gobwas/glob"
+)
+
+type Handler interface {
+ Type() webhook_module.HookType
+ Metadata(*webhook_model.Webhook) any
+ // UnmarshalForm provides a function to bind the request to the form.
+ // If form implements the [binding.Validator] interface, the Validate method will be called
+ UnmarshalForm(bind func(form any)) forms.WebhookForm
+ NewRequest(context.Context, *webhook_model.Webhook, *webhook_model.HookTask) (req *http.Request, body []byte, err error)
+ Icon(size int) template.HTML
+}
+
+var webhookHandlers = []Handler{
+ defaultHandler{true},
+ defaultHandler{false},
+ gogsHandler{},
+
+ slackHandler{},
+ discordHandler{},
+ dingtalkHandler{},
+ telegramHandler{},
+ msteamsHandler{},
+ feishuHandler{},
+ matrixHandler{},
+ wechatworkHandler{},
+ packagistHandler{},
+ sourcehut.BuildsHandler{},
+}
+
+// GetWebhookHandler return the handler for a given webhook type (nil if not found)
+func GetWebhookHandler(name webhook_module.HookType) Handler {
+ for _, h := range webhookHandlers {
+ if h.Type() == name {
+ return h
+ }
+ }
+ return nil
+}
+
+// List provides a list of the supported webhooks
+func List() []Handler {
+ return webhookHandlers
+}
+
+// IsValidHookTaskType returns true if a webhook registered
+func IsValidHookTaskType(name string) bool {
+ return GetWebhookHandler(name) != nil
+}
+
+// hookQueue is a global queue of web hooks
+var hookQueue *queue.WorkerPoolQueue[int64]
+
+// getPayloadBranch returns branch for hook event, if applicable.
+func getPayloadBranch(p api.Payloader) string {
+ var ref string
+ switch pp := p.(type) {
+ case *api.CreatePayload:
+ ref = pp.Ref
+ case *api.DeletePayload:
+ ref = pp.Ref
+ case *api.PushPayload:
+ ref = pp.Ref
+ }
+ if strings.HasPrefix(ref, git.BranchPrefix) {
+ return ref[len(git.BranchPrefix):]
+ }
+ return ""
+}
+
+// EventSource represents the source of a webhook action. Repository and/or Owner must be set.
+type EventSource struct {
+ Repository *repo_model.Repository
+ Owner *user_model.User
+}
+
+// handle delivers hook tasks
+func handler(items ...int64) []int64 {
+ ctx := graceful.GetManager().HammerContext()
+
+ for _, taskID := range items {
+ task, err := webhook_model.GetHookTaskByID(ctx, taskID)
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ log.Warn("GetHookTaskByID[%d] warn: %v", taskID, err)
+ } else {
+ log.Error("GetHookTaskByID[%d] failed: %v", taskID, err)
+ }
+ continue
+ }
+
+ if task.IsDelivered {
+ // Already delivered in the meantime
+ log.Trace("Task[%d] has already been delivered", task.ID)
+ continue
+ }
+
+ if err := Deliver(ctx, task); err != nil {
+ log.Error("Unable to deliver webhook task[%d]: %v", task.ID, err)
+ }
+ }
+
+ return nil
+}
+
+func enqueueHookTask(taskID int64) error {
+ err := hookQueue.Push(taskID)
+ if err != nil && err != queue.ErrAlreadyInQueue {
+ return err
+ }
+ return nil
+}
+
+func checkBranch(w *webhook_model.Webhook, branch string) bool {
+ if w.BranchFilter == "" || w.BranchFilter == "*" {
+ return true
+ }
+
+ g, err := glob.Compile(w.BranchFilter)
+ if err != nil {
+ // should not really happen as BranchFilter is validated
+ log.Error("CheckBranch failed: %s", err)
+ return false
+ }
+
+ return g.Match(branch)
+}
+
+// PrepareWebhook creates a hook task and enqueues it for processing.
+// The payload is saved as-is. The adjustments depending on the webhook type happen
+// right before delivery, in the [Deliver] method.
+func PrepareWebhook(ctx context.Context, w *webhook_model.Webhook, event webhook_module.HookEventType, p api.Payloader) error {
+ // Skip sending if webhooks are disabled.
+ if setting.DisableWebhooks {
+ return nil
+ }
+
+ for _, e := range w.EventCheckers() {
+ if event == e.Type {
+ if !e.Has() {
+ return nil
+ }
+
+ break
+ }
+ }
+
+ // Avoid sending "0 new commits" to non-integration relevant webhooks (e.g. slack, discord, etc.).
+ // Integration webhooks (e.g. drone) still receive the required data.
+ if pushEvent, ok := p.(*api.PushPayload); ok &&
+ w.Type != webhook_module.FORGEJO && w.Type != webhook_module.GITEA && w.Type != webhook_module.GOGS &&
+ len(pushEvent.Commits) == 0 {
+ return nil
+ }
+
+ // If payload has no associated branch (e.g. it's a new tag, issue, etc.),
+ // branch filter has no effect.
+ if branch := getPayloadBranch(p); branch != "" {
+ if !checkBranch(w, branch) {
+ log.Info("Branch %q doesn't match branch filter %q, skipping", branch, w.BranchFilter)
+ return nil
+ }
+ }
+
+ payload, err := p.JSONPayload()
+ if err != nil {
+ return fmt.Errorf("JSONPayload for %s: %w", event, err)
+ }
+
+ task, err := webhook_model.CreateHookTask(ctx, &webhook_model.HookTask{
+ HookID: w.ID,
+ PayloadContent: string(payload),
+ EventType: event,
+ PayloadVersion: 2,
+ })
+ if err != nil {
+ return fmt.Errorf("CreateHookTask for %s: %w", event, err)
+ }
+
+ return enqueueHookTask(task.ID)
+}
+
+// PrepareWebhooks adds new webhooks to task queue for given payload.
+func PrepareWebhooks(ctx context.Context, source EventSource, event webhook_module.HookEventType, p api.Payloader) error {
+ owner := source.Owner
+
+ var ws []*webhook_model.Webhook
+
+ if source.Repository != nil {
+ repoHooks, err := db.Find[webhook_model.Webhook](ctx, webhook_model.ListWebhookOptions{
+ RepoID: source.Repository.ID,
+ IsActive: optional.Some(true),
+ })
+ if err != nil {
+ return fmt.Errorf("ListWebhooksByOpts: %w", err)
+ }
+ ws = append(ws, repoHooks...)
+
+ owner = source.Repository.MustOwner(ctx)
+ }
+
+ // append additional webhooks of a user or organization
+ if owner != nil {
+ ownerHooks, err := db.Find[webhook_model.Webhook](ctx, webhook_model.ListWebhookOptions{
+ OwnerID: owner.ID,
+ IsActive: optional.Some(true),
+ })
+ if err != nil {
+ return fmt.Errorf("ListWebhooksByOpts: %w", err)
+ }
+ ws = append(ws, ownerHooks...)
+ }
+
+ // Add any admin-defined system webhooks
+ systemHooks, err := webhook_model.GetSystemWebhooks(ctx, true)
+ if err != nil {
+ return fmt.Errorf("GetSystemWebhooks: %w", err)
+ }
+ ws = append(ws, systemHooks...)
+
+ if len(ws) == 0 {
+ return nil
+ }
+
+ for _, w := range ws {
+ if err := PrepareWebhook(ctx, w, event, p); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// ReplayHookTask replays a webhook task
+func ReplayHookTask(ctx context.Context, w *webhook_model.Webhook, uuid string) error {
+ task, err := webhook_model.ReplayHookTask(ctx, w.ID, uuid)
+ if err != nil {
+ return err
+ }
+
+ return enqueueHookTask(task.ID)
+}
diff --git a/services/webhook/webhook_test.go b/services/webhook/webhook_test.go
new file mode 100644
index 0000000..816940a
--- /dev/null
+++ b/services/webhook/webhook_test.go
@@ -0,0 +1,100 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "fmt"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func activateWebhook(t *testing.T, hookID int64) {
+ t.Helper()
+ updated, err := db.GetEngine(db.DefaultContext).ID(hookID).Cols("is_active").Update(webhook_model.Webhook{IsActive: true})
+ assert.Equal(t, int64(1), updated)
+ require.NoError(t, err)
+}
+
+func TestPrepareWebhooks(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ activateWebhook(t, 1)
+
+ hookTasks := []*webhook_model.HookTask{
+ {HookID: 1, EventType: webhook_module.HookEventPush},
+ }
+ for _, hookTask := range hookTasks {
+ unittest.AssertNotExistsBean(t, hookTask)
+ }
+ require.NoError(t, PrepareWebhooks(db.DefaultContext, EventSource{Repository: repo}, webhook_module.HookEventPush, &api.PushPayload{Commits: []*api.PayloadCommit{{}}}))
+ for _, hookTask := range hookTasks {
+ unittest.AssertExistsAndLoadBean(t, hookTask)
+ }
+}
+
+func eventType(p api.Payloader) webhook_module.HookEventType {
+ switch p.(type) {
+ case *api.CreatePayload:
+ return webhook_module.HookEventCreate
+ case *api.DeletePayload:
+ return webhook_module.HookEventDelete
+ case *api.PushPayload:
+ return webhook_module.HookEventPush
+ }
+ panic(fmt.Sprintf("no event type for payload %T", p))
+}
+
+func TestPrepareWebhooksBranchFilterMatch(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ // branch_filter: {master,feature*}
+ w := unittest.AssertExistsAndLoadBean(t, &webhook_model.Webhook{ID: 4})
+ activateWebhook(t, w.ID)
+
+ for _, p := range []api.Payloader{
+ &api.PushPayload{Ref: "refs/heads/feature/7791"},
+ &api.CreatePayload{Ref: "refs/heads/feature/7791"}, // branch creation
+ &api.DeletePayload{Ref: "refs/heads/feature/7791"}, // branch deletion
+ } {
+ t.Run(fmt.Sprintf("%T", p), func(t *testing.T) {
+ db.DeleteBeans(db.DefaultContext, webhook_model.HookTask{HookID: w.ID})
+ typ := eventType(p)
+ require.NoError(t, PrepareWebhook(db.DefaultContext, w, typ, p))
+ unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{
+ HookID: w.ID,
+ EventType: typ,
+ })
+ })
+ }
+}
+
+func TestPrepareWebhooksBranchFilterNoMatch(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ // branch_filter: {master,feature*}
+ w := unittest.AssertExistsAndLoadBean(t, &webhook_model.Webhook{ID: 4})
+ activateWebhook(t, w.ID)
+
+ for _, p := range []api.Payloader{
+ &api.PushPayload{Ref: "refs/heads/fix_weird_bug"},
+ &api.CreatePayload{Ref: "refs/heads/fix_weird_bug"}, // branch creation
+ &api.DeletePayload{Ref: "refs/heads/fix_weird_bug"}, // branch deletion
+ } {
+ t.Run(fmt.Sprintf("%T", p), func(t *testing.T) {
+ db.DeleteBeans(db.DefaultContext, webhook_model.HookTask{HookID: w.ID})
+ require.NoError(t, PrepareWebhook(db.DefaultContext, w, eventType(p), p))
+ unittest.AssertNotExistsBean(t, &webhook_model.HookTask{HookID: w.ID})
+ })
+ }
+}
diff --git a/services/webhook/wechatwork.go b/services/webhook/wechatwork.go
new file mode 100644
index 0000000..87f8bb8
--- /dev/null
+++ b/services/webhook/wechatwork.go
@@ -0,0 +1,210 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "net/http"
+ "strings"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/git"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/webhook/shared"
+)
+
+type wechatworkHandler struct{}
+
+func (wechatworkHandler) Type() webhook_module.HookType { return webhook_module.WECHATWORK }
+func (wechatworkHandler) Metadata(*webhook_model.Webhook) any { return nil }
+
+func (wechatworkHandler) Icon(size int) template.HTML {
+ return shared.ImgIcon("wechatwork.png", size)
+}
+
+func (wechatworkHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
+ var form struct {
+ forms.WebhookCoreForm
+ PayloadURL string `binding:"Required;ValidUrl"`
+ }
+ bind(&form)
+
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: form.PayloadURL,
+ ContentType: webhook_model.ContentTypeJSON,
+ Secret: "",
+ HTTPMethod: http.MethodPost,
+ Metadata: nil,
+ }
+}
+
+type (
+ // WechatworkPayload represents
+ WechatworkPayload struct {
+ Msgtype string `json:"msgtype"`
+ Text struct {
+ Content string `json:"content"`
+ MentionedList []string `json:"mentioned_list"`
+ MentionedMobileList []string `json:"mentioned_mobile_list"`
+ } `json:"text"`
+ Markdown struct {
+ Content string `json:"content"`
+ } `json:"markdown"`
+ }
+)
+
+func newWechatworkMarkdownPayload(title string) WechatworkPayload {
+ return WechatworkPayload{
+ Msgtype: "markdown",
+ Markdown: struct {
+ Content string `json:"content"`
+ }{
+ Content: title,
+ },
+ }
+}
+
+// Create implements PayloadConvertor Create method
+func (wc wechatworkConvertor) Create(p *api.CreatePayload) (WechatworkPayload, error) {
+ // created tag/branch
+ refName := git.RefName(p.Ref).ShortName()
+ title := fmt.Sprintf("[%s] %s %s created", p.Repo.FullName, p.RefType, refName)
+
+ return newWechatworkMarkdownPayload(title), nil
+}
+
+// Delete implements PayloadConvertor Delete method
+func (wc wechatworkConvertor) Delete(p *api.DeletePayload) (WechatworkPayload, error) {
+ // created tag/branch
+ refName := git.RefName(p.Ref).ShortName()
+ title := fmt.Sprintf("[%s] %s %s deleted", p.Repo.FullName, p.RefType, refName)
+
+ return newWechatworkMarkdownPayload(title), nil
+}
+
+// Fork implements PayloadConvertor Fork method
+func (wc wechatworkConvertor) Fork(p *api.ForkPayload) (WechatworkPayload, error) {
+ title := fmt.Sprintf("%s is forked to %s", p.Forkee.FullName, p.Repo.FullName)
+
+ return newWechatworkMarkdownPayload(title), nil
+}
+
+// Push implements PayloadConvertor Push method
+func (wc wechatworkConvertor) Push(p *api.PushPayload) (WechatworkPayload, error) {
+ var (
+ branchName = git.RefName(p.Ref).ShortName()
+ commitDesc string
+ )
+
+ title := fmt.Sprintf("# %s:%s <font color=\"warning\"> %s </font>", p.Repo.FullName, branchName, commitDesc)
+
+ var text string
+ // for each commit, generate attachment text
+ for i, commit := range p.Commits {
+ var authorName string
+ if commit.Author != nil {
+ authorName = "Author: " + commit.Author.Name
+ }
+
+ message := strings.ReplaceAll(commit.Message, "\n\n", "\r\n")
+ text += fmt.Sprintf(" > [%s](%s) \r\n ><font color=\"info\">%s</font> \n ><font color=\"warning\">%s</font>", commit.ID[:7], commit.URL,
+ message, authorName)
+
+ // add linebreak to each commit but the last
+ if i < len(p.Commits)-1 {
+ text += "\n"
+ }
+ }
+ return newWechatworkMarkdownPayload(title + "\r\n\r\n" + text), nil
+}
+
+// Issue implements PayloadConvertor Issue method
+func (wc wechatworkConvertor) Issue(p *api.IssuePayload) (WechatworkPayload, error) {
+ text, issueTitle, attachmentText, _ := getIssuesPayloadInfo(p, noneLinkFormatter, true)
+ var content string
+ content += fmt.Sprintf(" ><font color=\"info\">%s</font>\n >%s \n ><font color=\"warning\"> %s</font> \n [%s](%s)", text, attachmentText, issueTitle, p.Issue.HTMLURL, p.Issue.HTMLURL)
+
+ return newWechatworkMarkdownPayload(content), nil
+}
+
+// IssueComment implements PayloadConvertor IssueComment method
+func (wc wechatworkConvertor) IssueComment(p *api.IssueCommentPayload) (WechatworkPayload, error) {
+ text, issueTitle, _ := getIssueCommentPayloadInfo(p, noneLinkFormatter, true)
+ var content string
+ content += fmt.Sprintf(" ><font color=\"info\">%s</font>\n >%s \n ><font color=\"warning\">%s</font> \n [%s](%s)", text, p.Comment.Body, issueTitle, p.Comment.HTMLURL, p.Comment.HTMLURL)
+
+ return newWechatworkMarkdownPayload(content), nil
+}
+
+// PullRequest implements PayloadConvertor PullRequest method
+func (wc wechatworkConvertor) PullRequest(p *api.PullRequestPayload) (WechatworkPayload, error) {
+ text, issueTitle, attachmentText, _ := getPullRequestPayloadInfo(p, noneLinkFormatter, true)
+ pr := fmt.Sprintf("> <font color=\"info\"> %s </font> \r\n > <font color=\"comment\">%s </font> \r\n > <font color=\"comment\">%s </font> \r\n",
+ text, issueTitle, attachmentText)
+
+ return newWechatworkMarkdownPayload(pr), nil
+}
+
+// Review implements PayloadConvertor Review method
+func (wc wechatworkConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (WechatworkPayload, error) {
+ var text, title string
+ if p.Action == api.HookIssueReviewed {
+ action, err := parseHookPullRequestEventType(event)
+ if err != nil {
+ return WechatworkPayload{}, err
+ }
+ title = fmt.Sprintf("[%s] Pull request review %s : #%d %s", p.Repository.FullName, action, p.Index, p.PullRequest.Title)
+ text = p.Review.Content
+ }
+
+ return newWechatworkMarkdownPayload("# " + title + "\r\n\r\n >" + text), nil
+}
+
+// Repository implements PayloadConvertor Repository method
+func (wc wechatworkConvertor) Repository(p *api.RepositoryPayload) (WechatworkPayload, error) {
+ var title string
+ switch p.Action {
+ case api.HookRepoCreated:
+ title = fmt.Sprintf("[%s] Repository created", p.Repository.FullName)
+ return newWechatworkMarkdownPayload(title), nil
+ case api.HookRepoDeleted:
+ title = fmt.Sprintf("[%s] Repository deleted", p.Repository.FullName)
+ return newWechatworkMarkdownPayload(title), nil
+ }
+
+ return WechatworkPayload{}, nil
+}
+
+// Wiki implements PayloadConvertor Wiki method
+func (wc wechatworkConvertor) Wiki(p *api.WikiPayload) (WechatworkPayload, error) {
+ text, _, _ := getWikiPayloadInfo(p, noneLinkFormatter, true)
+
+ return newWechatworkMarkdownPayload(text), nil
+}
+
+// Release implements PayloadConvertor Release method
+func (wc wechatworkConvertor) Release(p *api.ReleasePayload) (WechatworkPayload, error) {
+ text, _ := getReleasePayloadInfo(p, noneLinkFormatter, true)
+
+ return newWechatworkMarkdownPayload(text), nil
+}
+
+func (wc wechatworkConvertor) Package(p *api.PackagePayload) (WechatworkPayload, error) {
+ text, _ := getPackagePayloadInfo(p, noneLinkFormatter, true)
+
+ return newWechatworkMarkdownPayload(text), nil
+}
+
+type wechatworkConvertor struct{}
+
+var _ shared.PayloadConvertor[WechatworkPayload] = wechatworkConvertor{}
+
+func (wechatworkHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
+ return shared.NewJSONRequest(wechatworkConvertor{}, w, t, true)
+}