Files
unkin-agent f1bcb8cd3a
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add the initial repospawner service
repospawner turns JSON new-repo requests into terraform-git pull requests
via kubernetes Jobs, follows those PRs to merge and optionally activates
the repository in Woodpecker.
2026-08-30 14:33:31 +10:00

208 lines
6.1 KiB
Go

// 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, "/")
}