[FEAT] sourcehut webhooks

This commit is contained in:
oliverpool 2024-03-13 16:49:48 +01:00
parent 04a398a1af
commit ed9dd0e62a
28 changed files with 890 additions and 13 deletions

View file

@ -115,6 +115,16 @@ type Repository struct {
RepoTransfer *RepoTransfer `json:"repo_transfer"` 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 // CreateRepoOption options when creating repository
// swagger:model // swagger:model
type CreateRepoOption struct { type CreateRepoOption struct {

View file

@ -73,18 +73,19 @@ type HookType = string
// Types of webhooks // Types of webhooks
const ( const (
FORGEJO HookType = "forgejo" FORGEJO HookType = "forgejo"
GITEA HookType = "gitea" GITEA HookType = "gitea"
GOGS HookType = "gogs" GOGS HookType = "gogs"
SLACK HookType = "slack" SLACK HookType = "slack"
DISCORD HookType = "discord" DISCORD HookType = "discord"
DINGTALK HookType = "dingtalk" DINGTALK HookType = "dingtalk"
TELEGRAM HookType = "telegram" TELEGRAM HookType = "telegram"
MSTEAMS HookType = "msteams" MSTEAMS HookType = "msteams"
FEISHU HookType = "feishu" FEISHU HookType = "feishu"
MATRIX HookType = "matrix" MATRIX HookType = "matrix"
WECHATWORK HookType = "wechatwork" WECHATWORK HookType = "wechatwork"
PACKAGIST HookType = "packagist" PACKAGIST HookType = "packagist"
SOURCEHUT_BUILDS HookType = "sourcehut_builds" //nolint:revive
) )
// HookStatus is the status of a web hook // HookStatus is the status of a web hook

View file

@ -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. 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] [user]
change_avatar = Change your avatar… change_avatar = Change your avatar…
joined_on = Joined on %s 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.remove_team_success = The team's access to the repository has been removed.
settings.add_webhook = Add webhook 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_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.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 = Remove webhook
settings.webhook_deletion_desc = Removing a webhook deletes its settings and delivery history. Continue? 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_username = Packagist username
settings.packagist_api_token = API token settings.packagist_api_token = API token
settings.packagist_package_url = Packagist package URL 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.deploy_keys = Deploy keys
settings.add_deploy_key = Add deploy key settings.add_deploy_key = Add deploy key
settings.deploy_key_desc = Deploy keys have read-only pull access to the repository. settings.deploy_key_desc = Deploy keys have read-only pull access to the repository.

View file

@ -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>

After

Width:  |  Height:  |  Size: 345 B

View file

@ -9,6 +9,7 @@ import (
"crypto/sha1" "crypto/sha1"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -19,6 +20,8 @@ import (
webhook_module "code.gitea.io/gitea/modules/webhook" 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 // PayloadConvertor defines the interface to convert system payload to webhook payload
type PayloadConvertor[T any] interface { type PayloadConvertor[T any] interface {
Create(*api.CreatePayload) (T, error) Create(*api.CreatePayload) (T, error)

View file

@ -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)
}

View file

@ -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
}

View file

@ -0,0 +1 @@
ref: refs/heads/main

View file

@ -0,0 +1,4 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true

View file

@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View file

@ -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]
# *~

View file

@ -0,0 +1 @@
x•NKjÃ0ìZ§xûBÑçɶ ”¬z<C2AC>ççQã[FQÚ?"=A3óѲmk#ÏüÒ*@š²L3&²)ú”'D$ #²Î’ƒ æ<>Š½Ñ¼,#/³„Ov‰ƒzIN<Áu'¨‘[;—JŸ¥~á»Ð{þ#'Üe;.xëòƒÜè¼#[K¯Ö[kôy¯áßASq\DAìkƵÑïÚÎÔûúØ<C3BA>~P¯kÙÍÂVO<56>

View file

@ -0,0 +1,2 @@
x=<3D>Α0D=χ+φnBΊX<>ΓΙ<CE93>hιVk¨%¥?_PγαΝΜ”b °ΗCΙΜ Ή±Dδ{΄
µF&«”q®λ™m¥“Β<Κ5e8§|α[‚ΑΓΘ/—™« O€„5¶¤ GYK)¦Ο\α iOΞKJ3 —PΖ<50>ηjρΖU><3E>έVΣΟΫXΓήΡάƒηµ<CEB7>7\p;Ό

View file

@ -0,0 +1 @@
x=ŽÍnà „{æ)ö^ÉZ ,EUN}<7D>ï&T¶A„¶yüÒõ6ßa¾™Tö=w˜­ê<7F>ˆÌŽÄ¢5çO ²m\¼uFT¥ÆG¼×ˆF;ƒ¦˜NQ¬^“[£ÕÖ“a“QôÞo¥ÁkiW~+pßpáíuãià h¯ça²ˆðŒ3¢J?÷:7([þàVKÙà|ÍýòÍ™ÛT…ÖIÚ7 ÿëªÆu£Ä°Ó…ï>s¿ÁPŽ½ Û=—C}Ë¢O»

View file

@ -0,0 +1 @@
x=ЋKnГ0 D»Ц)ё`иkЙ@PdХ{P2™Ё°-AQїк] АYјЗIeЯsmэKoDђЖ)¤™µґ8Ѕp gg44вlЉИFССБп•”F9ѓВ<D193>жИV­,“[ЈО¤`~ф[iрVЪ•Ю њщчёРчєС4к+(Їф0Y)б$µ”"эМлФ lщ“Z-eѓу5чЛwПФ¦КёNЬюЩY»?V4Є&ЏМtпрИэC9ю=aШов ™,PЎ

View file

@ -0,0 +1,4 @@
xENInÃ0 ìY¯Ð»®—D§þ#È<>¢ Û<>,
"$¿¯<C2BF>¦É\fÁ™9ئ9~,+Lä-œã’¶»É€×=g<13>ô#ÿ&¯OUäÐoß·³jöU!Î,ê¿êº®”DGP¨
e>L¹Š·ç¡t[
§•’þŽ”#?¼ÝßCú~² zà2!,¤¯qCtÔQëZ<<3C>.@78Âö»¾ïŒù\«I

View file

@ -0,0 +1 @@
69b217caa89166a02b8cd368b64fb83a44720e14

View file

@ -25,6 +25,7 @@ import (
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
webhook_module "code.gitea.io/gitea/modules/webhook" webhook_module "code.gitea.io/gitea/modules/webhook"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/webhook/sourcehut"
"github.com/gobwas/glob" "github.com/gobwas/glob"
) )
@ -53,6 +54,7 @@ var webhookHandlers = []Handler{
matrixHandler{}, matrixHandler{},
wechatworkHandler{}, wechatworkHandler{},
packagistHandler{}, packagistHandler{},
sourcehut.BuildsHandler{},
} }
// GetWebhookHandler return the handler for a given webhook type (nil if not found) // GetWebhookHandler return the handler for a given webhook type (nil if not found)

View file

@ -36,6 +36,8 @@
{{template "webhook/new/wechatwork" .}} {{template "webhook/new/wechatwork" .}}
{{else if eq .HookType "packagist"}} {{else if eq .HookType "packagist"}}
{{template "webhook/new/packagist" .}} {{template "webhook/new/packagist" .}}
{{else if eq .HookType "sourcehut_builds"}}
{{template "webhook/new/sourcehut_builds" .}}
{{end}} {{end}}
{{end}} {{end}}
</div> </div>

View file

@ -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>

View file

@ -238,6 +238,34 @@ func TestWebhookForms(t *testing.T) {
"branch_filter": "packagist/*", "branch_filter": "packagist/*",
"authorization_header": "Bearer 123456", "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 { 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.Log(form.Html())
t.Errorf("field <input name=%q /> found %d times, expected once", name, input.Length()) 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) { func testWebhookForms(name string, session *TestSession, validFields map[string]string, invalidPatches ...map[string]string) func(t *testing.T) {