233 lines
6.8 KiB
Go
233 lines
6.8 KiB
Go
// Package e2emutate contains narrowly scoped Forgejo fixture mutations.
|
|
package e2emutate
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
fixtureOwner = "test-org-2"
|
|
fixturePrefix = "maidn-e2e-"
|
|
maxBodyBytes = 1 << 20
|
|
)
|
|
|
|
var (
|
|
forgejoName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
|
gitSHA = regexp.MustCompile(`^[0-9a-fA-F]{40}([0-9a-fA-F]{24})?$`)
|
|
)
|
|
|
|
// Options identifies the only Forgejo resources this command may mutate.
|
|
type Options struct {
|
|
ForgejoURL string
|
|
Owner string
|
|
Repo string
|
|
Branch string
|
|
SHA string
|
|
Token string
|
|
OpenPR bool
|
|
}
|
|
|
|
// HTTPDoer is the Forgejo API boundary and can be faked in tests.
|
|
type HTTPDoer interface {
|
|
Do(*http.Request) (*http.Response, error)
|
|
}
|
|
|
|
type Mutator struct {
|
|
HTTP HTTPDoer
|
|
}
|
|
|
|
func ReadToken(environment, path string) (string, error) {
|
|
if environment != "" && path != "" {
|
|
return "", errors.New("use only one Forgejo token reference")
|
|
}
|
|
var token string
|
|
if environment != "" {
|
|
var present bool
|
|
token, present = os.LookupEnv(environment)
|
|
if !present {
|
|
return "", errors.New("Forgejo token environment variable is not set")
|
|
}
|
|
} else if path != "" {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", errors.New("read Forgejo token file")
|
|
}
|
|
token = string(data)
|
|
} else {
|
|
return "", errors.New("a Forgejo token environment or file reference is required")
|
|
}
|
|
token = strings.TrimSpace(token)
|
|
if token == "" || strings.ContainsAny(token, "\r\n") {
|
|
return "", errors.New("Forgejo token reference is empty or invalid")
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
func (o Options) Validate() error {
|
|
if err := validForgejoURL(o.ForgejoURL); err != nil {
|
|
return err
|
|
}
|
|
if o.Owner != fixtureOwner {
|
|
return errors.New("Forgejo mutation owner must be test-org-2")
|
|
}
|
|
for _, value := range []string{o.Repo, o.Branch} {
|
|
if !fixtureName(value) {
|
|
return errors.New("Forgejo mutation repository and branch must be maidn-e2e fixture identifiers")
|
|
}
|
|
}
|
|
if !gitSHA.MatchString(o.SHA) {
|
|
return errors.New("Forgejo mutation SHA must be a full Git object ID")
|
|
}
|
|
if o.Token == "" || strings.ContainsAny(o.Token, "\r\n") {
|
|
return errors.New("Forgejo token is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func fixtureName(value string) bool {
|
|
return strings.HasPrefix(value, fixturePrefix) && forgejoName.MatchString(value) && !strings.Contains(value, "..") && !strings.HasSuffix(value, ".") && !strings.HasSuffix(value, ".lock")
|
|
}
|
|
|
|
func validForgejoURL(value string) error {
|
|
parsed, err := url.Parse(value)
|
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return errors.New("Forgejo URL must be a credential-free HTTP(S) URL without query or fragment")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Run updates one fixture branch and may ensure its single PR to main.
|
|
func (m Mutator) Run(ctx context.Context, options Options) error {
|
|
if err := options.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if m.HTTP == nil {
|
|
return errors.New("Forgejo mutation API is required")
|
|
}
|
|
if err := m.updateRef(ctx, options); err != nil {
|
|
return err
|
|
}
|
|
if options.OpenPR {
|
|
return m.ensurePR(ctx, options)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m Mutator) updateRef(ctx context.Context, options Options) error {
|
|
endpoint := options.apiURL("git", "refs", "heads", options.Branch)
|
|
status, err := m.request(ctx, options, http.MethodPatch, endpoint, struct {
|
|
SHA string `json:"sha"`
|
|
Force bool `json:"force"`
|
|
}{SHA: options.SHA})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status >= http.StatusOK && status < http.StatusMultipleChoices {
|
|
return nil
|
|
}
|
|
if status != http.StatusNotFound {
|
|
return errors.New("Forgejo branch update failed")
|
|
}
|
|
status, err = m.request(ctx, options, http.MethodPost, options.apiURL("git", "refs"), struct {
|
|
Ref string `json:"ref"`
|
|
SHA string `json:"sha"`
|
|
}{Ref: "refs/heads/" + options.Branch, SHA: options.SHA})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status < http.StatusOK || status >= http.StatusMultipleChoices {
|
|
return errors.New("Forgejo branch creation failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m Mutator) ensurePR(ctx context.Context, options Options) error {
|
|
endpoint, _ := url.Parse(options.apiURL("pulls"))
|
|
endpoint.RawQuery = url.Values{"state": {"open"}, "head": {options.Owner + ":" + options.Branch}}.Encode()
|
|
request, err := m.newRequest(ctx, options, http.MethodGet, endpoint.String(), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
response, err := m.HTTP.Do(request)
|
|
if err != nil {
|
|
return errors.New("Forgejo mutation request failed")
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
return errors.New("Forgejo pull request lookup failed")
|
|
}
|
|
var pulls []json.RawMessage
|
|
if json.NewDecoder(io.LimitReader(response.Body, maxBodyBytes)).Decode(&pulls) != nil {
|
|
return errors.New("Forgejo pull request lookup returned invalid data")
|
|
}
|
|
if len(pulls) > 1 {
|
|
return errors.New("multiple open Forgejo pull requests exist for the fixture branch")
|
|
}
|
|
if len(pulls) == 1 {
|
|
return nil
|
|
}
|
|
status, err := m.request(ctx, options, http.MethodPost, options.apiURL("pulls"), struct {
|
|
Title string `json:"title"`
|
|
Head string `json:"head"`
|
|
Base string `json:"base"`
|
|
}{Title: "maidn e2e mutation", Head: options.Branch, Base: "main"})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status < http.StatusOK || status >= http.StatusMultipleChoices {
|
|
return errors.New("Forgejo pull request creation failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m Mutator) request(ctx context.Context, options Options, method, endpoint string, body any) (int, error) {
|
|
request, err := m.newRequest(ctx, options, method, endpoint, body)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
response, err := m.HTTP.Do(request)
|
|
if err != nil {
|
|
return 0, errors.New("Forgejo mutation request failed")
|
|
}
|
|
defer response.Body.Close()
|
|
return response.StatusCode, nil
|
|
}
|
|
|
|
func (m Mutator) newRequest(ctx context.Context, options Options, method, endpoint string, body any) (*http.Request, error) {
|
|
var reader io.Reader
|
|
if body != nil {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, errors.New("encode Forgejo mutation request")
|
|
}
|
|
reader = bytes.NewReader(data)
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
|
if err != nil {
|
|
return nil, errors.New("create Forgejo mutation request")
|
|
}
|
|
request.Header.Set("Authorization", "token "+options.Token)
|
|
if body != nil {
|
|
request.Header.Set("Content-Type", "application/json")
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func (o Options) apiURL(parts ...string) string {
|
|
return strings.TrimRight(o.ForgejoURL, "/") + "/api/v1/repos/" + o.Owner + "/" + o.Repo + "/" + strings.Join(parts, "/")
|
|
}
|
|
|
|
func DefaultMutator() Mutator {
|
|
return Mutator{HTTP: &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
|
}
|