090ae3848c
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 34s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 45s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m34s
Address review feedback:
1. Make backoff delays injectable via Client.RetryBackoff field
- Defaults to {1s, 2s} when nil for production
- Tests can set shorter values for fast execution
- Fixes slow unit tests that previously waited 3+ seconds
2. Add retry on temporary network errors (net.OpError, net.DNSError)
- Connection refused, network unreachable, DNS failures now retry
- Non-temporary network errors still fail immediately
- Context cancellation still respected during backoff
Added isTemporaryNetError helper and TestIsTemporaryNetError test.
Updated existing retry tests to use configurable short backoffs.
746 lines
23 KiB
Go
746 lines
23 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"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"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
|
|
}
|
|
|
|
// 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.
|
|
RetryBackoff []time.Duration
|
|
}
|
|
|
|
// 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},
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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)
|
|
body, err := c.doGet(ctx, reqURL)
|
|
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, DNS failures, and timeouts that aren't context-based.
|
|
func isTemporaryNetError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
// Check for common retriable error patterns in the error chain.
|
|
// Check OpError first since it embeds net.Error, and we want to catch
|
|
// connection refused, network unreachable, etc. as retriable.
|
|
var opErr *net.OpError
|
|
if errors.As(err, &opErr) {
|
|
// Connection refused, network unreachable, etc. are typically transient
|
|
return true
|
|
}
|
|
|
|
// DNS errors are often transient
|
|
var dnsErr *net.DNSError
|
|
if errors.As(err, &dnsErr) {
|
|
return dnsErr.Temporary()
|
|
}
|
|
|
|
// Check for net.Error with Timeout() (Temporary is deprecated)
|
|
var netErr net.Error
|
|
if errors.As(err, &netErr) {
|
|
return netErr.Timeout()
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// doGet 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).
|
|
func (c *Client) doGet(ctx context.Context, reqURL string) ([]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 no delay
|
|
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", reqURL,
|
|
"delay", delay.String(),
|
|
"lastError", 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 {
|
|
// Check if this is a temporary/transient network error worth retrying.
|
|
// We only retry if there are attempts remaining.
|
|
if attempt < maxAttempts-1 && isTemporaryNetError(err) {
|
|
slog.Warn("temporary network error, will retry",
|
|
"attempt", attempt+1,
|
|
"url", reqURL,
|
|
"error", err)
|
|
lastErr = err
|
|
continue
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
body, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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.
|
|
func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) {
|
|
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 {
|
|
return nil, fmt.Errorf("parse contents JSON: %w", err)
|
|
}
|
|
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
|
|
}
|