9de9dffab1
- add issue close and issue reopen subcommands - add GetIssue and SetIssueState to the Gitea client - fail when the issue is already in the requested state - document the new subcommands in README.md and AGENTS.md
413 lines
14 KiB
Go
413 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 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
|
|
}
|
|
|
|
// 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
|
|
}
|