310376525b
- Use the forked [binding](https://code.forgejo.org/go-chi/binding) library. This library has two benefits, it removes the usage of `github.com/goccy/go-json` (has no benefit as the minimo library is also using it). It adds the `TrimSpace` feature, which will during the binding part trim the spaces around the value it got from the form, this is done before validation.
301 lines
9.9 KiB
Go
301 lines
9.9 KiB
Go
// 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"
|
|
|
|
"code.forgejo.org/go-chi/binding"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type BuildsHandler struct{}
|
|
|
|
func (BuildsHandler) Type() webhook_module.HookType { return webhook_module.SOURCEHUT_BUILDS }
|
|
func (BuildsHandler) Metadata(w *webhook_model.Webhook) any {
|
|
s := &BuildsMeta{}
|
|
if err := json.Unmarshal([]byte(w.Meta), s); err != nil {
|
|
log.Error("sourcehut.BuildsHandler.Metadata(%d): %v", w.ID, err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (BuildsHandler) Icon(size int) template.HTML {
|
|
return shared.ImgIcon("sourcehut.svg", size)
|
|
}
|
|
|
|
type buildsForm struct {
|
|
forms.WebhookCoreForm
|
|
PayloadURL string `binding:"Required;ValidUrl"`
|
|
ManifestPath string `binding:"Required"`
|
|
Visibility string `binding:"Required;In(PUBLIC,UNLISTED,PRIVATE)"`
|
|
Secrets bool
|
|
AccessToken string `binding:"Required"`
|
|
}
|
|
|
|
var _ binding.Validator = &buildsForm{}
|
|
|
|
// Validate implements binding.Validator.
|
|
func (f *buildsForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
|
|
ctx := gitea_context.GetWebContext(req)
|
|
if !fs.ValidPath(f.ManifestPath) {
|
|
errs = append(errs, binding.Error{
|
|
FieldNames: []string{"ManifestPath"},
|
|
Classification: "",
|
|
Message: ctx.Locale.TrString("repo.settings.add_webhook.invalid_path"),
|
|
})
|
|
}
|
|
f.AuthorizationHeader = "Bearer " + strings.TrimSpace(f.AccessToken)
|
|
return errs
|
|
}
|
|
|
|
func (BuildsHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
|
|
var form buildsForm
|
|
bind(&form)
|
|
|
|
return forms.WebhookForm{
|
|
WebhookCoreForm: form.WebhookCoreForm,
|
|
URL: form.PayloadURL,
|
|
ContentType: webhook_model.ContentTypeJSON,
|
|
Secret: "",
|
|
HTTPMethod: http.MethodPost,
|
|
Metadata: &BuildsMeta{
|
|
ManifestPath: form.ManifestPath,
|
|
Visibility: form.Visibility,
|
|
Secrets: form.Secrets,
|
|
},
|
|
}
|
|
}
|
|
|
|
type (
|
|
graphqlPayload[V any] struct {
|
|
Query string `json:"query,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Variables V `json:"variables,omitempty"`
|
|
}
|
|
// buildsVariables according to https://man.sr.ht/builds.sr.ht/graphql.md
|
|
buildsVariables struct {
|
|
Manifest string `json:"manifest"`
|
|
Tags []string `json:"tags"`
|
|
Note string `json:"note"`
|
|
Secrets bool `json:"secrets"`
|
|
Execute bool `json:"execute"`
|
|
Visibility string `json:"visibility"`
|
|
}
|
|
|
|
// BuildsMeta contains the metadata for the webhook
|
|
BuildsMeta struct {
|
|
ManifestPath string `json:"manifest_path"`
|
|
Visibility string `json:"visibility"`
|
|
Secrets bool `json:"secrets"`
|
|
}
|
|
)
|
|
|
|
type sourcehutConvertor struct {
|
|
ctx context.Context
|
|
meta BuildsMeta
|
|
}
|
|
|
|
var _ shared.PayloadConvertor[graphqlPayload[buildsVariables]] = sourcehutConvertor{}
|
|
|
|
func (BuildsHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
|
|
meta := BuildsMeta{}
|
|
if err := json.Unmarshal([]byte(w.Meta), &meta); err != nil {
|
|
return nil, nil, fmt.Errorf("newSourcehutRequest meta json: %w", err)
|
|
}
|
|
pc := sourcehutConvertor{
|
|
ctx: ctx,
|
|
meta: meta,
|
|
}
|
|
return shared.NewJSONRequest(pc, w, t, false)
|
|
}
|
|
|
|
// Create implements PayloadConvertor Create method
|
|
func (pc sourcehutConvertor) Create(p *api.CreatePayload) (graphqlPayload[buildsVariables], error) {
|
|
return pc.newPayload(p.Repo, p.Sha, p.Ref, p.RefType+" "+git.RefName(p.Ref).ShortName()+" created", true)
|
|
}
|
|
|
|
// Delete implements PayloadConvertor Delete method
|
|
func (pc sourcehutConvertor) Delete(_ *api.DeletePayload) (graphqlPayload[buildsVariables], error) {
|
|
return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
|
|
}
|
|
|
|
// Fork implements PayloadConvertor Fork method
|
|
func (pc sourcehutConvertor) Fork(_ *api.ForkPayload) (graphqlPayload[buildsVariables], error) {
|
|
return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
|
|
}
|
|
|
|
// Push implements PayloadConvertor Push method
|
|
func (pc sourcehutConvertor) Push(p *api.PushPayload) (graphqlPayload[buildsVariables], error) {
|
|
return pc.newPayload(p.Repo, p.HeadCommit.ID, p.Ref, p.HeadCommit.Message, true)
|
|
}
|
|
|
|
// Issue implements PayloadConvertor Issue method
|
|
func (pc sourcehutConvertor) Issue(_ *api.IssuePayload) (graphqlPayload[buildsVariables], error) {
|
|
return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
|
|
}
|
|
|
|
// IssueComment implements PayloadConvertor IssueComment method
|
|
func (pc sourcehutConvertor) IssueComment(_ *api.IssueCommentPayload) (graphqlPayload[buildsVariables], error) {
|
|
return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
|
|
}
|
|
|
|
// PullRequest implements PayloadConvertor PullRequest method
|
|
func (pc sourcehutConvertor) PullRequest(_ *api.PullRequestPayload) (graphqlPayload[buildsVariables], error) {
|
|
// TODO
|
|
return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
|
|
}
|
|
|
|
// Review implements PayloadConvertor Review method
|
|
func (pc sourcehutConvertor) Review(_ *api.PullRequestPayload, _ webhook_module.HookEventType) (graphqlPayload[buildsVariables], error) {
|
|
return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
|
|
}
|
|
|
|
// Repository implements PayloadConvertor Repository method
|
|
func (pc sourcehutConvertor) Repository(_ *api.RepositoryPayload) (graphqlPayload[buildsVariables], error) {
|
|
return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
|
|
}
|
|
|
|
// Wiki implements PayloadConvertor Wiki method
|
|
func (pc sourcehutConvertor) Wiki(_ *api.WikiPayload) (graphqlPayload[buildsVariables], error) {
|
|
return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
|
|
}
|
|
|
|
// Release implements PayloadConvertor Release method
|
|
func (pc sourcehutConvertor) Release(_ *api.ReleasePayload) (graphqlPayload[buildsVariables], error) {
|
|
return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
|
|
}
|
|
|
|
func (pc sourcehutConvertor) Package(_ *api.PackagePayload) (graphqlPayload[buildsVariables], error) {
|
|
return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
|
|
}
|
|
|
|
// mustBuildManifest adjusts the manifest to submit to the builds service
|
|
//
|
|
// in case of an error the Error field will be set, to be visible by the end-user under recent deliveries
|
|
func (pc sourcehutConvertor) newPayload(repo *api.Repository, commitID, ref, note string, trusted bool) (graphqlPayload[buildsVariables], error) {
|
|
manifest, err := pc.buildManifest(repo, commitID, ref)
|
|
if err != nil {
|
|
if len(manifest) == 0 {
|
|
return graphqlPayload[buildsVariables]{}, err
|
|
}
|
|
// the manifest contains an error for the user: log the actual error and construct the payload
|
|
// the error will be visible under the "recent deliveries" of the webhook settings.
|
|
log.Warn("sourcehut.builds: could not construct manifest for %s: %v", repo.FullName, err)
|
|
msg := fmt.Sprintf("%s:%s %s", repo.FullName, ref, manifest)
|
|
return graphqlPayload[buildsVariables]{
|
|
Error: msg,
|
|
}, nil
|
|
}
|
|
|
|
gitRef := git.RefName(ref)
|
|
return graphqlPayload[buildsVariables]{
|
|
Query: `mutation (
|
|
$manifest: String!
|
|
$tags: [String!]
|
|
$note: String!
|
|
$secrets: Boolean!
|
|
$execute: Boolean!
|
|
$visibility: Visibility!
|
|
) {
|
|
submit(
|
|
manifest: $manifest
|
|
tags: $tags
|
|
note: $note
|
|
secrets: $secrets
|
|
execute: $execute
|
|
visibility: $visibility
|
|
) {
|
|
id
|
|
}
|
|
}`, Variables: buildsVariables{
|
|
Manifest: string(manifest),
|
|
Tags: []string{repo.FullName, gitRef.RefType() + "/" + gitRef.ShortName(), pc.meta.ManifestPath},
|
|
Note: note,
|
|
Secrets: pc.meta.Secrets && trusted,
|
|
Execute: trusted,
|
|
Visibility: cmp.Or(pc.meta.Visibility, "PRIVATE"),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// buildManifest adjusts the manifest to submit to the builds service
|
|
// in case of an error the []byte might contain an error that can be displayed to the user
|
|
func (pc sourcehutConvertor) buildManifest(repo *api.Repository, commitID, gitRef string) ([]byte, error) {
|
|
gitRepo, err := gitrepo.OpenRepository(pc.ctx, repo)
|
|
if err != nil {
|
|
msg := "could not open repository"
|
|
return []byte(msg), fmt.Errorf(msg+": %w", err)
|
|
}
|
|
defer gitRepo.Close()
|
|
|
|
commit, err := gitRepo.GetCommit(commitID)
|
|
if err != nil {
|
|
msg := fmt.Sprintf("could not get commit %q", commitID)
|
|
return []byte(msg), fmt.Errorf(msg+": %w", err)
|
|
}
|
|
entry, err := commit.GetTreeEntryByPath(pc.meta.ManifestPath)
|
|
if err != nil {
|
|
msg := fmt.Sprintf("could not open manifest %q", pc.meta.ManifestPath)
|
|
return []byte(msg), fmt.Errorf(msg+": %w", err)
|
|
}
|
|
r, err := entry.Blob().DataAsync()
|
|
if err != nil {
|
|
msg := fmt.Sprintf("could not read manifest %q", pc.meta.ManifestPath)
|
|
return []byte(msg), fmt.Errorf(msg+": %w", err)
|
|
}
|
|
defer r.Close()
|
|
|
|
// reference: https://man.sr.ht/builds.sr.ht/manifest.md
|
|
var manifest struct {
|
|
Sources []string `yaml:"sources"`
|
|
Environment map[string]string `yaml:"environment"`
|
|
|
|
Rest map[string]yaml.Node `yaml:",inline"`
|
|
}
|
|
if err := yaml.NewDecoder(r).Decode(&manifest); err != nil {
|
|
msg := fmt.Sprintf("could not decode manifest %q", pc.meta.ManifestPath)
|
|
return []byte(msg), fmt.Errorf(msg+": %w", err)
|
|
}
|
|
|
|
if manifest.Environment == nil {
|
|
manifest.Environment = make(map[string]string)
|
|
}
|
|
manifest.Environment["BUILD_SUBMITTER"] = "forgejo"
|
|
manifest.Environment["BUILD_SUBMITTER_URL"] = setting.AppURL
|
|
manifest.Environment["GIT_REF"] = gitRef
|
|
|
|
source := repo.CloneURL + "#" + commitID
|
|
found := false
|
|
for i, s := range manifest.Sources {
|
|
if s == repo.CloneURL {
|
|
manifest.Sources[i] = source
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
manifest.Sources = append(manifest.Sources, source)
|
|
}
|
|
|
|
return yaml.Marshal(manifest)
|
|
}
|