028d46942a
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 16s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 25s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 53s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m4s
950 lines
31 KiB
Go
950 lines
31 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
|
|
}
|
|
|
|
// NewClient creates a new Gitea API client.
|
|
func NewClient(baseURL, token string) *Client {
|
|
return &Client{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
token: token,
|
|
http: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
CheckRedirect: defaultCheckRedirect,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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 client (30s timeout + 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 = &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
CheckRedirect: defaultCheckRedirect,
|
|
}
|
|
}
|
|
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
|
|
}
|