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 3a871b2eb8..21b157aaa0 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 @@ -2267,6 +2269,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 webhooks guide. settings.webhook_deletion = Remove webhook settings.webhook_deletion_desc = Removing a webhook deletes its settings and delivery history. Continue? @@ -2382,6 +2385,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 @@ + \ No newline at end of file diff --git a/services/webhook/shared/payloader.go b/services/webhook/shared/payloader.go index da7424dc20..cf0bfa82cb 100644 --- a/services/webhook/shared/payloader.go +++ b/services/webhook/shared/payloader.go @@ -9,6 +9,7 @@ import ( "crypto/sha1" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" "net/http" @@ -19,6 +20,8 @@ import ( 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) 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 Binary files /dev/null and b/services/webhook/sourcehut/testdata/repo.git/objects/01/16b5e2279b70e5f5b98240e8896331248f4463 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 Binary files /dev/null and b/services/webhook/sourcehut/testdata/repo.git/objects/3c/3d4b799b3933ba687b263eeef2034300a5315e 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̔bC̠D{ +;F&qm<5e8|[/ O5 GYK)\iOKJ3PƝ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\uFTGF;NQ^[֓aQokiW~+ppuiha3J?: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 Binary files /dev/null and b/services/webhook/sourcehut/testdata/repo.git/objects/9e/4b777f81b316a1c75a0797b33add68ee49b0d0 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 Binary files /dev/null and b/services/webhook/sourcehut/testdata/repo.git/objects/aa/3905af404394f576f88f00e7f0919b4b97453f 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 Binary files /dev/null and b/services/webhook/sourcehut/testdata/repo.git/objects/bd/d74dba3f34542e480d56c2a91c9e2463180d3c 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 Binary files /dev/null and b/services/webhook/sourcehut/testdata/repo.git/objects/c2/30778a03511f546f532e79c8c14917c260e750 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 Binary files /dev/null and b/services/webhook/sourcehut/testdata/repo.git/objects/c9/fa8be8c6f230e9529f8e546a3e6a354cbaf313 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=Kn0D)`k@Pd{P2-AQ]YIesmKoD)8p gg44lFQF9V,[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 @@ +xENIn0YD#ȁ ۍ, +"$\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/webhook.go b/services/webhook/webhook.go index 75962db605..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" ) @@ -53,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/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}} 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 @@ +
{{ctx.Locale.Tr "repo.settings.add_web_hook_desc" "https://sourcehut.org/" (ctx.Locale.Tr "repo.settings.web_hook_name_sourcehut_builds")}}
+ 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 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) {