5ac93bea70
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 15s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 47s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m49s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 2m15s
Previously safeDialContext only dialed the first resolved IP. If the connection failed, it returned an error without trying other IPs. Now it iterates all validated IPs and returns the first successful connection, or the last error if all fail. This matches the resilience behavior of a plain net.Dialer on multi-IP hostnames. Addresses review finding: safeDialContext only dials first resolved IP. All IPs are still validated before any dial attempt is made.
1026 lines
34 KiB
Go
1026 lines
34 KiB
Go
// Package gitea provides a client for the Gitea API.
|
|
// It supports pull request operations, file content retrieval,
|
|
// and review submission.
|
|
package gitea
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"math"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// APIError represents an HTTP error response from the Gitea API.
|
|
// It carries the status code so callers can distinguish between
|
|
// different failure modes (e.g. 404 vs 500).
|
|
type APIError struct {
|
|
StatusCode int
|
|
Body string
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
body := e.Body
|
|
if len(body) > 200 {
|
|
body = body[:200] + "...(truncated)"
|
|
}
|
|
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, body)
|
|
}
|
|
|
|
// IsNotFound reports whether an error is an API 404 response.
|
|
func IsNotFound(err error) bool {
|
|
var apiErr *APIError
|
|
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound
|
|
}
|
|
|
|
// IsServerError reports whether an error is an API 5xx response.
|
|
func IsServerError(err error) bool {
|
|
var apiErr *APIError
|
|
return errors.As(err, &apiErr) && apiErr.StatusCode >= 500 && apiErr.StatusCode < 600
|
|
}
|
|
|
|
// DefaultMaxDiffSize is the default maximum diff size in bytes (10 MB).
|
|
const DefaultMaxDiffSize = 10 * 1024 * 1024
|
|
|
|
// ErrDiffTooLarge is returned when a PR diff exceeds the configured MaxDiffSize.
|
|
var ErrDiffTooLarge = errors.New("diff size exceeds maximum allowed size")
|
|
|
|
// Client interacts with the Gitea API.
|
|
// A Client is safe for concurrent use by multiple goroutines.
|
|
type Client struct {
|
|
baseURL string
|
|
token string
|
|
http *http.Client
|
|
|
|
// RetryBackoff defines the delays between retry attempts.
|
|
// RetryBackoff[i] is the delay before attempt i+1 (after attempt i fails).
|
|
// If nil, defaults to {1s, 2s}. Set to shorter durations in tests.
|
|
//
|
|
// This field must be configured before the first request is made.
|
|
// Modifying it while requests are in flight is not safe.
|
|
RetryBackoff []time.Duration
|
|
|
|
// MaxDiffSize is the maximum number of bytes allowed when fetching a PR diff.
|
|
// If zero, defaults to DefaultMaxDiffSize (10 MB). Set to any negative value
|
|
// (or math.MaxInt64) to disable the limit.
|
|
//
|
|
// This field must be configured before the first request is made.
|
|
// Modifying it while requests are in flight is not safe.
|
|
MaxDiffSize int64
|
|
}
|
|
|
|
// defaultCheckRedirect is the redirect policy used by NewClient.
|
|
// NOTE: This function is intentionally duplicated in github/client.go (and vice versa)
|
|
// because the packages are separate. Changes here must be mirrored there.
|
|
// It rejects HTTPS->HTTP protocol downgrades (to prevent plaintext leakage)
|
|
// and cross-host redirects (to prevent following responses from untrusted
|
|
// endpoints). Same-host, same-or-upgraded-scheme redirects are allowed.
|
|
func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 10 {
|
|
return fmt.Errorf("stopped after 10 redirects")
|
|
}
|
|
// Guard for direct invocation in tests and any future callers;
|
|
// net/http guarantees len(via) >= 1 during actual redirects.
|
|
if len(via) == 0 {
|
|
return nil
|
|
}
|
|
prev := via[len(via)-1]
|
|
// Reject protocol downgrade: HTTPS->HTTP leaks request metadata over plaintext.
|
|
if prev.URL.Scheme == "https" && req.URL.Scheme == "http" {
|
|
return fmt.Errorf("refusing redirect: HTTPS to HTTP downgrade (%s -> %s)", prev.URL.Host, req.URL.Host)
|
|
}
|
|
// Reject cross-host redirect entirely to avoid consuming responses
|
|
// from untrusted endpoints.
|
|
if req.URL.Host != prev.URL.Host {
|
|
return fmt.Errorf("refusing redirect: cross-host (%s -> %s)", prev.URL.Host, req.URL.Host)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// safeDialContext is the default DialContext for NewClient.
|
|
// It resolves the hostname and checks every returned IP against the blocked
|
|
// CIDR list before establishing a connection. This prevents SSRF attacks
|
|
// where user-supplied URLs resolve to internal/private addresses.
|
|
//
|
|
// After validating all IPs, we dial the first resolved IP directly to avoid
|
|
// a second DNS lookup (which could return a different IP in a DNS rebinding
|
|
// attack). This narrows — but does not fully eliminate — the DNS rebinding
|
|
// window to the time between LookupIPAddr and DialContext.
|
|
//
|
|
// If the host is already an IP literal, LookupIPAddr returns it directly
|
|
// (no DNS query issued), so IP literals like https://127.0.0.1/ are blocked.
|
|
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("safeDialContext: invalid address %q: %w", addr, err)
|
|
}
|
|
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("safeDialContext: DNS lookup %q: %w", host, err)
|
|
}
|
|
if len(addrs) == 0 {
|
|
return nil, fmt.Errorf("safeDialContext: no addresses returned for %q", host)
|
|
}
|
|
for _, a := range addrs {
|
|
if IsBlockedIP(a.IP) {
|
|
return nil, fmt.Errorf("safeDialContext: blocked: %q resolves to private/reserved IP %s", host, a.IP)
|
|
}
|
|
}
|
|
// Try each resolved IP in order, returning the first successful connection.
|
|
// Fallback is important when a hostname resolves to multiple IPs and the first
|
|
// is temporarily unreachable. All IPs were already validated above, so dialing
|
|
// any of them is safe.
|
|
//
|
|
// Timeout: 10s per the design (PLAN.md); the outer http.Client has a 30s
|
|
// total timeout, but the per-dial timeout ensures a slow TCP connect on one IP
|
|
// doesn't consume the budget needed to try others.
|
|
d := &net.Dialer{Timeout: 10 * time.Second}
|
|
var lastErr error
|
|
for _, a := range addrs {
|
|
conn, err := d.DialContext(ctx, network, net.JoinHostPort(a.IP.String(), port))
|
|
if err == nil {
|
|
return conn, nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
return nil, fmt.Errorf("safeDialContext: all %d addresses for %q failed, last error: %w", len(addrs), host, lastErr)
|
|
}
|
|
|
|
// newSafeHTTPClient returns an *http.Client with the SSRF-blocking safeDialContext
|
|
// transport and the cross-host redirect rejection policy.
|
|
func newSafeHTTPClient() *http.Client {
|
|
transport := &http.Transport{
|
|
DialContext: safeDialContext,
|
|
}
|
|
return &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: transport,
|
|
CheckRedirect: defaultCheckRedirect,
|
|
}
|
|
}
|
|
|
|
// NewClient creates a new Gitea API client.
|
|
//
|
|
// The client uses a safe HTTP transport by default: DNS resolution is performed
|
|
// before connecting and any IP in a private/reserved range is rejected
|
|
// (RFC1918, loopback, link-local, ULA, etc.). Cross-host and HTTPS→HTTP
|
|
// redirects are also rejected.
|
|
//
|
|
// For tests that use httptest.NewServer (which listens on 127.0.0.1), call
|
|
// WithUnsafeDialer() to bypass the IP check.
|
|
func NewClient(baseURL, token string) *Client {
|
|
return &Client{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
token: token,
|
|
http: newSafeHTTPClient(),
|
|
}
|
|
}
|
|
|
|
// WithUnsafeDialer returns the client configured with a plain HTTP client that
|
|
// has no IP-level SSRF protection. It preserves the redirect-rejection policy.
|
|
//
|
|
// This MUST only be used in tests. Production code must never call this method.
|
|
func (c *Client) WithUnsafeDialer() *Client {
|
|
c.http = &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
CheckRedirect: defaultCheckRedirect,
|
|
}
|
|
return c
|
|
}
|
|
|
|
// SetHTTPClient sets the underlying HTTP client used for requests.
|
|
// This is intended for test setup only to inject mock transports; it must be
|
|
// called before any goroutines issue requests.
|
|
//
|
|
// Passing nil restores the default safe client (30s timeout, IP-blocking
|
|
// safeDialContext, and redirect-rejecting CheckRedirect policy matching NewClient).
|
|
//
|
|
// Callers providing a non-nil client are responsible for configuring a safe
|
|
// CheckRedirect policy. Without one, the default net/http behavior will follow
|
|
// redirects and may forward the Authorization header to untrusted hosts.
|
|
func (c *Client) SetHTTPClient(hc *http.Client) {
|
|
if hc == nil {
|
|
hc = newSafeHTTPClient()
|
|
}
|
|
c.http = hc
|
|
}
|
|
|
|
// PullRequest holds relevant PR metadata.
|
|
type PullRequest struct {
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
Head struct {
|
|
Sha string `json:"sha"`
|
|
Ref string `json:"ref"`
|
|
} `json:"head"`
|
|
}
|
|
|
|
// CommitStatus represents a single CI status entry.
|
|
type CommitStatus struct {
|
|
Status string `json:"status"`
|
|
Context string `json:"context"`
|
|
Description string `json:"description"`
|
|
TargetURL string `json:"target_url"`
|
|
}
|
|
|
|
// ChangedFile represents a file modified in a PR.
|
|
type ChangedFile struct {
|
|
Filename string `json:"filename"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// ReviewComment represents an inline comment to attach to a review.
|
|
type ReviewComment struct {
|
|
ID int64 `json:"id,omitempty"`
|
|
Path string `json:"path"`
|
|
NewPosition int64 `json:"new_position"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// GetPullRequest fetches PR metadata.
|
|
func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error) {
|
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch PR: %w", err)
|
|
}
|
|
var pr PullRequest
|
|
if err := json.Unmarshal(body, &pr); err != nil {
|
|
return nil, fmt.Errorf("parse PR JSON: %w", err)
|
|
}
|
|
return &pr, nil
|
|
}
|
|
|
|
// GetPullRequestDiff fetches the unified diff for a PR.
|
|
// It enforces MaxDiffSize to prevent unbounded memory allocation.
|
|
// Returns ErrDiffTooLarge if the diff exceeds the configured limit.
|
|
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
|
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
|
|
|
maxSize := c.MaxDiffSize
|
|
if maxSize == 0 {
|
|
maxSize = DefaultMaxDiffSize
|
|
}
|
|
|
|
// When the limit is disabled (negative) or set to math.MaxInt64 (which
|
|
// would overflow the +1 detection and silently disable enforcement),
|
|
// use the standard unlimited doGet path.
|
|
if maxSize < 0 || maxSize == math.MaxInt64 {
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch diff: %w", err)
|
|
}
|
|
return string(body), nil
|
|
}
|
|
|
|
body, err := c.doGetLimited(ctx, reqURL, maxSize)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch diff: %w", err)
|
|
}
|
|
return string(body), nil
|
|
}
|
|
|
|
// GetPullRequestFiles fetches the list of files changed in a PR.
|
|
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, error) {
|
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/files", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch PR files: %w", err)
|
|
}
|
|
var files []ChangedFile
|
|
if err := json.Unmarshal(body, &files); err != nil {
|
|
return nil, fmt.Errorf("parse PR files JSON: %w", err)
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
// GetCommitStatuses fetches CI statuses for a commit SHA.
|
|
func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]CommitStatus, error) {
|
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits/%s/statuses", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch commit statuses: %w", err)
|
|
}
|
|
var statuses []CommitStatus
|
|
if err := json.Unmarshal(body, &statuses); err != nil {
|
|
return nil, fmt.Errorf("parse statuses JSON: %w", err)
|
|
}
|
|
return statuses, nil
|
|
}
|
|
|
|
// GetFileContent fetches a file from the default branch of a repo.
|
|
func (c *Client) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
|
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(filepath))
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch file %s: %w", filepath, err)
|
|
}
|
|
return string(body), nil
|
|
}
|
|
|
|
// GetFileContentRef fetches a file from a specific ref (branch/tag/sha) in a repo.
|
|
func (c *Client) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
|
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(filepath), url.QueryEscape(ref))
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch file %s@%s: %w", filepath, ref, err)
|
|
}
|
|
return string(body), nil
|
|
}
|
|
|
|
// PostReview submits a review to a PR and returns the created review.
|
|
// event should be one of "APPROVED", "REQUEST_CHANGES", or "COMMENT".
|
|
// commitID anchors the review to a specific commit SHA. If empty, Gitea
|
|
// defaults to the current PR head.
|
|
// comments are optional inline comments attached to specific lines.
|
|
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []ReviewComment) (*Review, error) {
|
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
|
|
|
payload := struct {
|
|
Body string `json:"body"`
|
|
Event string `json:"event"`
|
|
CommitID string `json:"commit_id,omitempty"`
|
|
Comments []ReviewComment `json:"comments,omitempty"`
|
|
}{
|
|
Body: body,
|
|
Event: event,
|
|
CommitID: commitID,
|
|
Comments: comments,
|
|
}
|
|
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal review payload: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create review request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "token "+c.token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("post review: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("post review failed (status %d): %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read review response: %w", err)
|
|
}
|
|
var review Review
|
|
if err := json.Unmarshal(respBody, &review); err != nil {
|
|
return nil, fmt.Errorf("parse review response: %w", err)
|
|
}
|
|
return &review, nil
|
|
}
|
|
|
|
// isTemporaryNetError reports whether err is a temporary network error worth retrying.
|
|
// This includes connection refused, network unreachable, connection reset, and DNS
|
|
// timeouts. It explicitly excludes permanent errors like permission denied or
|
|
// "no such host" DNS failures.
|
|
func isTemporaryNetError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
// Check for OpError and inspect the underlying syscall error.
|
|
// Not all OpErrors are transient — permission denied, for example, is permanent.
|
|
var opErr *net.OpError
|
|
if errors.As(err, &opErr) {
|
|
return isRetriableSyscallError(opErr.Err)
|
|
}
|
|
|
|
// DNS errors: only retry on timeout, not on "no such host" which is permanent.
|
|
var dnsErr *net.DNSError
|
|
if errors.As(err, &dnsErr) {
|
|
return dnsErr.IsTimeout
|
|
}
|
|
|
|
// Check for net.Error with Timeout() (Temporary is deprecated)
|
|
var netErr net.Error
|
|
if errors.As(err, &netErr) {
|
|
return netErr.Timeout()
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// isRetriableSyscallError reports whether the underlying error from a net.OpError
|
|
// is a transient syscall error worth retrying.
|
|
func isRetriableSyscallError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
// Check for syscall.Errno directly or wrapped
|
|
var errno syscall.Errno
|
|
if errors.As(err, &errno) {
|
|
switch errno {
|
|
case syscall.ECONNREFUSED, // connection refused — server not listening
|
|
syscall.ECONNRESET, // connection reset by peer
|
|
syscall.ENETUNREACH, // network unreachable
|
|
syscall.EHOSTUNREACH, // host unreachable
|
|
syscall.ETIMEDOUT: // connection timed out
|
|
return true
|
|
default:
|
|
// EACCES, EPERM, etc. are permanent — don't retry
|
|
return false
|
|
}
|
|
}
|
|
|
|
// If we can't identify the specific syscall error, be conservative and retry.
|
|
// This handles wrapped errors or platform-specific error types.
|
|
// The retry count is limited, so erring on the side of retrying is safe.
|
|
return true
|
|
}
|
|
|
|
// redactURL strips query parameters and userinfo credentials from a URL for
|
|
// safe logging. This prevents accidental exposure of sensitive data (tokens in
|
|
// query strings, or user:pass in the authority) in log output.
|
|
func redactURL(rawURL string) string {
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
// If we cannot parse it, return a safe placeholder rather than
|
|
// potentially logging something sensitive.
|
|
return "[invalid URL]"
|
|
}
|
|
if parsed.User != nil {
|
|
parsed.User = url.User("REDACTED")
|
|
}
|
|
if parsed.RawQuery != "" {
|
|
parsed.RawQuery = "[redacted]"
|
|
}
|
|
return parsed.String()
|
|
}
|
|
|
|
// sanitizeErrorForLog returns a loggable version of an error that omits
|
|
// potentially sensitive content like response bodies. For APIError, only
|
|
// the status code is included; for other errors, the type is preserved.
|
|
func sanitizeErrorForLog(err error) string {
|
|
if err == nil {
|
|
return "<nil>"
|
|
}
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) {
|
|
return fmt.Sprintf("HTTP %d", apiErr.StatusCode)
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
// doGetWithReader performs an HTTP GET request with retry on 5xx errors and
|
|
// temporary network errors. Retries up to 3 times with exponential backoff
|
|
// (1s, 2s delays by default; configurable via Client.RetryBackoff for testing).
|
|
// The readBody function is called with the response body on success (2xx) and
|
|
// is responsible for reading and closing it.
|
|
func (c *Client) doGetWithReader(ctx context.Context, reqURL string, readBody func(io.ReadCloser) ([]byte, error)) ([]byte, error) {
|
|
const maxAttempts = 3
|
|
// backoff[i] is the delay before attempt i+1 (i.e., after attempt i fails).
|
|
// First attempt (i=0) has no delay; retries wait 1s then 2s by default.
|
|
backoff := c.RetryBackoff
|
|
if backoff == nil {
|
|
backoff = []time.Duration{1 * time.Second, 2 * time.Second}
|
|
}
|
|
|
|
// maxErrorBodyBytes limits how much of an error response body we read
|
|
// to protect against malicious servers sending unbounded data.
|
|
const maxErrorBodyBytes = 64 * 1024 // 64 KB
|
|
|
|
var lastErr error
|
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
|
if attempt > 0 {
|
|
// Determine delay: use backoff slice if available, otherwise retry immediately.
|
|
// An empty RetryBackoff slice means "retry without delay" — this is intentional
|
|
// as the caller explicitly configured no delays.
|
|
var delay time.Duration
|
|
if attempt-1 < len(backoff) {
|
|
delay = backoff[attempt-1]
|
|
}
|
|
|
|
if delay > 0 {
|
|
slog.Warn("retrying request after error",
|
|
"attempt", attempt+1,
|
|
"url", redactURL(reqURL),
|
|
"delay", delay.String(),
|
|
"lastError", sanitizeErrorForLog(lastErr))
|
|
|
|
timer := time.NewTimer(delay)
|
|
select {
|
|
case <-timer.C:
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "token "+c.token)
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
// Always capture the error for consistent return at loop end.
|
|
// This ensures both network errors and HTTP 5xx return lastErr.
|
|
lastErr = err
|
|
|
|
// Only retry temporary network errors when attempts remain.
|
|
if attempt < maxAttempts-1 && isTemporaryNetError(err) {
|
|
slog.Warn("temporary network error, will retry",
|
|
"attempt", attempt+1,
|
|
"url", redactURL(reqURL),
|
|
"error", err)
|
|
continue
|
|
}
|
|
// Non-retryable network error or final attempt exhausted.
|
|
return nil, lastErr
|
|
}
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
return readBody(resp.Body)
|
|
}
|
|
|
|
// Error path: limit how much we read from potentially malicious server
|
|
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
|
|
resp.Body.Close()
|
|
|
|
lastErr = &APIError{StatusCode: resp.StatusCode, Body: string(errBody)}
|
|
|
|
// Only retry on 5xx server errors
|
|
if resp.StatusCode < 500 || resp.StatusCode >= 600 {
|
|
return nil, lastErr
|
|
}
|
|
}
|
|
|
|
return nil, lastErr
|
|
}
|
|
|
|
// doGet performs an HTTP GET request with retry, reading the full response body.
|
|
func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
|
|
return c.doGetWithReader(ctx, reqURL, func(body io.ReadCloser) ([]byte, error) {
|
|
defer body.Close()
|
|
return io.ReadAll(body)
|
|
})
|
|
}
|
|
|
|
// doGetLimited performs an HTTP GET request with retry but enforces a maximum
|
|
// response body size. Returns ErrDiffTooLarge if the response exceeds maxBytes.
|
|
// It reads maxBytes+1 (clamped to avoid overflow) to detect truncation without
|
|
// buffering the entire body.
|
|
func (c *Client) doGetLimited(ctx context.Context, reqURL string, maxBytes int64) ([]byte, error) {
|
|
return c.doGetWithReader(ctx, reqURL, func(body io.ReadCloser) ([]byte, error) {
|
|
defer body.Close()
|
|
// Read up to maxBytes+1 to detect overflow.
|
|
// Clamp to prevent integer overflow when maxBytes == math.MaxInt64.
|
|
limitBytes := maxBytes + 1
|
|
if limitBytes <= 0 {
|
|
limitBytes = math.MaxInt64
|
|
}
|
|
limited := io.LimitReader(body, limitBytes)
|
|
data, err := io.ReadAll(limited)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(data)) > maxBytes {
|
|
return nil, fmt.Errorf("%w: response exceeds %d bytes", ErrDiffTooLarge, maxBytes)
|
|
}
|
|
return data, nil
|
|
})
|
|
}
|
|
|
|
// escapePath escapes each segment of a relative file path for use in URLs.
|
|
// Slashes are preserved as path separators; other special characters are escaped.
|
|
// Input should be a relative path (no leading slash). Already-encoded segments
|
|
// will be double-encoded, which is the desired behavior for user-provided paths.
|
|
func escapePath(p string) string {
|
|
parts := strings.Split(p, "/")
|
|
for i, part := range parts {
|
|
parts[i] = url.PathEscape(part)
|
|
}
|
|
return strings.Join(parts, "/")
|
|
}
|
|
|
|
// ContentEntry represents a file or directory entry from the contents API.
|
|
type ContentEntry struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Type string `json:"type"` // "file" or "dir"
|
|
}
|
|
|
|
// ListContents lists files and directories at a given path in a repo.
|
|
// Pass an empty path to list the repository root.
|
|
// If the path points to a file (not a directory), Gitea returns a single
|
|
// object instead of an array; this method normalizes both cases to a slice.
|
|
func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) {
|
|
// Normalize "." to empty string — Gitea API rejects "." with 500
|
|
if path == "." {
|
|
path = ""
|
|
}
|
|
var reqURL string
|
|
if path == "" {
|
|
reqURL = fmt.Sprintf("%s/api/v1/repos/%s/%s/contents", c.baseURL, url.PathEscape(owner), url.PathEscape(repo))
|
|
} else {
|
|
reqURL = fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(path))
|
|
}
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list contents %s: %w", path, err)
|
|
}
|
|
var entries []ContentEntry
|
|
if err := json.Unmarshal(body, &entries); err != nil {
|
|
// Gitea returns a single object (not an array) when path is a file
|
|
var single ContentEntry
|
|
if err2 := json.Unmarshal(body, &single); err2 != nil {
|
|
return nil, fmt.Errorf("parse contents JSON: %w", err)
|
|
}
|
|
// Guard against empty/malformed responses
|
|
if single.Name == "" && single.Path == "" {
|
|
return nil, fmt.Errorf("parse contents JSON: empty response for path %q", path)
|
|
}
|
|
entries = []ContentEntry{single}
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
// GetAllFilesInPath recursively fetches all file contents under a path.
|
|
// If the path is a file, returns just that file's content.
|
|
// If the path is a directory, recursively fetches all files within it.
|
|
func (c *Client) GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error) {
|
|
results := make(map[string]string)
|
|
|
|
// Try listing as directory first
|
|
entries, err := c.ListContents(ctx, owner, repo, path)
|
|
if err != nil {
|
|
// Only fall back to single-file fetch on 404 (path is a file, not a dir).
|
|
// Propagate all other errors (auth failures, server errors, rate limits).
|
|
if !IsNotFound(err) {
|
|
return nil, fmt.Errorf("list contents %q: %w", path, err)
|
|
}
|
|
// 404 means the path might be a file — try fetching directly
|
|
content, fileErr := c.GetFileContent(ctx, owner, repo, path)
|
|
if fileErr != nil {
|
|
return nil, fmt.Errorf("path %q is neither a file nor directory: %w", path, fileErr)
|
|
}
|
|
results[path] = content
|
|
return results, nil
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
switch entry.Type {
|
|
case "file":
|
|
content, err := c.GetFileContent(ctx, owner, repo, entry.Path)
|
|
if err != nil {
|
|
slog.Warn("could not fetch file from patterns repo", "file", entry.Path, "error", err)
|
|
continue
|
|
}
|
|
results[entry.Path] = content
|
|
case "dir":
|
|
subResults, err := c.GetAllFilesInPath(ctx, owner, repo, entry.Path)
|
|
if err != nil {
|
|
slog.Warn("could not recurse into directory", "dir", entry.Path, "error", err)
|
|
continue
|
|
}
|
|
for k, v := range subResults {
|
|
results[k] = v
|
|
}
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// Review represents a pull request review from the Gitea API.
|
|
type Review struct {
|
|
ID int64 `json:"id"`
|
|
Body string `json:"body"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
} `json:"user"`
|
|
State string `json:"state"`
|
|
Stale bool `json:"stale"`
|
|
CommitID string `json:"commit_id"`
|
|
}
|
|
|
|
// ListReviews returns all reviews on a pull request.
|
|
// Paginates through all pages to ensure no reviews are missed.
|
|
func (c *Client) ListReviews(ctx context.Context, owner, repo string, number int) ([]Review, error) {
|
|
const pageSize = 50
|
|
var all []Review
|
|
for page := 1; ; page++ {
|
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews?limit=%d&page=%d",
|
|
c.baseURL,
|
|
url.PathEscape(owner),
|
|
url.PathEscape(repo),
|
|
number,
|
|
pageSize,
|
|
page)
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list reviews (page %d): %w", page, err)
|
|
}
|
|
var batch []Review
|
|
if err := json.Unmarshal(body, &batch); err != nil {
|
|
return nil, fmt.Errorf("parse reviews (page %d): %w", page, err)
|
|
}
|
|
all = append(all, batch...)
|
|
if len(batch) < pageSize {
|
|
break
|
|
}
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
// DeleteReview deletes a review by ID. The token must belong to the review author.
|
|
func (c *Client) DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error {
|
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews/%d",
|
|
c.baseURL,
|
|
url.PathEscape(owner),
|
|
url.PathEscape(repo),
|
|
number,
|
|
reviewID)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("create delete request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "token "+c.token)
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("delete review: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("delete review failed (status %d): %s", resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TimelineEvent represents an entry from the issue timeline API.
|
|
type TimelineEvent struct {
|
|
ID int64 `json:"id"`
|
|
Type string `json:"type"`
|
|
Body string `json:"body"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
} `json:"user"`
|
|
}
|
|
|
|
// GetTimelineReviewCommentID finds the comment ID for a review body by
|
|
// scanning the issue timeline for a review event containing the sentinel.
|
|
func (c *Client) GetTimelineReviewCommentID(ctx context.Context, owner, repo string, number int, sentinel string) (int64, error) {
|
|
const pageSize = 50
|
|
for page := 1; ; page++ {
|
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/timeline?limit=%d&page=%d",
|
|
c.baseURL,
|
|
url.PathEscape(owner),
|
|
url.PathEscape(repo),
|
|
number,
|
|
pageSize,
|
|
page)
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("get timeline (page %d): %w", page, err)
|
|
}
|
|
var events []TimelineEvent
|
|
if err := json.Unmarshal(body, &events); err != nil {
|
|
return 0, fmt.Errorf("parse timeline (page %d): %w", page, err)
|
|
}
|
|
for _, ev := range events {
|
|
if ev.Type == "review" && strings.Contains(ev.Body, sentinel) {
|
|
return ev.ID, nil
|
|
}
|
|
}
|
|
if len(events) < pageSize {
|
|
break
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("no timeline event found with sentinel")
|
|
}
|
|
|
|
// GetTimelineReviewCommentIDForReview finds the timeline comment ID for a
|
|
// specific review by matching its body content in the timeline.
|
|
func (c *Client) GetTimelineReviewCommentIDForReview(ctx context.Context, owner, repo string, number int, reviewID int64) (int64, error) {
|
|
// Use the reviews API to get the review body, then find in timeline
|
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews/%d",
|
|
c.baseURL,
|
|
url.PathEscape(owner),
|
|
url.PathEscape(repo),
|
|
number,
|
|
reviewID)
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("get review %d: %w", reviewID, err)
|
|
}
|
|
var review struct {
|
|
Body string `json:"body"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
} `json:"user"`
|
|
}
|
|
if err := json.Unmarshal(body, &review); err != nil {
|
|
return 0, fmt.Errorf("parse review %d: %w", reviewID, err)
|
|
}
|
|
if review.Body == "" {
|
|
return 0, fmt.Errorf("review %d has empty body", reviewID)
|
|
}
|
|
|
|
// Use a prefix for matching (handles minor trailing whitespace differences)
|
|
matchPrefix := review.Body
|
|
if len(matchPrefix) > 200 {
|
|
matchPrefix = matchPrefix[:200]
|
|
}
|
|
|
|
const pageSize = 50
|
|
for page := 1; ; page++ {
|
|
timelineURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/timeline?limit=%d&page=%d",
|
|
c.baseURL,
|
|
url.PathEscape(owner),
|
|
url.PathEscape(repo),
|
|
number,
|
|
pageSize,
|
|
page)
|
|
tlBody, err := c.doGet(ctx, timelineURL)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("get timeline (page %d): %w", page, err)
|
|
}
|
|
var events []TimelineEvent
|
|
if err := json.Unmarshal(tlBody, &events); err != nil {
|
|
return 0, fmt.Errorf("parse timeline (page %d): %w", page, err)
|
|
}
|
|
for _, ev := range events {
|
|
if ev.Type == "review" && ev.User.Login == review.User.Login && strings.HasPrefix(ev.Body, matchPrefix) {
|
|
return ev.ID, nil
|
|
}
|
|
}
|
|
if len(events) < pageSize {
|
|
break
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("no timeline event found for review %d", reviewID)
|
|
}
|
|
|
|
// EditComment updates the body of an issue/review comment.
|
|
func (c *Client) EditComment(ctx context.Context, owner, repo string, commentID int64, newBody string) error {
|
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/comments/%d",
|
|
c.baseURL,
|
|
url.PathEscape(owner),
|
|
url.PathEscape(repo),
|
|
commentID)
|
|
|
|
payload := struct {
|
|
Body string `json:"body"`
|
|
}{Body: newBody}
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal edit payload: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, reqURL, bytes.NewReader(data))
|
|
if err != nil {
|
|
return fmt.Errorf("create edit request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "token "+c.token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("edit comment: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("edit comment failed (status %d): %s", resp.StatusCode, body)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetAuthenticatedUser returns the login of the user authenticated by the token.
|
|
func (c *Client) GetAuthenticatedUser(ctx context.Context) (string, error) {
|
|
reqURL := fmt.Sprintf("%s/api/v1/user", c.baseURL)
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("get authenticated user: %w", err)
|
|
}
|
|
var result struct {
|
|
Login string `json:"login"`
|
|
}
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return "", fmt.Errorf("parse user response: %w", err)
|
|
}
|
|
return result.Login, nil
|
|
}
|
|
|
|
// RequestReviewer adds the given user as a requested reviewer on a pull request.
|
|
// This is idempotent — requesting an already-requested reviewer is a no-op.
|
|
func (c *Client) RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error {
|
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/requested_reviewers",
|
|
c.baseURL,
|
|
url.PathEscape(owner),
|
|
url.PathEscape(repo),
|
|
number)
|
|
|
|
payload := struct {
|
|
Reviewers []string `json:"reviewers"`
|
|
}{Reviewers: []string{reviewer}}
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal reviewer request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(data))
|
|
if err != nil {
|
|
return fmt.Errorf("create reviewer request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "token "+c.token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("request reviewer: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
|
return fmt.Errorf("request reviewer failed (status %d): %s", resp.StatusCode, body)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListReviewComments returns the inline comments attached to a specific review.
|
|
// Paginates through all pages.
|
|
func (c *Client) ListReviewComments(ctx context.Context, owner, repo string, prNumber int, reviewID int64) ([]ReviewComment, error) {
|
|
const pageSize = 50
|
|
var all []ReviewComment
|
|
for page := 1; ; page++ {
|
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews/%d/comments?limit=%d&page=%d",
|
|
c.baseURL,
|
|
url.PathEscape(owner),
|
|
url.PathEscape(repo),
|
|
prNumber,
|
|
reviewID,
|
|
pageSize,
|
|
page)
|
|
body, err := c.doGet(ctx, reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list review comments (page %d): %w", page, err)
|
|
}
|
|
var batch []ReviewComment
|
|
if err := json.Unmarshal(body, &batch); err != nil {
|
|
return nil, fmt.Errorf("parse review comments (page %d): %w", page, err)
|
|
}
|
|
all = append(all, batch...)
|
|
if len(batch) < pageSize {
|
|
break
|
|
}
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
// ResolveComment marks an inline review comment as resolved.
|
|
func (c *Client) ResolveComment(ctx context.Context, owner, repo string, commentID int64) error {
|
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/comments/%d/resolve",
|
|
c.baseURL,
|
|
url.PathEscape(owner),
|
|
url.PathEscape(repo),
|
|
commentID)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("create resolve request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "token "+c.token)
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve comment: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
|
return fmt.Errorf("resolve comment failed (status %d): %s", resp.StatusCode, body)
|
|
}
|
|
return nil
|
|
}
|