package gitea import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) // Client is an authenticated Gitea REST client scoped to a single token. type Client struct { baseURL string token string http *http.Client } // NewClient builds a client for baseURL (e.g. https://git.unkin.net) using the // given API token. func NewClient(baseURL, token string) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), token: token, http: &http.Client{Timeout: 30 * time.Second}, } } // SetHTTPClient overrides the underlying HTTP client (used in tests). func (c *Client) SetHTTPClient(h *http.Client) { c.http = h } func (c *Client) get(ctx context.Context, path string, query url.Values, out any) error { u := c.baseURL + "/api/v1" + path if len(query) > 0 { u += "?" + query.Encode() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return err } req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("GET %s: %w", path, err) } defer func() { _ = resp.Body.Close() }() body, _ := io.ReadAll(resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("GET %s: status %d: %s", path, resp.StatusCode, strings.TrimSpace(string(body))) } if out == nil { return nil } if err := json.Unmarshal(body, out); err != nil { return fmt.Errorf("decoding %s response: %w", path, err) } return nil } // splitRepo splits "owner/name" into its parts. func splitRepo(repo string) (owner, name string, err error) { parts := strings.SplitN(strings.Trim(repo, "/"), "/", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return "", "", fmt.Errorf("invalid repo %q, want owner/name", repo) } return parts[0], parts[1], nil } // ListIssues returns open issues (excluding pull requests) updated since the // given time. A zero time returns all open issues. func (c *Client) ListIssues(ctx context.Context, repo string, since time.Time) ([]Issue, error) { owner, name, err := splitRepo(repo) if err != nil { return nil, err } q := url.Values{} q.Set("type", "issues") q.Set("state", "open") q.Set("limit", "50") if !since.IsZero() { q.Set("since", since.UTC().Format(time.RFC3339)) } var issues []Issue if err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/issues", owner, name), q, &issues); err != nil { return nil, err } // Defensive: the API should exclude PRs given type=issues, but drop any // that slip through. out := issues[:0] for _, i := range issues { if !i.IsPull() { out = append(out, i) } } return out, nil } // ListPulls returns open pull requests for a repo, most-recently-updated first. func (c *Client) ListPulls(ctx context.Context, repo string) ([]PullRequest, error) { owner, name, err := splitRepo(repo) if err != nil { return nil, err } q := url.Values{} q.Set("state", "open") q.Set("sort", "recentupdate") q.Set("limit", "50") var pulls []PullRequest if err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/pulls", owner, name), q, &pulls); err != nil { return nil, err } return pulls, nil } // ListComments returns issue/PR comments across a repo updated since the given // time. Gitea's repo-level comments endpoint covers both issues and PRs. func (c *Client) ListComments(ctx context.Context, repo string, since time.Time) ([]Comment, error) { owner, name, err := splitRepo(repo) if err != nil { return nil, err } q := url.Values{} q.Set("limit", "50") if !since.IsZero() { q.Set("since", since.UTC().Format(time.RFC3339)) } var comments []Comment if err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/issues/comments", owner, name), q, &comments); err != nil { return nil, err } return comments, nil } // GetIssueComments returns all comments on a single issue or PR (by index), // used to build follow-up thread context. func (c *Client) GetIssueComments(ctx context.Context, repo string, index int64) ([]Comment, error) { owner, name, err := splitRepo(repo) if err != nil { return nil, err } var comments []Comment path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, name, index) if err := c.get(ctx, path, nil, &comments); err != nil { return nil, err } return comments, nil } // GetIssue fetches a single issue (or PR, which Gitea also serves here) by index. func (c *Client) GetIssue(ctx context.Context, repo string, index int64) (Issue, error) { owner, name, err := splitRepo(repo) if err != nil { return Issue{}, err } var issue Issue path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, name, index) if err := c.get(ctx, path, nil, &issue); err != nil { return Issue{}, err } return issue, nil } // GetPull fetches a single pull request by index. func (c *Client) GetPull(ctx context.Context, repo string, index int64) (PullRequest, error) { owner, name, err := splitRepo(repo) if err != nil { return PullRequest{}, err } var pr PullRequest path := fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, name, index) if err := c.get(ctx, path, nil, &pr); err != nil { return PullRequest{}, err } return pr, nil } // GetPullDiff fetches the unified diff of a pull request for review context. func (c *Client) GetPullDiff(ctx context.Context, repo string, index int64) (string, error) { owner, name, err := splitRepo(repo) if err != nil { return "", err } u := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.baseURL, owner, name, index) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return "", err } req.Header.Set("Authorization", "token "+c.token) resp, err := c.http.Do(req) if err != nil { return "", err } defer func() { _ = resp.Body.Close() }() body, _ := io.ReadAll(resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", fmt.Errorf("GET pull diff: status %d", resp.StatusCode) } return string(body), nil } // IssueIndexFromCommentURL extracts the issue/PR index from a comment's // issue_url or pull_request_url (e.g. ".../issues/42" -> 42). func IssueIndexFromCommentURL(c Comment) (int64, bool) { raw := c.IssueURL if raw == "" { raw = c.PRURL } if raw == "" { return 0, false } parts := strings.Split(strings.TrimRight(raw, "/"), "/") if len(parts) == 0 { return 0, false } n, err := strconv.ParseInt(parts[len(parts)-1], 10, 64) if err != nil { return 0, false } return n, true }