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//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 ":". 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 } // EditPROptions are the fields an edit may change. 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 EditPROptions 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 EditPROptions) (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 } // 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 }