Files
agent-tools/internal/agent/gitea.go
T
unkin-agent c1c02c01cf
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Require git proof before prune deletes a branch
A merged or closed PR no longer authorises a delete on its own: HEAD must
be contained in the PR's head commit or in origin/<branch>, otherwise the
worktree goes and the branch stays. Branch deletion runs `git branch -d`
first and falls back to -D only for a proven branch.

Reword the cherry check to say patches reached the default branch's
history, print the verdict --keep-branches will actually perform, and warn
when a PR listing hits the pagination cap instead of reading it as "no PR".
2026-09-10 00:00:22 +10:00

263 lines
8.2 KiB
Go

package agent
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
// APIError is a non-2xx response from the Gitea API. It carries the status code
// so callers can react to specific failures (e.g. tolerate a 404 for a commit
// whose branch was deleted after a merge) instead of parsing error strings.
type APIError struct {
Method string
Path string
StatusCode int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("gitea %s %s: HTTP %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
}
// IsNotFound reports whether err is a Gitea 404. Gitea hides repositories a
// caller may not see behind a 404 rather than a 403, so this also covers a repo
// that was renamed, deleted, or made private.
func IsNotFound(err error) bool {
var apiErr *APIError
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound
}
// IsAuthError reports whether err is a Gitea 401/403: the token is expired or
// unauthorised, which retrying the same request cannot fix.
func IsAuthError(err error) bool {
var apiErr *APIError
return errors.As(err, &apiErr) &&
(apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden)
}
// GiteaClient talks to the Gitea REST API as the agent user.
type GiteaClient struct {
BaseURL string
Token string
HTTP *http.Client
// Refresh mints a replacement token when the current one is rejected; Vault's
// Gitea tokens expire in ~1h, far short of a watchpr run.
Refresh func() (string, error)
}
// NewGiteaClient builds a client from the configured base URL and a Vault-minted
// token, re-minting from Vault when that token expires.
func NewGiteaClient(token string) *GiteaClient {
return &GiteaClient{BaseURL: GiteaURL(), Token: token, HTTP: httpClient, Refresh: RefreshGiteaToken}
}
// do sends the request and, if the token was rejected, re-mints it once and
// replays the request with the fresh token.
func (c *GiteaClient) do(method, path string, body any, out any) error {
var payload []byte
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
payload = b
}
err := c.attempt(method, path, payload, out)
if !IsAuthError(err) || c.Refresh == nil {
return err
}
token, refreshErr := c.Refresh()
if refreshErr != nil {
return fmt.Errorf("%w; re-minting token: %v", err, refreshErr)
}
c.Token = token
return c.attempt(method, path, payload, out)
}
func (c *GiteaClient) attempt(method, path string, body []byte, out any) error {
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
url := strings.TrimRight(c.BaseURL, "/") + path
req, err := http.NewRequest(method, url, reader)
if err != nil {
return err
}
// An empty token means anonymous access, which public repos serve fine.
if c.Token != "" {
req.Header.Set("Authorization", "token "+c.Token)
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &APIError{Method: method, Path: path, StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(data))}
}
if out != nil && len(data) > 0 {
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("gitea %s %s: decoding response: %w", method, path, err)
}
}
return nil
}
// User is the subset of the Gitea user object we care about.
type User struct {
Login string `json:"login"`
ID int64 `json:"id"`
}
// Whoami returns the authenticated user (GET /api/v1/user).
func (c *GiteaClient) Whoami() (User, error) {
var u User
err := c.do(http.MethodGet, "/api/v1/user", nil, &u)
return u, err
}
// PullRequest is the subset of Gitea's PR object we track.
type PullRequest struct {
Number int `json:"number"`
State string `json:"state"`
Title string `json:"title"`
Merged bool `json:"merged"`
Mergeable bool `json:"mergeable"`
HTMLURL string `json:"html_url"`
Head struct {
Sha string `json:"sha"`
Ref string `json:"ref"`
Label string `json:"label"`
} `json:"head"`
}
// prPageSize is the per-page limit for the pulls listing; maxPRPages caps how
// far back a listing walks.
const (
prPageSize = 50
maxPRPages = 20
)
// ErrPRListTruncated reports that a listing hit the page cap, so the returned
// pull requests are only the most recent ones and older PRs went unseen.
var ErrPRListTruncated = errors.New("pull request listing truncated at the page cap")
// ListPRs lists a repo's pull requests in the given state ("open", "closed" or
// "all"), following pagination. A repo with more PRs than the page cap returns
// the PRs it did read alongside ErrPRListTruncated.
func (c *GiteaClient) ListPRs(repoPath, state string) ([]PullRequest, error) {
if state == "" {
state = "all"
}
var all []PullRequest
for page := 1; page <= maxPRPages; page++ {
var batch []PullRequest
path := fmt.Sprintf("/api/v1/repos/%s/pulls?state=%s&limit=%d&page=%d", repoPath, state, prPageSize, page)
if err := c.do(http.MethodGet, path, nil, &batch); err != nil {
return nil, err
}
all = append(all, batch...)
if len(batch) < prPageSize {
return all, nil
}
}
return all, fmt.Errorf("%s: %w after %d pull requests", repoPath, ErrPRListTruncated, len(all))
}
// PRHeadBranch returns the branch a PR was opened from. Gitea rewrites head.ref
// to "refs/pull/<n>/head" once the branch is deleted (which merging does), so
// head.label — which keeps the original name — is authoritative.
func PRHeadBranch(pr PullRequest) string {
if label := pr.Head.Label; label != "" && !strings.HasPrefix(label, "refs/pull/") {
// Cross-repo PRs label as "<owner>:<branch>".
if _, branch, ok := strings.Cut(label, ":"); ok {
return branch
}
return label
}
ref := pr.Head.Ref
if strings.HasPrefix(ref, "refs/pull/") {
return ""
}
return strings.TrimPrefix(ref, "refs/heads/")
}
// IsOpen reports whether a PR is still open (not merged, not closed).
func (pr PullRequest) IsOpen() bool {
return pr.State == "open" && !pr.Merged
}
// CreatePROptions are the fields for opening a PR.
type CreatePROptions struct {
Base string `json:"base"`
Head string `json:"head"`
Title string `json:"title"`
Body string `json:"body"`
}
// CreatePR opens a pull request (POST /api/v1/repos/{owner}/{repo}/pulls).
func (c *GiteaClient) CreatePR(repoPath string, opts CreatePROptions) (PullRequest, error) {
var pr PullRequest
err := c.do(http.MethodPost, "/api/v1/repos/"+repoPath+"/pulls", opts, &pr)
return pr, err
}
// GetPR fetches a single pull request.
func (c *GiteaClient) GetPR(repoPath string, number int) (PullRequest, error) {
var pr PullRequest
err := c.do(http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/pulls/%d", repoPath, number), nil, &pr)
return pr, err
}
// Comment is the subset of an issue comment we track.
type Comment struct {
ID int64 `json:"id"`
User User `json:"user"`
Body string `json:"body"`
}
// CreateComment posts a comment on the PR's issue thread
// (POST /api/v1/repos/{owner}/{repo}/issues/{n}/comments).
func (c *GiteaClient) CreateComment(repoPath string, number int, body string) (Comment, error) {
var cm Comment
payload := map[string]string{"body": body}
err := c.do(http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/issues/%d/comments", repoPath, number), payload, &cm)
return cm, err
}
// ListComments lists the PR's issue comments.
func (c *GiteaClient) ListComments(repoPath string, number int) ([]Comment, error) {
var out []Comment
err := c.do(http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/issues/%d/comments", repoPath, number), nil, &out)
return out, err
}
// CombinedStatus is the combined commit status for a ref.
type CombinedStatus struct {
State string `json:"state"`
}
// CommitStatus returns the combined CI status for a commit SHA
// (GET /api/v1/repos/{owner}/{repo}/commits/{sha}/status). An empty ref yields
// an empty state without an API call.
func (c *GiteaClient) CommitStatus(repoPath, sha string) (string, error) {
if sha == "" {
return "", nil
}
var cs CombinedStatus
err := c.do(http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/commits/%s/status", repoPath, sha), nil, &cs)
return cs.State, err
}