// 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 } // 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}, } } // SetHTTPClient sets the underlying HTTP client used for requests. // This is intended for testing to inject mock transports. func (c *Client) SetHTTPClient(hc *http.Client) { 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 "APPROVED" or "REQUEST_CHANGES". // comments are optional inline comments attached to specific lines. func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body 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"` Comments []ReviewComment `json:"comments,omitempty"` }{ Body: body, Event: event, 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 "" } 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 }