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"` } // GetPullRequest fetches PR metadata. func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error) { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d", c.baseURL, owner, repo, number) body, err := c.doGet(ctx, url) 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) { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.baseURL, owner, repo, number) body, err := c.doGet(ctx, url) 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) { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/files", c.baseURL, owner, repo, number) body, err := c.doGet(ctx, url) 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) { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits/%s/statuses", c.baseURL, owner, repo, sha) body, err := c.doGet(ctx, url) 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) { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s", c.baseURL, owner, repo, filepath) body, err := c.doGet(ctx, url) 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, 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. // event should be "APPROVED" or "REQUEST_CHANGES". func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body string) error { url := 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"` }{ Body: body, Event: event, } data, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal review payload: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data)) if err != nil { return 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 fmt.Errorf("post review: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("post review failed (status %d): %s", resp.StatusCode, string(respBody)) } return nil } func (c *Client) doGet(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, "GET", url, 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) } // 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. func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.baseURL, owner, repo, path) body, err := c.doGet(ctx, url) 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 }