54c1d868d3
A mergeable=false baseline meant a conflict could never be reported, since Gitea sends false while it recomputes after a push. Arm on a head or base SHA change as well as on a mergeable poll, break the conflict run on unknown and failed polls, and print the baseline with the conditions it suppresses.
418 lines
14 KiB
Go
418 lines
14 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. Both end a watch: neither
|
|
// a rejected credential nor a permission boundary clears itself on a retry.
|
|
func IsAuthError(err error) bool {
|
|
var apiErr *APIError
|
|
return errors.As(err, &apiErr) &&
|
|
(apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden)
|
|
}
|
|
|
|
// ErrNoCredential marks a 401/403 on a request that carried no token at all.
|
|
// Anonymous polling of a public repo is supported, so this is not a rejected
|
|
// credential: the resource simply is not public and no token was available.
|
|
var ErrNoCredential = errors.New("gitea requires authentication and no token was available")
|
|
|
|
// IsNoCredential reports whether err is an auth failure on an anonymous request.
|
|
func IsNoCredential(err error) bool {
|
|
return errors.Is(err, ErrNoCredential)
|
|
}
|
|
|
|
// credentialHints are the fragments Gitea puts in a 403 body when the
|
|
// credential itself is at fault ("token does not have at least one of required
|
|
// scope(s)", "sign in required") rather than the identity's permissions, whose
|
|
// body is a bare "Forbidden".
|
|
var credentialHints = []string{"token", "sign in", "credential"}
|
|
|
|
// IsCredentialRejected reports whether err means the credential that was sent
|
|
// was refused, which a freshly minted token may fix. A 401 always qualifies.
|
|
// Gitea 403s both for a token missing a scope and for an identity that may not
|
|
// do this at all, so for a 403 the response body decides.
|
|
func IsCredentialRejected(err error) bool {
|
|
var apiErr *APIError
|
|
if !errors.As(err, &apiErr) {
|
|
return false
|
|
}
|
|
switch apiErr.StatusCode {
|
|
case http.StatusUnauthorized:
|
|
return true
|
|
case http.StatusForbidden:
|
|
body := strings.ToLower(apiErr.Body)
|
|
for _, hint := range credentialHints {
|
|
if strings.Contains(body, hint) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsPermissionDenied reports a 403 that names no credential problem: the
|
|
// identity is authenticated but not allowed, so re-minting cannot help.
|
|
func IsPermissionDenied(err error) bool {
|
|
var apiErr *APIError
|
|
return errors.As(err, &apiErr) &&
|
|
apiErr.StatusCode == http.StatusForbidden && !IsCredentialRejected(err)
|
|
}
|
|
|
|
// 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 credential it carried was rejected, re-mints
|
|
// the token once and replays the request. Anonymous requests and permission
|
|
// denials are returned as they are: neither is fixed by a fresh token, and
|
|
// re-minting on them would report a stale token as the cause of something else.
|
|
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
|
|
}
|
|
anonymous := c.Token == ""
|
|
err := c.attempt(method, path, payload, out)
|
|
if !IsAuthError(err) {
|
|
return err
|
|
}
|
|
if anonymous {
|
|
return fmt.Errorf("%w: %w", ErrNoCredential, err)
|
|
}
|
|
if !IsCredentialRejected(err) || c.Refresh == nil {
|
|
return err
|
|
}
|
|
token, refreshErr := c.Refresh()
|
|
if refreshErr != nil {
|
|
return fmt.Errorf("%w; re-minting token: %v", err, refreshErr)
|
|
}
|
|
if token == "" {
|
|
return fmt.Errorf("%w; re-minting token yielded an empty token", err)
|
|
}
|
|
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 Mergeability `json:"mergeable"`
|
|
HTMLURL string `json:"html_url"`
|
|
Head struct {
|
|
Sha string `json:"sha"`
|
|
Ref string `json:"ref"`
|
|
Label string `json:"label"`
|
|
} `json:"head"`
|
|
// Base.Sha is the base branch tip as of this response, not the merge base
|
|
// recorded when the PR was opened.
|
|
Base struct {
|
|
Sha string `json:"sha"`
|
|
} `json:"base"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// EditOptions are the fields an edit may change, for a pull request or an
|
|
// issue alike. Pointers so an unset field is omitted from the payload
|
|
// entirely, leaving that field as it is. The two fields are not symmetric:
|
|
// Gitea only applies a title when it is non-empty, so Title can be set but
|
|
// never cleared and a "" title is a silent no-op, while a pointer to "" Body
|
|
// really does blank the body.
|
|
type EditOptions struct {
|
|
Title *string `json:"title,omitempty"`
|
|
Body *string `json:"body,omitempty"`
|
|
}
|
|
|
|
// EditPR updates a pull request's title and/or body
|
|
// (PATCH /api/v1/repos/{owner}/{repo}/pulls/{index}).
|
|
func (c *GiteaClient) EditPR(repoPath string, number int, opts EditOptions) (PullRequest, error) {
|
|
var pr PullRequest
|
|
err := c.do(http.MethodPatch, fmt.Sprintf("/api/v1/repos/%s/pulls/%d", repoPath, number), 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
|
|
}
|
|
|
|
// Issue is the subset of Gitea's issue object we track. Gitea numbers issues
|
|
// and pull requests in one sequence, so Number is comparable to a PR number.
|
|
type Issue struct {
|
|
Number int `json:"number"`
|
|
State string `json:"state"`
|
|
Title string `json:"title"`
|
|
HTMLURL string `json:"html_url"`
|
|
}
|
|
|
|
// CreateIssueOptions are the fields for filing an issue.
|
|
type CreateIssueOptions struct {
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// CreateIssue files an issue (POST /api/v1/repos/{owner}/{repo}/issues).
|
|
func (c *GiteaClient) CreateIssue(repoPath string, opts CreateIssueOptions) (Issue, error) {
|
|
var issue Issue
|
|
err := c.do(http.MethodPost, "/api/v1/repos/"+repoPath+"/issues", opts, &issue)
|
|
return issue, err
|
|
}
|
|
|
|
// EditIssue updates an issue's title and/or body
|
|
// (PATCH /api/v1/repos/{owner}/{repo}/issues/{index}).
|
|
func (c *GiteaClient) EditIssue(repoPath string, number int, opts EditOptions) (Issue, error) {
|
|
var issue Issue
|
|
err := c.do(http.MethodPatch, fmt.Sprintf("/api/v1/repos/%s/issues/%d", repoPath, number), opts, &issue)
|
|
return issue, err
|
|
}
|
|
|
|
// Issue states Gitea accepts on a state change. Gitea has no third state: an
|
|
// issue is open or closed.
|
|
const (
|
|
IssueStateOpen = "open"
|
|
IssueStateClosed = "closed"
|
|
)
|
|
|
|
// ErrIssueStateUnchanged reports a state change asked for the state the issue
|
|
// is already in. Gitea answers such a PATCH with 200 and changes nothing, so
|
|
// without this check closing an already-closed issue would look like it worked.
|
|
var ErrIssueStateUnchanged = errors.New("issue is already in that state")
|
|
|
|
// GetIssue fetches a single issue
|
|
// (GET /api/v1/repos/{owner}/{repo}/issues/{index}).
|
|
func (c *GiteaClient) GetIssue(repoPath string, number int) (Issue, error) {
|
|
var issue Issue
|
|
err := c.do(http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/issues/%d", repoPath, number), nil, &issue)
|
|
return issue, err
|
|
}
|
|
|
|
// SetIssueState closes or reopens an issue. It reads the issue first so an
|
|
// issue already in the requested state fails with ErrIssueStateUnchanged
|
|
// instead of reporting a change that never happened.
|
|
func (c *GiteaClient) SetIssueState(repoPath string, number int, state string) (Issue, error) {
|
|
if state != IssueStateOpen && state != IssueStateClosed {
|
|
return Issue{}, fmt.Errorf("invalid issue state %q: want %q or %q", state, IssueStateOpen, IssueStateClosed)
|
|
}
|
|
current, err := c.GetIssue(repoPath, number)
|
|
if err != nil {
|
|
return Issue{}, err
|
|
}
|
|
if current.State == state {
|
|
return current, fmt.Errorf("%s#%d: %w (%s)", repoPath, number, ErrIssueStateUnchanged, state)
|
|
}
|
|
var issue Issue
|
|
payload := map[string]string{"state": state}
|
|
err = c.do(http.MethodPatch, fmt.Sprintf("/api/v1/repos/%s/issues/%d", repoPath, number), payload, &issue)
|
|
return issue, 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 an issue thread
|
|
// (POST /api/v1/repos/{owner}/{repo}/issues/{n}/comments). Gitea backs a pull
|
|
// request with an issue of the same number, so this is the single path for
|
|
// both.
|
|
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
|
|
}
|