package gitea import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "gitea-codex-bot/internal/config" "gitea-codex-bot/internal/domain" ) type Client struct { baseURL, token string httpClient *http.Client } func NewClient(settings config.Settings) *Client { return &Client{baseURL: strings.TrimRight(settings.GiteaBaseURL, "/"), token: settings.GiteaToken, httpClient: &http.Client{Timeout: 20 * time.Second}} } func (c *Client) request(ctx context.Context, method, path string, body any) ([]byte, int, error) { var reader io.Reader if body != nil { data, err := json.Marshal(body) if err != nil { return nil, 0, err } reader = strings.NewReader(string(data)) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) if err != nil { return nil, 0, err } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "token "+c.token) resp, err := c.httpClient.Do(req) if err != nil { return nil, 0, err } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if err != nil { return nil, resp.StatusCode, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return data, resp.StatusCode, fmt.Errorf("gitea returned HTTP %d", resp.StatusCode) } return data, resp.StatusCode, nil } func splitRepo(repo string) (string, string, error) { owner, name, ok := strings.Cut(repo, "/") if !ok || owner == "" || name == "" { return "", "", fmt.Errorf("invalid repository %q", repo) } return url.PathEscape(owner), url.PathEscape(name), nil } func (c *Client) GetPullRequest(ctx context.Context, repo string, number int) (domain.PullRequestContext, error) { owner, name, err := splitRepo(repo) if err != nil { return domain.PullRequestContext{}, err } data, _, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, name, number), nil) if err != nil { return domain.PullRequestContext{}, err } var p struct { HTMLURL string `json:"html_url"` Base struct { Ref, SHA string Repo struct { CloneURL string `json:"clone_url"` FullName string `json:"full_name"` } `json:"repo"` } `json:"base"` Head struct { Ref, SHA string Repo struct { CloneURL string `json:"clone_url"` FullName string `json:"full_name"` } `json:"repo"` } `json:"head"` } if err := json.Unmarshal(data, &p); err != nil { return domain.PullRequestContext{}, err } if p.Base.SHA == "" || p.Head.SHA == "" || p.Base.Repo.CloneURL == "" || p.Head.Repo.CloneURL == "" { return domain.PullRequestContext{}, fmt.Errorf("gitea pull request response missing required fields") } return domain.PullRequestContext{Repo: repo, PRNumber: number, BaseRef: p.Base.Ref, BaseSHA: p.Base.SHA, HeadRef: p.Head.Ref, HeadSHA: p.Head.SHA, CloneURL: p.Head.Repo.CloneURL, BaseCloneURL: p.Base.Repo.CloneURL, HeadCloneURL: p.Head.Repo.CloneURL, HTMLURL: p.HTMLURL, IsFork: p.Base.Repo.FullName != p.Head.Repo.FullName}, nil } func (c *Client) GetFileContent(ctx context.Context, repo, path, ref string) (string, bool, error) { owner, name, err := splitRepo(repo) if err != nil { return "", false, err } data, status, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?ref=%s", owner, name, url.PathEscape(path), url.QueryEscape(ref)), nil) if err != nil && status == http.StatusNotFound { return "", false, nil } if err != nil { return "", false, err } var p struct { Content string `json:"content"` Encoding string `json:"encoding"` } if err := json.Unmarshal(data, &p); err != nil { return "", false, err } if p.Encoding != "base64" || p.Content == "" { return "", false, nil } decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(p.Content, "\n", "")) if err != nil { return "", false, err } return string(decoded), true, nil } func (c *Client) PostIssueComment(ctx context.Context, repo string, number int, body string) (int64, error) { owner, name, err := splitRepo(repo) if err != nil { return 0, err } data, _, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, name, number), map[string]string{"body": body}) if err != nil { return 0, err } var p struct { ID int64 `json:"id"` } if err := json.Unmarshal(data, &p); err != nil { return 0, err } if p.ID <= 0 { return 0, fmt.Errorf("gitea comment response missing a positive id") } return p.ID, nil } func (c *Client) EditIssueComment(ctx context.Context, repo string, commentID int64, body string) (int64, error) { owner, name, err := splitRepo(repo) if err != nil { return 0, err } data, _, err := c.request(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d", owner, name, commentID), map[string]string{"body": body}) if err != nil { return 0, err } var p struct { ID int64 `json:"id"` } if err := json.Unmarshal(data, &p); err != nil { return 0, err } if p.ID <= 0 { return 0, fmt.Errorf("gitea comment response missing a positive id") } return p.ID, nil } func (c *Client) GetIssueComments(ctx context.Context, repo string, number int) ([]map[string]any, error) { owner, name, err := splitRepo(repo) if err != nil { return nil, err } data, _, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, name, number), nil) if err != nil { return nil, err } var p []map[string]any if err := json.Unmarshal(data, &p); err != nil { return nil, err } return p, nil } func (c *Client) GetIssueComment(ctx context.Context, repo string, commentID int64) (map[string]any, error) { owner, name, err := splitRepo(repo) if err != nil { return nil, err } data, _, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d", owner, name, commentID), nil) if err != nil { return nil, err } var p map[string]any if err := json.Unmarshal(data, &p); err != nil { return nil, err } return p, nil }