Files
teabot/internal/gitea/client.go
T
unkinben 3be3f4cc46
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Fix errcheck lint findings in config, docker, and gitea
Handle Fprintf/Close/RemoveAll return values so golangci-lint passes.

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-26 23:43:30 +10:00

225 lines
6.3 KiB
Go

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
}