summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--modules/structs/repo.go10
-rw-r--r--modules/webhook/type.go25
-rw-r--r--options/locale/locale_en-US.ini9
-rw-r--r--public/assets/img/sourcehut.svg7
-rw-r--r--routers/web/repo/setting/webhook.go24
-rw-r--r--services/forms/repo_form.go21
-rw-r--r--services/webhook/default.go65
-rw-r--r--services/webhook/dingtalk.go25
-rw-r--r--services/webhook/discord.go23
-rw-r--r--services/webhook/feishu.go25
-rw-r--r--services/webhook/general.go8
-rw-r--r--services/webhook/gogs.go21
-rw-r--r--services/webhook/matrix.go25
-rw-r--r--services/webhook/msteams.go25
-rw-r--r--services/webhook/packagist.go21
-rw-r--r--services/webhook/shared/img.go15
-rw-r--r--services/webhook/shared/payloader.go (renamed from services/webhook/payloader.go)64
-rw-r--r--services/webhook/slack.go23
-rw-r--r--services/webhook/sourcehut/builds.go312
-rw-r--r--services/webhook/sourcehut/builds_test.go440
-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/01/16b5e2279b70e5f5b98240e8896331248f4463bin0 -> 54 bytes
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/objects/3c/3d4b799b3933ba687b263eeef2034300a5315ebin0 -> 83 bytes
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/objects/56/f276169b9766f805e5198fe7fb6698153fdb031
-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/9e/4b777f81b316a1c75a0797b33add68ee49b0d0bin0 -> 54 bytes
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/objects/aa/3905af404394f576f88f00e7f0919b4b97453fbin0 -> 57 bytes
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/objects/bd/d74dba3f34542e480d56c2a91c9e2463180d3cbin0 -> 130 bytes
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/objects/c2/30778a03511f546f532e79c8c14917c260e750bin0 -> 84 bytes
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/objects/c9/fa8be8c6f230e9529f8e546a3e6a354cbaf313bin0 -> 56 bytes
-rw-r--r--services/webhook/sourcehut/testdata/repo.git/objects/cb/80b2628b69c86b6baea464e5f9fc28405fde4b1
-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.go23
-rw-r--r--services/webhook/webhook.go15
-rw-r--r--services/webhook/wechatwork.go25
-rw-r--r--templates/webhook/new.tmpl2
-rw-r--r--templates/webhook/new/sourcehut_builds.tmpl33
-rw-r--r--tests/integration/repo_webhook_test.go38
43 files changed, 1122 insertions, 224 deletions
diff --git a/modules/structs/repo.go b/modules/structs/repo.go
index a50cddaf7e..f6cc9803a4 100644
--- a/modules/structs/repo.go
+++ b/modules/structs/repo.go
@@ -115,6 +115,16 @@ type Repository struct {
RepoTransfer *RepoTransfer `json:"repo_transfer"`
}
+// GetName implements the gitrepo.Repository interface
+func (r Repository) GetName() string {
+ return r.Name
+}
+
+// GetOwnerName implements the gitrepo.Repository interface
+func (r Repository) GetOwnerName() string {
+ return r.Owner.UserName
+}
+
// CreateRepoOption options when creating repository
// swagger:model
type CreateRepoOption struct {
diff --git a/modules/webhook/type.go b/modules/webhook/type.go
index 0d2aef5e15..865f30c926 100644
--- a/modules/webhook/type.go
+++ b/modules/webhook/type.go
@@ -73,18 +73,19 @@ type HookType = string
// Types of webhooks
const (
- FORGEJO HookType = "forgejo"
- GITEA HookType = "gitea"
- GOGS HookType = "gogs"
- SLACK HookType = "slack"
- DISCORD HookType = "discord"
- DINGTALK HookType = "dingtalk"
- TELEGRAM HookType = "telegram"
- MSTEAMS HookType = "msteams"
- FEISHU HookType = "feishu"
- MATRIX HookType = "matrix"
- WECHATWORK HookType = "wechatwork"
- PACKAGIST HookType = "packagist"
+ FORGEJO HookType = "forgejo"
+ GITEA HookType = "gitea"
+ GOGS HookType = "gogs"
+ SLACK HookType = "slack"
+ DISCORD HookType = "discord"
+ DINGTALK HookType = "dingtalk"
+ TELEGRAM HookType = "telegram"
+ MSTEAMS HookType = "msteams"
+ FEISHU HookType = "feishu"
+ MATRIX HookType = "matrix"
+ WECHATWORK HookType = "wechatwork"
+ PACKAGIST HookType = "packagist"
+ SOURCEHUT_BUILDS HookType = "sourcehut_builds" //nolint:revive
)
// HookStatus is the status of a web hook
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index bef5610962..c727a9cc12 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -640,6 +640,8 @@ target_branch_not_exist = Target branch does not exist.
admin_cannot_delete_self = You cannot delete yourself when you are an admin. Please remove your admin privileges first.
+required_prefix = Input must start with "%s"
+
[user]
change_avatar = Change your avatar…
joined_on = Joined on %s
@@ -2269,6 +2271,7 @@ settings.delete_team_tip = This team has access to all repositories and can't be
settings.remove_team_success = The team's access to the repository has been removed.
settings.add_webhook = Add webhook
settings.add_webhook.invalid_channel_name = Webhook channel name cannot be empty and cannot contain only a # character.
+settings.add_webhook.invalid_path = Path must not contain a part that is "." or ".." or the empty string. It cannot start or end with a slash.
settings.hooks_desc = Webhooks automatically make HTTP POST requests to a server when certain Forgejo events trigger. Read more in the <a target="_blank" rel="noopener noreferrer" href="%s">webhooks guide</a>.
settings.webhook_deletion = Remove webhook
settings.webhook_deletion_desc = Removing a webhook deletes its settings and delivery history. Continue?
@@ -2384,6 +2387,12 @@ settings.web_hook_name_packagist = Packagist
settings.packagist_username = Packagist username
settings.packagist_api_token = API token
settings.packagist_package_url = Packagist package URL
+settings.web_hook_name_sourcehut_builds = SourceHut Builds
+settings.sourcehut_builds.manifest_path = Build manifest path
+settings.sourcehut_builds.graphql_url = GraphQL URL (e.g. https://builds.sr.ht/query)
+settings.sourcehut_builds.visibility = Job visibility
+settings.sourcehut_builds.secrets = Secrets
+settings.sourcehut_builds.secrets_helper = Give the job access to the build secrets (requires the SECRETS:RO grant)
settings.deploy_keys = Deploy keys
settings.add_deploy_key = Add deploy key
settings.deploy_key_desc = Deploy keys have read-only pull access to the repository.
diff --git a/public/assets/img/sourcehut.svg b/public/assets/img/sourcehut.svg
new file mode 100644
index 0000000000..a2a08d77d0
--- /dev/null
+++ b/public/assets/img/sourcehut.svg
@@ -0,0 +1,7 @@
+<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
+ <style>
+ path { fill: black; }
+ @media (prefers-color-scheme: dark) { path { fill: white; } }
+ </style>
+ <path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200z"/>
+</svg> \ No newline at end of file
diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go
index 4469eac9e8..eee493e2c2 100644
--- a/routers/web/repo/setting/webhook.go
+++ b/routers/web/repo/setting/webhook.go
@@ -148,7 +148,7 @@ func WebhookNew(ctx *context.Context) {
}
// ParseHookEvent convert web form content to webhook.HookEvent
-func ParseHookEvent(form forms.WebhookForm) *webhook_module.HookEvent {
+func ParseHookEvent(form forms.WebhookCoreForm) *webhook_module.HookEvent {
return &webhook_module.HookEvent{
PushOnly: form.PushOnly(),
SendEverything: form.SendEverything(),
@@ -188,7 +188,7 @@ func WebhookCreate(ctx *context.Context) {
return
}
- fields := handler.FormFields(func(form any) {
+ fields := handler.UnmarshalForm(func(form any) {
errs := binding.Bind(ctx.Req, form)
middleware.Validate(errs, ctx.Data, form, ctx.Locale) // error checked below in ctx.HasError
})
@@ -215,10 +215,10 @@ func WebhookCreate(ctx *context.Context) {
w.URL = fields.URL
w.ContentType = fields.ContentType
w.Secret = fields.Secret
- w.HookEvent = ParseHookEvent(fields.WebhookForm)
- w.IsActive = fields.WebhookForm.Active
+ w.HookEvent = ParseHookEvent(fields.WebhookCoreForm)
+ w.IsActive = fields.Active
w.HTTPMethod = fields.HTTPMethod
- err := w.SetHeaderAuthorization(fields.WebhookForm.AuthorizationHeader)
+ err := w.SetHeaderAuthorization(fields.AuthorizationHeader)
if err != nil {
ctx.ServerError("SetHeaderAuthorization", err)
return
@@ -245,14 +245,14 @@ func WebhookCreate(ctx *context.Context) {
HTTPMethod: fields.HTTPMethod,
ContentType: fields.ContentType,
Secret: fields.Secret,
- HookEvent: ParseHookEvent(fields.WebhookForm),
- IsActive: fields.WebhookForm.Active,
+ HookEvent: ParseHookEvent(fields.WebhookCoreForm),
+ IsActive: fields.Active,
Type: hookType,
Meta: string(meta),
OwnerID: orCtx.OwnerID,
IsSystemWebhook: orCtx.IsSystemWebhook,
}
- err = w.SetHeaderAuthorization(fields.WebhookForm.AuthorizationHeader)
+ err = w.SetHeaderAuthorization(fields.AuthorizationHeader)
if err != nil {
ctx.ServerError("SetHeaderAuthorization", err)
return
@@ -286,7 +286,7 @@ func WebhookUpdate(ctx *context.Context) {
return
}
- fields := handler.FormFields(func(form any) {
+ fields := handler.UnmarshalForm(func(form any) {
errs := binding.Bind(ctx.Req, form)
middleware.Validate(errs, ctx.Data, form, ctx.Locale) // error checked below in ctx.HasError
})
@@ -295,11 +295,11 @@ func WebhookUpdate(ctx *context.Context) {
w.URL = fields.URL
w.ContentType = fields.ContentType
w.Secret = fields.Secret
- w.HookEvent = ParseHookEvent(fields.WebhookForm)
- w.IsActive = fields.WebhookForm.Active
+ w.HookEvent = ParseHookEvent(fields.WebhookCoreForm)
+ w.IsActive = fields.Active
w.HTTPMethod = fields.HTTPMethod
- err := w.SetHeaderAuthorization(fields.WebhookForm.AuthorizationHeader)
+ err := w.SetHeaderAuthorization(fields.AuthorizationHeader)
if err != nil {
ctx.ServerError("SetHeaderAuthorization", err)
return
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index b5ff031f4b..e0540852af 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -12,6 +12,7 @@ import (
"code.gitea.io/gitea/models"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
+ webhook_model "code.gitea.io/gitea/models/webhook"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web/middleware"
@@ -235,8 +236,8 @@ func (f *ProtectBranchForm) Validate(req *http.Request, errs binding.Errors) bin
// \__/\ / \___ >___ /___| /\____/ \____/|__|_ \
// \/ \/ \/ \/ \/
-// WebhookForm form for changing web hook
-type WebhookForm struct {
+// WebhookCoreForm form for changing web hook (common to all webhook types)
+type WebhookCoreForm struct {
Events string
Create bool
Delete bool
@@ -265,20 +266,30 @@ type WebhookForm struct {
}
// PushOnly if the hook will be triggered when push
-func (f WebhookForm) PushOnly() bool {
+func (f WebhookCoreForm) PushOnly() bool {
return f.Events == "push_only"
}
// SendEverything if the hook will be triggered any event
-func (f WebhookForm) SendEverything() bool {
+func (f WebhookCoreForm) SendEverything() bool {
return f.Events == "send_everything"
}
// ChooseEvents if the hook will be triggered choose events
-func (f WebhookForm) ChooseEvents() bool {
+func (f WebhookCoreForm) ChooseEvents() bool {
return f.Events == "choose_events"
}
+// WebhookForm form for changing web hook (specific handling depending on the webhook type)
+type WebhookForm struct {
+ WebhookCoreForm
+ URL string
+ ContentType webhook_model.HookContentType
+ Secret string
+ HTTPMethod string
+ Metadata any
+}
+
// .___
// | | ______ ________ __ ____
// | |/ ___// ___/ | \_/ __ \
diff --git a/services/webhook/default.go b/services/webhook/default.go
index be3b9b3c73..314f539648 100644
--- a/services/webhook/default.go
+++ b/services/webhook/default.go
@@ -5,13 +5,8 @@ package webhook
import (
"context"
- "crypto/hmac"
- "crypto/sha1"
- "crypto/sha256"
- "encoding/hex"
"fmt"
"html/template"
- "io"
"net/http"
"net/url"
"strings"
@@ -21,6 +16,7 @@ import (
"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{}
@@ -39,16 +35,16 @@ func (dh defaultHandler) Type() webhook_module.HookType {
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 imgIcon("forgejo.svg", size)
+ return shared.ImgIcon("forgejo.svg", size)
}
return svg.RenderHTML("gitea-gitea", size, "img")
}
func (defaultHandler) Metadata(*webhook_model.Webhook) any { return nil }
-func (defaultHandler) FormFields(bind func(any)) FormFields {
+func (defaultHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
var form struct {
- forms.WebhookForm
+ forms.WebhookCoreForm
PayloadURL string `binding:"Required;ValidUrl"`
HTTPMethod string `binding:"Required;In(POST,GET)"`
ContentType int `binding:"Required"`
@@ -60,13 +56,13 @@ func (defaultHandler) FormFields(bind func(any)) FormFields {
if webhook_model.HookContentType(form.ContentType) == webhook_model.ContentTypeForm {
contentType = webhook_model.ContentTypeForm
}
- return FormFields{
- WebhookForm: form.WebhookForm,
- URL: form.PayloadURL,
- ContentType: contentType,
- Secret: form.Secret,
- HTTPMethod: form.HTTPMethod,
- Metadata: nil,
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: form.PayloadURL,
+ ContentType: contentType,
+ Secret: form.Secret,
+ HTTPMethod: form.HTTPMethod,
+ Metadata: nil,
}
}
@@ -130,42 +126,5 @@ func (defaultHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook,
}
body = []byte(t.PayloadContent)
- return req, body, addDefaultHeaders(req, []byte(w.Secret), t, body)
-}
-
-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
+ return req, body, shared.AddDefaultHeaders(req, []byte(w.Secret), t, body)
}
diff --git a/services/webhook/dingtalk.go b/services/webhook/dingtalk.go
index 0a0160ac46..ea35442436 100644
--- a/services/webhook/dingtalk.go
+++ b/services/webhook/dingtalk.go
@@ -17,28 +17,29 @@ import (
"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 imgIcon("dingtalk.ico", size) }
+func (dingtalkHandler) Icon(size int) template.HTML { return shared.ImgIcon("dingtalk.ico", size) }
-func (dingtalkHandler) FormFields(bind func(any)) FormFields {
+func (dingtalkHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
var form struct {
- forms.WebhookForm
+ forms.WebhookCoreForm
PayloadURL string `binding:"Required;ValidUrl"`
}
bind(&form)
- return FormFields{
- WebhookForm: form.WebhookForm,
- URL: form.PayloadURL,
- ContentType: webhook_model.ContentTypeJSON,
- Secret: "",
- HTTPMethod: http.MethodPost,
- Metadata: nil,
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: form.PayloadURL,
+ ContentType: webhook_model.ContentTypeJSON,
+ Secret: "",
+ HTTPMethod: http.MethodPost,
+ Metadata: nil,
}
}
@@ -225,8 +226,8 @@ func createDingtalkPayload(title, text, singleTitle, singleURL string) DingtalkP
type dingtalkConvertor struct{}
-var _ payloadConvertor[DingtalkPayload] = dingtalkConvertor{}
+var _ shared.PayloadConvertor[DingtalkPayload] = dingtalkConvertor{}
func (dingtalkHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
- return newJSONRequest(dingtalkConvertor{}, w, t, true)
+ return shared.NewJSONRequest(dingtalkConvertor{}, w, t, true)
}
diff --git a/services/webhook/discord.go b/services/webhook/discord.go
index 2efb46f5bb..cb756688c8 100644
--- a/services/webhook/discord.go
+++ b/services/webhook/discord.go
@@ -22,28 +22,29 @@ import (
"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 discordHandler struct{}
func (discordHandler) Type() webhook_module.HookType { return webhook_module.DISCORD }
-func (discordHandler) Icon(size int) template.HTML { return imgIcon("discord.png", size) }
+func (discordHandler) Icon(size int) template.HTML { return shared.ImgIcon("discord.png", size) }
-func (discordHandler) FormFields(bind func(any)) FormFields {
+func (discordHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
var form struct {
- forms.WebhookForm
+ forms.WebhookCoreForm
PayloadURL string `binding:"Required;ValidUrl"`
Username string
IconURL string
}
bind(&form)
- return FormFields{
- WebhookForm: form.WebhookForm,
- URL: form.PayloadURL,
- ContentType: webhook_model.ContentTypeJSON,
- Secret: "",
- HTTPMethod: http.MethodPost,
+ 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,
@@ -287,7 +288,7 @@ type discordConvertor struct {
AvatarURL string
}
-var _ payloadConvertor[DiscordPayload] = discordConvertor{}
+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{}
@@ -298,7 +299,7 @@ func (discordHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook,
Username: meta.Username,
AvatarURL: meta.IconURL,
}
- return newJSONRequest(sc, w, t, true)
+ return shared.NewJSONRequest(sc, w, t, true)
}
func parseHookPullRequestEventType(event webhook_module.HookEventType) (string, error) {
diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go
index eba54fa09b..f77c3bbd65 100644
--- a/services/webhook/feishu.go
+++ b/services/webhook/feishu.go
@@ -15,27 +15,28 @@ import (
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 imgIcon("feishu.png", size) }
+func (feishuHandler) Icon(size int) template.HTML { return shared.ImgIcon("feishu.png", size) }
-func (feishuHandler) FormFields(bind func(any)) FormFields {
+func (feishuHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
var form struct {
- forms.WebhookForm
+ forms.WebhookCoreForm
PayloadURL string `binding:"Required;ValidUrl"`
}
bind(&form)
- return FormFields{
- WebhookForm: form.WebhookForm,
- URL: form.PayloadURL,
- ContentType: webhook_model.ContentTypeJSON,
- Secret: "",
- HTTPMethod: http.MethodPost,
- Metadata: nil,
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: form.PayloadURL,
+ ContentType: webhook_model.ContentTypeJSON,
+ Secret: "",
+ HTTPMethod: http.MethodPost,
+ Metadata: nil,
}
}
@@ -192,8 +193,8 @@ func (fc feishuConvertor) Package(p *api.PackagePayload) (FeishuPayload, error)
type feishuConvertor struct{}
-var _ payloadConvertor[FeishuPayload] = feishuConvertor{}
+var _ shared.PayloadConvertor[FeishuPayload] = feishuConvertor{}
func (feishuHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
- return newJSONRequest(feishuConvertor{}, w, t, true)
+ return shared.NewJSONRequest(feishuConvertor{}, w, t, true)
}
diff --git a/services/webhook/general.go b/services/webhook/general.go
index 454efc6495..c41f58fe8d 100644
--- a/services/webhook/general.go
+++ b/services/webhook/general.go
@@ -6,9 +6,7 @@ package webhook
import (
"fmt"
"html"
- "html/template"
"net/url"
- "strconv"
"strings"
webhook_model "code.gitea.io/gitea/models/webhook"
@@ -354,9 +352,3 @@ func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) {
Created: w.CreatedUnix.AsTime(),
}, nil
}
-
-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/gogs.go b/services/webhook/gogs.go
index f616f5e2f3..7dbf64343f 100644
--- a/services/webhook/gogs.go
+++ b/services/webhook/gogs.go
@@ -10,16 +10,17 @@ import (
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 imgIcon("gogs.ico", size) }
+func (gogsHandler) Icon(size int) template.HTML { return shared.ImgIcon("gogs.ico", size) }
-func (gogsHandler) FormFields(bind func(any)) FormFields {
+func (gogsHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
var form struct {
- forms.WebhookForm
+ forms.WebhookCoreForm
PayloadURL string `binding:"Required;ValidUrl"`
ContentType int `binding:"Required"`
Secret string
@@ -30,12 +31,12 @@ func (gogsHandler) FormFields(bind func(any)) FormFields {
if webhook_model.HookContentType(form.ContentType) == webhook_model.ContentTypeForm {
contentType = webhook_model.ContentTypeForm
}
- return FormFields{
- WebhookForm: form.WebhookForm,
- URL: form.PayloadURL,
- ContentType: contentType,
- Secret: form.Secret,
- HTTPMethod: http.MethodPost,
- Metadata: nil,
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: form.PayloadURL,
+ ContentType: contentType,
+ Secret: form.Secret,
+ HTTPMethod: http.MethodPost,
+ Metadata: nil,
}
}
diff --git a/services/webhook/matrix.go b/services/webhook/matrix.go
index 322b4d6665..697e33e94c 100644
--- a/services/webhook/matrix.go
+++ b/services/webhook/matrix.go
@@ -25,6 +25,7 @@ import (
"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{}
@@ -35,25 +36,25 @@ func (matrixHandler) Icon(size int) template.HTML {
return svg.RenderHTML("gitea-matrix", size, "img")
}
-func (matrixHandler) FormFields(bind func(any)) FormFields {
+func (matrixHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
var form struct {
- forms.WebhookForm
+ forms.WebhookCoreForm
HomeserverURL string `binding:"Required;ValidUrl"`
RoomID string `binding:"Required"`
MessageType int
// enforce requirement of authorization_header
- // (value will still be set in the embedded WebhookForm)
+ // (value will still be set in the embedded WebhookCoreForm)
AuthorizationHeader string `binding:"Required"`
}
bind(&form)
- return FormFields{
- WebhookForm: form.WebhookForm,
- URL: fmt.Sprintf("%s/_matrix/client/r0/rooms/%s/send/m.room.message", form.HomeserverURL, url.PathEscape(form.RoomID)),
- ContentType: webhook_model.ContentTypeJSON,
- Secret: "",
- HTTPMethod: http.MethodPut,
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: fmt.Sprintf("%s/_matrix/client/r0/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,
@@ -70,7 +71,7 @@ func (matrixHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t
mc := matrixConvertor{
MsgType: messageTypeText[meta.MessageType],
}
- payload, err := newPayload(mc, []byte(t.PayloadContent), t.EventType)
+ payload, err := shared.NewPayload(mc, []byte(t.PayloadContent), t.EventType)
if err != nil {
return nil, nil, err
}
@@ -90,7 +91,7 @@ func (matrixHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t
}
req.Header.Set("Content-Type", "application/json")
- return req, body, addDefaultHeaders(req, []byte(w.Secret), t, body) // likely useless, but has always been sent historially
+ return req, body, shared.AddDefaultHeaders(req, []byte(w.Secret), t, body) // likely useless, but has always been sent historially
}
const matrixPayloadSizeLimit = 1024 * 64
@@ -125,7 +126,7 @@ type MatrixPayload struct {
Commits []*api.PayloadCommit `json:"io.gitea.commits,omitempty"`
}
-var _ payloadConvertor[MatrixPayload] = matrixConvertor{}
+var _ shared.PayloadConvertor[MatrixPayload] = matrixConvertor{}
type matrixConvertor struct {
MsgType string
diff --git a/services/webhook/msteams.go b/services/webhook/msteams.go
index 940a6c49aa..3e9959146b 100644
--- a/services/webhook/msteams.go
+++ b/services/webhook/msteams.go
@@ -17,28 +17,29 @@ import (
"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 imgIcon("msteams.png", size) }
+func (msteamsHandler) Icon(size int) template.HTML { return shared.ImgIcon("msteams.png", size) }
-func (msteamsHandler) FormFields(bind func(any)) FormFields {
+func (msteamsHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
var form struct {
- forms.WebhookForm
+ forms.WebhookCoreForm
PayloadURL string `binding:"Required;ValidUrl"`
}
bind(&form)
- return FormFields{
- WebhookForm: form.WebhookForm,
- URL: form.PayloadURL,
- ContentType: webhook_model.ContentTypeJSON,
- Secret: "",
- HTTPMethod: http.MethodPost,
- Metadata: nil,
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: form.PayloadURL,
+ ContentType: webhook_model.ContentTypeJSON,
+ Secret: "",
+ HTTPMethod: http.MethodPost,
+ Metadata: nil,
}
}
@@ -370,8 +371,8 @@ func createMSTeamsPayload(r *api.Repository, s *api.User, title, text, actionTar
type msteamsConvertor struct{}
-var _ payloadConvertor[MSTeamsPayload] = msteamsConvertor{}
+var _ shared.PayloadConvertor[MSTeamsPayload] = msteamsConvertor{}
func (msteamsHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
- return newJSONRequest(msteamsConvertor{}, w, t, true)
+ return shared.NewJSONRequest(msteamsConvertor{}, w, t, true)
}
diff --git a/services/webhook/packagist.go b/services/webhook/packagist.go
index f1f3306109..9831a4e008 100644
--- a/services/webhook/packagist.go
+++ b/services/webhook/packagist.go
@@ -15,28 +15,29 @@ import (
"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 imgIcon("packagist.png", size) }
+func (packagistHandler) Icon(size int) template.HTML { return shared.ImgIcon("packagist.png", size) }
-func (packagistHandler) FormFields(bind func(any)) FormFields {
+func (packagistHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
var form struct {
- forms.WebhookForm
+ forms.WebhookCoreForm
Username string `binding:"Required"`
APIToken string `binding:"Required"`
PackageURL string `binding:"Required;ValidUrl"`
}
bind(&form)
- return FormFields{
- WebhookForm: form.WebhookForm,
- 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,
+ 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,
@@ -85,5 +86,5 @@ func (packagistHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook
URL: meta.PackageURL,
},
}
- return newJSONRequestWithPayload(payload, w, t, false)
+ return shared.NewJSONRequestWithPayload(payload, w, t, false)
}
diff --git a/services/webhook/shared/img.go b/services/webhook/shared/img.go
new file mode 100644
index 0000000000..2d65ba4e0f
--- /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/payloader.go b/services/webhook/shared/payloader.go
index f87e6e4eec..cf0bfa82cb 100644
--- a/services/webhook/payloader.go
+++ b/services/webhook/shared/payloader.go
@@ -1,11 +1,17 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
-package webhook
+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"
@@ -14,8 +20,10 @@ import (
webhook_module "code.gitea.io/gitea/modules/webhook"
)
-// payloadConvertor defines the interface to convert system payload to webhook payload
-type payloadConvertor[T any] interface {
+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)
@@ -39,7 +47,7 @@ func convertUnmarshalledJSON[T, P any](convert func(P) (T, error), data []byte)
return convert(p)
}
-func newPayload[T any](rc payloadConvertor[T], data []byte, event webhook_module.HookEventType) (T, error) {
+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)
@@ -83,15 +91,15 @@ func newPayload[T any](rc payloadConvertor[T], data []byte, event webhook_module
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)
+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)
+ return NewJSONRequestWithPayload(payload, w, t, withDefaultHeaders)
}
-func newJSONRequestWithPayload(payload any, w *webhook_model.Webhook, t *webhook_model.HookTask, withDefaultHeaders bool) (*http.Request, []byte, error) {
+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
@@ -109,7 +117,45 @@ func newJSONRequestWithPayload(payload any, w *webhook_model.Webhook, t *webhook
req.Header.Set("Content-Type", "application/json")
if withDefaultHeaders {
- return req, body, addDefaultHeaders(req, []byte(w.Secret), t, body)
+ 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
index 0b4c4b6645..c835d59984 100644
--- a/services/webhook/slack.go
+++ b/services/webhook/slack.go
@@ -20,6 +20,7 @@ import (
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"
)
@@ -27,10 +28,10 @@ import (
type slackHandler struct{}
func (slackHandler) Type() webhook_module.HookType { return webhook_module.SLACK }
-func (slackHandler) Icon(size int) template.HTML { return imgIcon("slack.png", size) }
+func (slackHandler) Icon(size int) template.HTML { return shared.ImgIcon("slack.png", size) }
type slackForm struct {
- forms.WebhookForm
+ forms.WebhookCoreForm
PayloadURL string `binding:"Required;ValidUrl"`
Channel string `binding:"Required"`
Username string
@@ -53,16 +54,16 @@ func (s *slackForm) Validate(req *http.Request, errs binding.Errors) binding.Err
return errs
}
-func (slackHandler) FormFields(bind func(any)) FormFields {
+func (slackHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
var form slackForm
bind(&form)
- return FormFields{
- WebhookForm: form.WebhookForm,
- URL: form.PayloadURL,
- ContentType: webhook_model.ContentTypeJSON,
- Secret: "",
- HTTPMethod: http.MethodPost,
+ 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,
@@ -334,7 +335,7 @@ type slackConvertor struct {
Color string
}
-var _ payloadConvertor[SlackPayload] = slackConvertor{}
+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{}
@@ -347,7 +348,7 @@ func (slackHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t
IconURL: meta.IconURL,
Color: meta.Color,
}
- return newJSONRequest(sc, w, t, true)
+ return shared.NewJSONRequest(sc, w, t, true)
}
var slackChannel = regexp.MustCompile(`^#?[a-z0-9_-]{1,80}$`)
diff --git a/services/webhook/sourcehut/builds.go b/services/webhook/sourcehut/builds.go
new file mode 100644
index 0000000000..1561b9e6e6
--- /dev/null
+++ b/services/webhook/sourcehut/builds.go
@@ -0,0 +1,312 @@
+// 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
+}
+
+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"),
+ })
+ }
+ if !strings.HasPrefix(f.AuthorizationHeader, "Bearer ") {
+ errs = append(errs, binding.Error{
+ FieldNames: []string{"AuthorizationHeader"},
+ Classification: "",
+ Message: ctx.Locale.TrString("form.required_prefix", "Bearer "),
+ })
+ }
+ 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()
+ var manifest struct {
+ Image string `yaml:"image"`
+ Arch string `yaml:"arch,omitempty"`
+ Packages []string `yaml:"packages,omitempty"`
+ Repositories map[string]string `yaml:"repositories,omitempty"`
+ Artifacts []string `yaml:"artifacts,omitempty"`
+ Shell bool `yaml:"shell,omitempty"`
+ Sources []string `yaml:"sources"`
+ Tasks []map[string]string `yaml:"tasks"`
+ Triggers []string `yaml:"triggers,omitempty"`
+ Environment map[string]string `yaml:"environment"`
+ Secrets []string `yaml:"secrets,omitempty"`
+ Oauth string `yaml:"oauth,omitempty"`
+ }
+ 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 0000000000..9ab018df72
--- /dev/null
+++ b/services/webhook/sourcehut/builds_test.go
@@ -0,0 +1,440 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package sourcehut
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ 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/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"
+ repo_service "code.gitea.io/gitea/services/repository"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "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()))
+ assert.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, buildsVariables{
+ Manifest: `image: alpine/edge
+sources:
+ - http://localhost:3000/testdata/repo.git#58771003157b81abc6bf41df0c5db4147a3e3c83
+tasks:
+ - say-hello: |
+ echo hello
+ - say-world: echo world
+environment:
+ BUILD_SUBMITTER: forgejo
+ BUILD_SUBMITTER_URL: https://example.forgejo.org/
+ GIT_REF: refs/heads/test
+`,
+ 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, buildsVariables{
+ Manifest: `image: alpine/edge
+sources:
+ - http://localhost:3000/testdata/repo.git#58771003157b81abc6bf41df0c5db4147a3e3c83
+tasks:
+ - say-hello: |
+ echo hello
+ - say-world: echo world
+environment:
+ BUILD_SUBMITTER: forgejo
+ BUILD_SUBMITTER_URL: https://example.forgejo.org/
+ GIT_REF: refs/tags/v1.0.0
+`,
+ 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, err, shared.ErrPayloadTypeNotSupported)
+ require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+ })
+
+ t.Run("Fork", func(t *testing.T) {
+ p := &api.ForkPayload{}
+
+ pl, err := pc.Fork(p)
+ require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+ require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+ })
+
+ 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, buildsVariables{
+ Manifest: `image: alpine/edge
+sources:
+ - http://localhost:3000/testdata/repo.git#58771003157b81abc6bf41df0c5db4147a3e3c83
+tasks:
+ - say-hello: |
+ echo hello
+ - say-world: echo world
+environment:
+ BUILD_SUBMITTER: forgejo
+ BUILD_SUBMITTER_URL: https://example.forgejo.org/
+ GIT_REF: refs/heads/main
+`,
+ 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: "69b217caa89166a02b8cd368b64fb83a44720e14",
+ 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, buildsVariables{
+ Manifest: `image: archlinux
+packages:
+ - nodejs
+ - npm
+ - rsync
+sources:
+ - http://localhost:3000/testdata/repo.git#69b217caa89166a02b8cd368b64fb83a44720e14
+tasks: []
+environment:
+ BUILD_SUBMITTER: forgejo
+ BUILD_SUBMITTER_URL: https://example.forgejo.org/
+ GIT_REF: refs/heads/main
+ deploy: synapse@synapse-bt.org
+secrets:
+ - 7ebab768-e5e4-4c9d-ba57-ec41a72c5665
+`,
+ 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, err, shared.ErrPayloadTypeNotSupported)
+ require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+
+ p.Action = api.HookIssueClosed
+ pl, err = pc.Issue(p)
+ require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+ require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+ })
+
+ t.Run("IssueComment", func(t *testing.T) {
+ p := &api.IssueCommentPayload{}
+
+ pl, err := pc.IssueComment(p)
+ require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+ require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+ })
+
+ t.Run("PullRequest", func(t *testing.T) {
+ p := &api.PullRequestPayload{}
+
+ pl, err := pc.PullRequest(p)
+ require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+ require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+ })
+
+ t.Run("PullRequestComment", func(t *testing.T) {
+ p := &api.IssueCommentPayload{
+ IsPull: true,
+ }
+
+ pl, err := pc.IssueComment(p)
+ require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+ require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+ })
+
+ 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, err, shared.ErrPayloadTypeNotSupported)
+ require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+ })
+
+ t.Run("Repository", func(t *testing.T) {
+ p := &api.RepositoryPayload{}
+
+ pl, err := pc.Repository(p)
+ require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+ require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+ })
+
+ t.Run("Package", func(t *testing.T) {
+ p := &api.PackagePayload{}
+
+ pl, err := pc.Package(p)
+ require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+ require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+ })
+
+ t.Run("Wiki", func(t *testing.T) {
+ p := &api.WikiPayload{}
+
+ p.Action = api.HookWikiCreated
+ pl, err := pc.Wiki(p)
+ require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+ require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+
+ p.Action = api.HookWikiEdited
+ pl, err = pc.Wiki(p)
+ require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+ require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+
+ p.Action = api.HookWikiDeleted
+ pl, err = pc.Wiki(p)
+ require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+ require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+ })
+
+ t.Run("Release", func(t *testing.T) {
+ p := &api.ReleasePayload{}
+
+ pl, err := pc.Release(p)
+ require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+ require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+ })
+}
+
+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)
+ assert.NoError(t, err)
+ assert.Equal(t, "json test", body.Variables.Note)
+}
+
+func CreateDeclarativeRepo(t *testing.T, owner *user_model.User, name string, enabledUnits, disabledUnits []unit_model.Type, files []*files_service.ChangeRepoFile) (*repo_model.Repository, string) {
+ t.Helper()
+
+ // Create a new repository
+ repo, err := repo_service.CreateRepository(db.DefaultContext, owner, owner, repo_service.CreateRepoOptions{
+ Name: name,
+ Description: "Temporary Repo",
+ AutoInit: true,
+ Gitignores: "",
+ License: "WTFPL",
+ Readme: "Default",
+ DefaultBranch: "main",
+ })
+ assert.NoError(t, err)
+ assert.NotEmpty(t, repo)
+ t.Cleanup(func() {
+ repo_service.DeleteRepository(db.DefaultContext, owner, repo, false)
+ })
+
+ if enabledUnits != nil || disabledUnits != nil {
+ units := make([]repo_model.RepoUnit, len(enabledUnits))
+ for i, unitType := range enabledUnits {
+ units[i] = repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unitType,
+ }
+ }
+
+ err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, units, disabledUnits)
+ assert.NoError(t, err)
+ }
+
+ var sha string
+ if len(files) > 0 {
+ resp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, owner, &files_service.ChangeRepoFilesOptions{
+ Files: files,
+ Message: "add files",
+ OldBranch: "main",
+ NewBranch: "main",
+ Author: &files_service.IdentityOptions{
+ Name: owner.Name,
+ Email: owner.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: owner.Name,
+ Email: owner.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+ assert.NoError(t, err)
+ assert.NotEmpty(t, resp)
+
+ sha = resp.Commit.SHA
+ }
+
+ return repo, sha
+}
diff --git a/services/webhook/sourcehut/testdata/repo.git/HEAD b/services/webhook/sourcehut/testdata/repo.git/HEAD
new file mode 100644
index 0000000000..b870d82622
--- /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 0000000000..07d359d07c
--- /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 0000000000..498b267a8c
--- /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 0000000000..a5196d1be8
--- /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/01/16b5e2279b70e5f5b98240e8896331248f4463 b/services/webhook/sourcehut/testdata/repo.git/objects/01/16b5e2279b70e5f5b98240e8896331248f4463
new file mode 100644
index 0000000000..c06eb842be
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/01/16b5e2279b70e5f5b98240e8896331248f4463
Binary files differ
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 0000000000..f03b45d3f9
--- /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/56/f276169b9766f805e5198fe7fb6698153fdb03 b/services/webhook/sourcehut/testdata/repo.git/objects/56/f276169b9766f805e5198fe7fb6698153fdb03
new file mode 100644
index 0000000000..dca1d23ce9
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/56/f276169b9766f805e5198fe7fb6698153fdb03
@@ -0,0 +1 @@
+xNKj0ZxBɶzQ[FQ?"=A3Ѳmk#*@L3&)'D$#Β 搊Ѽ,#/8OvzIN<u'[;J~{#'e;.x輋#[K[kyASq\DAkƵ؝~PkVO \ No newline at end of file
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 0000000000..e9ff0d0bd9
--- /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 0000000000..1aed81107b
--- /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/9e/4b777f81b316a1c75a0797b33add68ee49b0d0 b/services/webhook/sourcehut/testdata/repo.git/objects/9e/4b777f81b316a1c75a0797b33add68ee49b0d0
new file mode 100644
index 0000000000..081cfcd5ba
--- /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/aa/3905af404394f576f88f00e7f0919b4b97453f b/services/webhook/sourcehut/testdata/repo.git/objects/aa/3905af404394f576f88f00e7f0919b4b97453f
new file mode 100644
index 0000000000..cc96171c1c
--- /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/bd/d74dba3f34542e480d56c2a91c9e2463180d3c b/services/webhook/sourcehut/testdata/repo.git/objects/bd/d74dba3f34542e480d56c2a91c9e2463180d3c
new file mode 100644
index 0000000000..639f5c4784
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/bd/d74dba3f34542e480d56c2a91c9e2463180d3c
Binary files differ
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/c2/30778a03511f546f532e79c8c14917c260e750 b/services/webhook/sourcehut/testdata/repo.git/objects/c2/30778a03511f546f532e79c8c14917c260e750
new file mode 100644
index 0000000000..4a952fb0b2
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/c2/30778a03511f546f532e79c8c14917c260e750
Binary files differ
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/c9/fa8be8c6f230e9529f8e546a3e6a354cbaf313 b/services/webhook/sourcehut/testdata/repo.git/objects/c9/fa8be8c6f230e9529f8e546a3e6a354cbaf313
new file mode 100644
index 0000000000..291f0a422c
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/c9/fa8be8c6f230e9529f8e546a3e6a354cbaf313
Binary files differ
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/cb/80b2628b69c86b6baea464e5f9fc28405fde4b b/services/webhook/sourcehut/testdata/repo.git/objects/cb/80b2628b69c86b6baea464e5f9fc28405fde4b
new file mode 100644
index 0000000000..891ace4651
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/cb/80b2628b69c86b6baea464e5f9fc28405fde4b
@@ -0,0 +1 @@
+x=Kn0 D)`k@Pd{P2-AQ] YIesmKoD)8p gg44lFQF9˜V,[UΤ`~[iVڕ 4+(0Y)$"ԠlZ-e5wԦʸNY?V4&tC9=a ,P \ No newline at end of file
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 0000000000..f57ab8a70d
--- /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 0000000000..4e693a7464
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/refs/heads/main
@@ -0,0 +1 @@
+69b217caa89166a02b8cd368b64fb83a44720e14
diff --git a/services/webhook/telegram.go b/services/webhook/telegram.go
index daa986bafb..724c41012f 100644
--- a/services/webhook/telegram.go
+++ b/services/webhook/telegram.go
@@ -18,28 +18,29 @@ import (
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 imgIcon("telegram.png", size) }
+func (telegramHandler) Icon(size int) template.HTML { return shared.ImgIcon("telegram.png", size) }
-func (telegramHandler) FormFields(bind func(any)) FormFields {
+func (telegramHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
var form struct {
- forms.WebhookForm
+ forms.WebhookCoreForm
BotToken string `binding:"Required"`
ChatID string `binding:"Required"`
ThreadID string
}
bind(&form)
- return FormFields{
- WebhookForm: form.WebhookForm,
- 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,
+ 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,
@@ -220,8 +221,8 @@ func createTelegramPayload(message string) TelegramPayload {
type telegramConvertor struct{}
-var _ payloadConvertor[TelegramPayload] = telegramConvertor{}
+var _ shared.PayloadConvertor[TelegramPayload] = telegramConvertor{}
func (telegramHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
- return newJSONRequest(telegramConvertor{}, w, t, true)
+ return shared.NewJSONRequest(telegramConvertor{}, w, t, true)
}
diff --git a/services/webhook/webhook.go b/services/webhook/webhook.go
index f27bffc29a..dc68cae84d 100644
--- a/services/webhook/webhook.go
+++ b/services/webhook/webhook.go
@@ -25,6 +25,7 @@ import (
"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"
)
@@ -32,22 +33,13 @@ import (
type Handler interface {
Type() webhook_module.HookType
Metadata(*webhook_model.Webhook) any
- // FormFields provides a function to bind the request to the form.
+ // UnmarshalForm provides a function to bind the request to the form.
// If form implements the [binding.Validator] interface, the Validate method will be called
- FormFields(bind func(form any)) FormFields
+ 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
}
-type FormFields struct {
- forms.WebhookForm
- URL string
- ContentType webhook_model.HookContentType
- Secret string
- HTTPMethod string
- Metadata any
-}
-
var webhookHandlers = []Handler{
defaultHandler{true},
defaultHandler{false},
@@ -62,6 +54,7 @@ var webhookHandlers = []Handler{
matrixHandler{},
wechatworkHandler{},
packagistHandler{},
+ sourcehut.BuildsHandler{},
}
// GetWebhookHandler return the handler for a given webhook type (nil if not found)
diff --git a/services/webhook/wechatwork.go b/services/webhook/wechatwork.go
index eff5b9b526..0329cff122 100644
--- a/services/webhook/wechatwork.go
+++ b/services/webhook/wechatwork.go
@@ -15,6 +15,7 @@ import (
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{}
@@ -23,23 +24,23 @@ func (wechatworkHandler) Type() webhook_module.HookType { return webhook_m
func (wechatworkHandler) Metadata(*webhook_model.Webhook) any { return nil }
func (wechatworkHandler) Icon(size int) template.HTML {
- return imgIcon("wechatwork.png", size)
+ return shared.ImgIcon("wechatwork.png", size)
}
-func (wechatworkHandler) FormFields(bind func(any)) FormFields {
+func (wechatworkHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
var form struct {
- forms.WebhookForm
+ forms.WebhookCoreForm
PayloadURL string `binding:"Required;ValidUrl"`
}
bind(&form)
- return FormFields{
- WebhookForm: form.WebhookForm,
- URL: form.PayloadURL,
- ContentType: webhook_model.ContentTypeJSON,
- Secret: "",
- HTTPMethod: http.MethodPost,
- Metadata: nil,
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: form.PayloadURL,
+ ContentType: webhook_model.ContentTypeJSON,
+ Secret: "",
+ HTTPMethod: http.MethodPost,
+ Metadata: nil,
}
}
@@ -203,8 +204,8 @@ func (wc wechatworkConvertor) Package(p *api.PackagePayload) (WechatworkPayload,
type wechatworkConvertor struct{}
-var _ payloadConvertor[WechatworkPayload] = wechatworkConvertor{}
+var _ shared.PayloadConvertor[WechatworkPayload] = wechatworkConvertor{}
func (wechatworkHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
- return newJSONRequest(wechatworkConvertor{}, w, t, true)
+ return shared.NewJSONRequest(wechatworkConvertor{}, w, t, true)
}
diff --git a/templates/webhook/new.tmpl b/templates/webhook/new.tmpl
index 8afdb1fa5d..a3fd89655c 100644
--- a/templates/webhook/new.tmpl
+++ b/templates/webhook/new.tmpl
@@ -36,6 +36,8 @@
{{template "webhook/new/wechatwork" .}}
{{else if eq .HookType "packagist"}}
{{template "webhook/new/packagist" .}}
+ {{else if eq .HookType "sourcehut_builds"}}
+ {{template "webhook/new/sourcehut_builds" .}}
{{end}}
{{end}}
</div>
diff --git a/templates/webhook/new/sourcehut_builds.tmpl b/templates/webhook/new/sourcehut_builds.tmpl
new file mode 100644
index 0000000000..1d6333fe79
--- /dev/null
+++ b/templates/webhook/new/sourcehut_builds.tmpl
@@ -0,0 +1,33 @@
+<p>{{ctx.Locale.Tr "repo.settings.add_web_hook_desc" "https://sourcehut.org/" (ctx.Locale.Tr "repo.settings.web_hook_name_sourcehut_builds")}}</p>
+<form class="ui form" action="{{.BaseLink}}/{{or .Webhook.ID "sourcehut_builds/new"}}" method="post">
+ {{.CsrfTokenHtml}}
+ <div class="required field {{if .Err_PayloadURL}}error{{end}}">
+ <label for="payload_url">{{ctx.Locale.Tr "repo.settings.sourcehut_builds.graphql_url"}}</label>
+ <input id="payload_url" name="payload_url" type="url" value="{{.Webhook.URL}}" autofocus required>
+ </div>
+ <div class="required field {{if .Err_ManifestPath}}error{{end}}">
+ <label for="manifest_path">{{ctx.Locale.Tr "repo.settings.sourcehut_builds.manifest_path"}}</label>
+ <input id="manifest_path" name="manifest_path" type="text" value="{{.HookMetadata.ManifestPath}}" required>
+ </div>
+ <div class="field">
+ <label>{{ctx.Locale.Tr "repo.settings.sourcehut_builds.visibility"}}</label>
+ <div class="ui selection dropdown">
+ <input type="hidden" id="visibility" name="visibility" value="{{if .HookMetadata.Visibility}}{{.HookMetadata.Visibility}}{{else}}PRIVATE{{end}}">
+ <div class="default text"></div>
+ {{svg "octicon-triangle-down" 14 "dropdown icon"}}
+ <div class="menu">
+ <div class="item" data-value="PUBLIC">PUBLIC</div>
+ <div class="item" data-value="UNLISTED">UNLISTED</div>
+ <div class="item" data-value="PRIVATE">PRIVATE</div>
+ </div>
+ </div>
+ </div>
+ <div class="field">
+ <div class="ui checkbox">
+ <input name="secrets" type="checkbox" {{if .HookMetadata.Secrets}}checked{{end}}>
+ <label>{{ctx.Locale.Tr "repo.settings.sourcehut_builds.secrets"}}</label>
+ <span class="help">{{ctx.Locale.Tr "repo.settings.sourcehut_builds.secrets_helper"}}</span>
+ </div>
+ </div>
+ {{template "repo/settings/webhook/settings" .}}
+</form>
diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go
index 15da511758..3375c0f1ed 100644
--- a/tests/integration/repo_webhook_test.go
+++ b/tests/integration/repo_webhook_test.go
@@ -238,6 +238,34 @@ func TestWebhookForms(t *testing.T) {
"branch_filter": "packagist/*",
"authorization_header": "Bearer 123456",
}))
+
+ t.Run("sourcehut_builds/required", testWebhookForms("sourcehut_builds", session, map[string]string{
+ "payload_url": "https://sourcehut_builds.example.com",
+ "manifest_path": ".build.yml",
+ "visibility": "PRIVATE",
+ "authorization_header": "Bearer 123456",
+ }, map[string]string{
+ "authorization_header": "",
+ }, map[string]string{
+ "authorization_header": "token ",
+ }, map[string]string{
+ "manifest_path": "",
+ }, map[string]string{
+ "manifest_path": "/absolute",
+ }, map[string]string{
+ "visibility": "",
+ }, map[string]string{
+ "visibility": "INVALID",
+ }))
+ t.Run("sourcehut_builds/optional", testWebhookForms("sourcehut_builds", session, map[string]string{
+ "payload_url": "https://sourcehut_builds.example.com",
+ "manifest_path": ".build.yml",
+ "visibility": "PRIVATE",
+ "secrets": "on",
+
+ "branch_filter": "srht/*",
+ "authorization_header": "Bearer 123456",
+ }))
}
func assertInput(t testing.TB, form *goquery.Selection, name string) string {
@@ -247,7 +275,15 @@ func assertInput(t testing.TB, form *goquery.Selection, name string) string {
t.Log(form.Html())
t.Errorf("field <input name=%q /> found %d times, expected once", name, input.Length())
}
- return input.AttrOr("value", "")
+ switch input.AttrOr("type", "") {
+ case "checkbox":
+ if _, checked := input.Attr("checked"); checked {
+ return "on"
+ }
+ return ""
+ default:
+ return input.AttrOr("value", "")
+ }
}
func testWebhookForms(name string, session *TestSession, validFields map[string]string, invalidPatches ...map[string]string) func(t *testing.T) {