1b4448afb4
teabot watches Gitea repos and dispatches one-shot Claude Code sessions in Docker containers to work issues and review PRs, acting as configurable bot personalities. Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
225 lines
6.3 KiB
Go
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 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 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
|
|
}
|