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