// 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" "fmt" "io" "log" "net/http" "net/url" "strings" "time" ) // 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 } // 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 { 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, owner, 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, owner, 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, owner, 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, owner, repo, 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, owner, 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, owner, 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, owner, 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 } func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) { 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 { return nil, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) } return io.ReadAll(resp.Body) } // 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, owner, repo) } else { reqURL = fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.baseURL, owner, 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 { // 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, err) } 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 { log.Printf("Warning: could not fetch file %s: %v", entry.Path, err) continue } results[entry.Path] = content case "dir": subResults, err := c.GetAllFilesInPath(ctx, owner, repo, entry.Path) if err != nil { log.Printf("Warning: could not recurse into %s: %v", entry.Path, 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"` } // 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 }