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//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 } // 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 } // 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 }