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. func isNotFound(err error) bool { var apiErr *APIError return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound } // GiteaClient talks to the Gitea REST API as the agent user. type GiteaClient struct { BaseURL string Token string HTTP *http.Client } // NewGiteaClient builds a client from the configured base URL and a Vault-minted // token. func NewGiteaClient(token string) *GiteaClient { return &GiteaClient{BaseURL: GiteaURL(), Token: token, HTTP: httpClient} } func (c *GiteaClient) do(method, path string, body any, out any) error { var reader io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return err } reader = bytes.NewReader(b) } url := strings.TrimRight(c.BaseURL, "/") + path req, err := http.NewRequest(method, url, reader) if err != nil { return err } 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"` } `json:"head"` } // 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 }