maidn-cli/internal/e2emutate/mutate.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 }}}
}