// Package gitea is the small slice of the Gitea API repospawner needs: does a // repository config file already exist, open a pull request, and follow that // pull request to its merge. package gitea import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) // TokenFunc supplies a Gitea token. force asks for a freshly minted one, which // the client requests exactly once per call after a 401. type TokenFunc func(ctx context.Context, force bool) (string, error) // Client talks to one Gitea instance. type Client struct { BaseURL string Token TokenFunc HTTP *http.Client } // New builds a Client with a bounded HTTP client. func New(baseURL string, token TokenFunc) *Client { return &Client{ BaseURL: strings.TrimSuffix(baseURL, "/"), Token: token, HTTP: &http.Client{Timeout: 30 * time.Second}, } } // Repo is the subset of a repository record repospawner uses. ID is the forge // remote id Woodpecker enablement needs. type Repo struct { ID int64 `json:"id"` FullName string `json:"full_name"` } // PullRequest is the subset of a pull request record repospawner uses. type PullRequest struct { Number int `json:"number"` HTMLURL string `json:"html_url"` State string `json:"state"` Merged bool `json:"merged"` } // Repo fetches a repository by "owner/name". func (c *Client) Repo(ctx context.Context, repo string) (Repo, error) { var out Repo err := c.do(ctx, http.MethodGet, "/api/v1/repos/"+repo, nil, &out) return out, err } // FileExists reports whether path exists on ref in repo. A 404 is the answer // "no", not an error; anything else is an error, so a broken forge can never be // mistaken for a free name. func (c *Client) FileExists(ctx context.Context, repo, path, ref string) (bool, error) { ep := "/api/v1/repos/" + repo + "/contents/" + escapePath(path) if ref != "" { ep += "?ref=" + url.QueryEscape(ref) } err := c.do(ctx, http.MethodGet, ep, nil, nil) var se *StatusError if errors.As(err, &se) && se.Status == http.StatusNotFound { return false, nil } if err != nil { return false, err } return true, nil } // CreateBranch branches newBranch off oldRef. An existing branch is not an // error: a retried Job must be able to finish what its predecessor started. func (c *Client) CreateBranch(ctx context.Context, repo, newBranch, oldRef string) error { payload := map[string]string{"new_branch_name": newBranch, "old_ref_name": oldRef} err := c.do(ctx, http.MethodPost, "/api/v1/repos/"+repo+"/branches", payload, nil) var se *StatusError if errors.As(err, &se) && se.Status == http.StatusConflict { return nil } return err } // CreateFile writes a new file on branch. content is raw bytes; Gitea wants it // base64-encoded. func (c *Client) CreateFile(ctx context.Context, repo, path, branch, message string, content []byte) error { payload := map[string]string{ "branch": branch, "content": base64.StdEncoding.EncodeToString(content), "message": message, } return c.do(ctx, http.MethodPost, "/api/v1/repos/"+repo+"/contents/"+escapePath(path), payload, nil) } // CreatePullRequest opens a pull request from head into base. func (c *Client) CreatePullRequest(ctx context.Context, repo, head, base, title, body string) (PullRequest, error) { payload := map[string]string{"head": head, "base": base, "title": title, "body": body} var out PullRequest err := c.do(ctx, http.MethodPost, "/api/v1/repos/"+repo+"/pulls", payload, &out) return out, err } // PullRequest fetches one pull request by index. func (c *Client) PullRequest(ctx context.Context, repo string, number int) (PullRequest, error) { var out PullRequest err := c.do(ctx, http.MethodGet, "/api/v1/repos/"+repo+"/pulls/"+strconv.Itoa(number), nil, &out) return out, err } // StatusError carries a non-2xx response. type StatusError struct { Status int Method string Path string Body string } func (e *StatusError) Error() string { msg := fmt.Sprintf("gitea %s %s: status %d", e.Method, e.Path, e.Status) if e.Body != "" { msg += ": " + e.Body } return msg } // do issues one API call, retrying exactly once with a freshly minted token // when the forge answers 401 — dynamic Gitea credentials expire after about an // hour and the watch job outlives that. func (c *Client) do(ctx context.Context, method, path string, in, out any) error { var body []byte if in != nil { var err error if body, err = json.Marshal(in); err != nil { return err } } resp, err := c.attempt(ctx, method, path, body, false) if err != nil { return err } if resp.StatusCode == http.StatusUnauthorized { _ = resp.Body.Close() if resp, err = c.attempt(ctx, method, path, body, true); err != nil { return err } } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode > 299 { snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return &StatusError{Status: resp.StatusCode, Method: method, Path: path, Body: strings.TrimSpace(string(snippet))} } if out == nil { _, _ = io.Copy(io.Discard, resp.Body) return nil } return json.NewDecoder(resp.Body).Decode(out) } func (c *Client) attempt(ctx context.Context, method, path string, body []byte, force bool) (*http.Response, error) { var reader io.Reader if body != nil { reader = bytes.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } if c.Token != nil { token, err := c.Token(ctx, force) if err != nil { return nil, err } if token != "" { req.Header.Set("Authorization", "token "+token) } } client := c.HTTP if client == nil { client = http.DefaultClient } return client.Do(req) } // escapePath percent-escapes each path segment so a config path survives the // contents API without collapsing its separators. func escapePath(p string) string { parts := strings.Split(strings.TrimPrefix(p, "/"), "/") for i, s := range parts { parts[i] = url.PathEscape(s) } return strings.Join(parts, "/") }