// Package gitea provides an aggregation layer over the Gitea API, // supporting concurrent fetching across multiple organizations and repos // with in-memory caching. package gitea import ( "context" "encoding/json" "fmt" "io" "log/slog" "math" "net/http" "sort" "strconv" "strings" "sync" "time" ) // Client wraps the Gitea API with aggregation and caching capabilities. type Client struct { baseURL string httpClient *http.Client mu sync.RWMutex cache map[string]*cacheEntry // maxConcurrent controls the semaphore size for parallel API calls. maxConcurrent int // cacheTTL controls how long cache entries remain valid. cacheTTL time.Duration // maxRetries is the maximum number of retries for rate-limited requests. maxRetries int // baseRetryDelay is the initial backoff delay before the first retry. baseRetryDelay time.Duration } type cacheEntry struct { data interface{} expiresAt time.Time } // Org represents a Gitea organization. type Org struct { Name string `json:"username"` FullName string `json:"full_name"` Description string `json:"description"` AvatarURL string `json:"avatar_url"` } // Repo represents a Gitea repository. type Repo struct { ID int64 `json:"id"` Name string `json:"name"` FullName string `json:"full_name"` Description string `json:"description"` Owner struct { Login string `json:"login"` } `json:"owner"` HTMLURL string `json:"html_url"` UpdatedAt time.Time `json:"updated_at"` } // Issue represents a Gitea issue. type Issue struct { ID int64 `json:"id"` Number int64 `json:"number"` Title string `json:"title"` Body string `json:"body"` State string `json:"state"` Labels []struct { ID int64 `json:"id"` Name string `json:"name"` Color string `json:"color"` } `json:"labels"` Assignee *struct { Login string `json:"login"` AvatarURL string `json:"avatar_url"` } `json:"assignee"` Assignees []struct { Login string `json:"login"` AvatarURL string `json:"avatar_url"` } `json:"assignees"` HTMLURL string `json:"html_url"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` RepoOwner string `json:"-"` // populated after fetch RepoName string `json:"-"` // populated after fetch } // PullRequest represents a Gitea pull request. type PullRequest struct { ID int64 `json:"id"` Number int64 `json:"number"` Title string `json:"title"` Body string `json:"body"` State string `json:"state"` Labels []struct { ID int64 `json:"id"` Name string `json:"name"` Color string `json:"color"` } `json:"labels"` User *struct { Login string `json:"login"` AvatarURL string `json:"avatar_url"` } `json:"user"` Mergeable bool `json:"mergeable"` HTMLURL string `json:"html_url"` DiffURL string `json:"diff_url"` Additions int `json:"additions"` Deletions int `json:"deletions"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` RepoOwner string `json:"-"` // populated after fetch RepoName string `json:"-"` // populated after fetch ReviewState string `json:"-"` // aggregated review state: "approved", "changes_requested", "pending", or "" } // TriageItem represents an item in the triage queue. type TriageItem struct { Type string // "issue" or "pull" RepoOwner string RepoName string Number int64 Title string HTMLURL string Labels []string UpdatedAt time.Time } // NewClient creates a new Gitea API client. func NewClient(baseURL string) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), httpClient: &http.Client{ Timeout: 30 * time.Second, }, cache: make(map[string]*cacheEntry), maxConcurrent: 5, cacheTTL: 30 * time.Second, maxRetries: 3, baseRetryDelay: 1 * time.Second, } } // doRequest performs an authenticated HTTP request to the Gitea API. // It automatically retries on HTTP 429 (rate limit) responses with // exponential backoff, respecting the Retry-After header when present. func (c *Client) doRequest(ctx context.Context, token, method, path string, body io.Reader) (*http.Response, error) { url := c.baseURL + "/api/v1" + path // Read the body once so we can replay it on retries. var bodyBytes []byte if body != nil { var err error bodyBytes, err = io.ReadAll(body) if err != nil { return nil, fmt.Errorf("reading request body: %w", err) } } var lastErr error for attempt := 0; attempt <= c.maxRetries; attempt++ { // Recreate the body reader for each attempt. var reqBody io.Reader if bodyBytes != nil { reqBody = strings.NewReader(string(bodyBytes)) } req, err := http.NewRequestWithContext(ctx, method, url, reqBody) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("Authorization", "token "+token) req.Header.Set("Accept", "application/json") if bodyBytes != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("executing request: %w", err) } // Not rate-limited: handle normally. if resp.StatusCode != http.StatusTooManyRequests { if resp.StatusCode >= 400 { defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) } return resp, nil } // Rate-limited (429): close body and compute retry delay. resp.Body.Close() if attempt == c.maxRetries { lastErr = fmt.Errorf("API rate limit exceeded after %d retries (429)", c.maxRetries) break } delay := c.retryDelay(resp, attempt) slog.Warn("rate limited by Gitea API, retrying", "attempt", attempt+1, "max_retries", c.maxRetries, "delay", delay, "path", path, ) select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(delay): // Continue to next attempt. } } return nil, lastErr } // retryDelay computes the delay before the next retry attempt. It uses the // Retry-After header value (in seconds) if present, otherwise falls back to // exponential backoff: baseRetryDelay * 2^attempt. func (c *Client) retryDelay(resp *http.Response, attempt int) time.Duration { if ra := resp.Header.Get("Retry-After"); ra != "" { if seconds, err := strconv.Atoi(ra); err == nil && seconds > 0 { return time.Duration(seconds) * time.Second } } // Exponential backoff: 1s, 2s, 4s, ... return c.baseRetryDelay * time.Duration(math.Pow(2, float64(attempt))) } // getFromCache returns cached data if still valid. func (c *Client) getFromCache(key string) (interface{}, bool) { c.mu.RLock() defer c.mu.RUnlock() entry, ok := c.cache[key] if !ok || time.Now().After(entry.expiresAt) { return nil, false } return entry.data, true } // setCache stores data in cache with TTL. func (c *Client) setCache(key string, data interface{}) { c.mu.Lock() defer c.mu.Unlock() c.cache[key] = &cacheEntry{ data: data, expiresAt: time.Now().Add(c.cacheTTL), } } // invalidateCache removes entries matching the given prefix. func (c *Client) invalidateCache(prefix string) { c.mu.Lock() defer c.mu.Unlock() for k := range c.cache { if strings.HasPrefix(k, prefix) { delete(c.cache, k) } } } // InvalidateAll clears the entire cache (called on write operations). func (c *Client) InvalidateAll() { c.mu.Lock() defer c.mu.Unlock() c.cache = make(map[string]*cacheEntry) } // ListOrgs returns the organizations the authenticated user belongs to. func (c *Client) ListOrgs(ctx context.Context, token string) ([]Org, error) { cacheKey := "orgs" if cached, ok := c.getFromCache(cacheKey); ok { return cached.([]Org), nil } resp, err := c.doRequest(ctx, token, http.MethodGet, "/user/orgs?limit=50", nil) if err != nil { return nil, fmt.Errorf("listing orgs: %w", err) } defer resp.Body.Close() var orgs []Org if err := json.NewDecoder(resp.Body).Decode(&orgs); err != nil { return nil, fmt.Errorf("decoding orgs: %w", err) } c.setCache(cacheKey, orgs) return orgs, nil } // ListOrgRepos returns all repositories for a given organization. func (c *Client) ListOrgRepos(ctx context.Context, token, org string) ([]Repo, error) { cacheKey := fmt.Sprintf("repos-%s", org) if cached, ok := c.getFromCache(cacheKey); ok { return cached.([]Repo), nil } var allRepos []Repo page := 1 for { path := fmt.Sprintf("/orgs/%s/repos?limit=50&page=%d", org, page) resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) if err != nil { return nil, fmt.Errorf("listing repos for %s: %w", org, err) } var repos []Repo if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil { resp.Body.Close() return nil, fmt.Errorf("decoding repos: %w", err) } resp.Body.Close() if len(repos) == 0 { break } allRepos = append(allRepos, repos...) if len(repos) < 50 { break } page++ } c.setCache(cacheKey, allRepos) return allRepos, nil } // ListOrgsAndRepos returns a map of org name to repos for all orgs the user belongs to. func (c *Client) ListOrgsAndRepos(ctx context.Context, token string) (map[string][]Repo, error) { orgs, err := c.ListOrgs(ctx, token) if err != nil { return nil, err } result := make(map[string][]Repo) var mu sync.Mutex sem := make(chan struct{}, c.maxConcurrent) var wg sync.WaitGroup var firstErr error for _, org := range orgs { wg.Add(1) go func(orgName string) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() repos, err := c.ListOrgRepos(ctx, token, orgName) if err != nil { mu.Lock() if firstErr == nil { firstErr = err } mu.Unlock() return } mu.Lock() result[orgName] = repos mu.Unlock() }(org.Name) } wg.Wait() if firstErr != nil { return nil, firstErr } return result, nil } // PageSize is the number of items returned per page for paginated listings. const PageSize = 20 // PaginatedIssues holds a page of issues along with pagination metadata. type PaginatedIssues struct { Issues []Issue HasMore bool } // PaginatedPulls holds a page of pull requests along with pagination metadata. type PaginatedPulls struct { Pulls []PullRequest HasMore bool } // ListAllIssues fetches issues across all repos in the given orgs, // using concurrent requests with a semaphore. Results are paginated. // The label parameter filters issues by label name (empty string means no filter). // The repoFilter parameter narrows results to a single repo name (empty means all repos). func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string, state string, page int, label string, repoFilter string) (PaginatedIssues, error) { if state == "" { state = "open" } if page < 1 { page = 1 } cacheKey := fmt.Sprintf("issues-%s-%s-%s-%s", state, strings.Join(orgs, ","), label, repoFilter) var allIssues []Issue if cached, ok := c.getFromCache(cacheKey); ok { allIssues = cached.([]Issue) } else { // First, collect all repos for the given orgs. var allRepos []Repo for _, org := range orgs { repos, err := c.ListOrgRepos(ctx, token, org) if err != nil { return PaginatedIssues{}, fmt.Errorf("listing repos for %s: %w", org, err) } allRepos = append(allRepos, repos...) } // Filter to a single repo if specified. if repoFilter != "" { var filtered []Repo for _, r := range allRepos { if r.Name == repoFilter { filtered = append(filtered, r) } } allRepos = filtered } // Fan out issue fetching across repos. var mu sync.Mutex sem := make(chan struct{}, c.maxConcurrent) var wg sync.WaitGroup var firstErr error for _, repo := range allRepos { wg.Add(1) go func(r Repo) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() path := fmt.Sprintf("/repos/%s/issues?state=%s&type=issues&limit=50", r.FullName, state) if label != "" { path += "&labels=" + label } resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) if err != nil { mu.Lock() if firstErr == nil { firstErr = fmt.Errorf("fetching issues for %s: %w", r.FullName, err) } mu.Unlock() return } defer resp.Body.Close() var issues []Issue if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { mu.Lock() if firstErr == nil { firstErr = fmt.Errorf("decoding issues for %s: %w", r.FullName, err) } mu.Unlock() return } // Tag each issue with repo info. for i := range issues { issues[i].RepoOwner = r.Owner.Login issues[i].RepoName = r.Name } mu.Lock() allIssues = append(allIssues, issues...) mu.Unlock() }(repo) } wg.Wait() if firstErr != nil { return PaginatedIssues{}, firstErr } // Sort by updated time, newest first. sort.Slice(allIssues, func(i, j int) bool { return allIssues[i].UpdatedAt.After(allIssues[j].UpdatedAt) }) c.setCache(cacheKey, allIssues) } // Paginate. start := (page - 1) * PageSize if start >= len(allIssues) { return PaginatedIssues{}, nil } end := start + PageSize hasMore := end < len(allIssues) if end > len(allIssues) { end = len(allIssues) } return PaginatedIssues{Issues: allIssues[start:end], HasMore: hasMore}, nil } // ListAllPullRequests fetches PRs across all repos in the given orgs. // Results are paginated. The label parameter filters PRs by label name. // The repoFilter parameter narrows results to a single repo name. func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string, state string, page int, label string, repoFilter string) (PaginatedPulls, error) { if state == "" { state = "open" } if page < 1 { page = 1 } cacheKey := fmt.Sprintf("pulls-%s-%s-%s-%s", state, strings.Join(orgs, ","), label, repoFilter) var allPRs []PullRequest if cached, ok := c.getFromCache(cacheKey); ok { allPRs = cached.([]PullRequest) } else { var allRepos []Repo for _, org := range orgs { repos, err := c.ListOrgRepos(ctx, token, org) if err != nil { return PaginatedPulls{}, fmt.Errorf("listing repos for %s: %w", org, err) } allRepos = append(allRepos, repos...) } // Filter to a single repo if specified. if repoFilter != "" { var filtered []Repo for _, r := range allRepos { if r.Name == repoFilter { filtered = append(filtered, r) } } allRepos = filtered } var mu sync.Mutex sem := make(chan struct{}, c.maxConcurrent) var wg sync.WaitGroup var firstErr error for _, repo := range allRepos { wg.Add(1) go func(r Repo) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() path := fmt.Sprintf("/repos/%s/pulls?state=%s&limit=50", r.FullName, state) if label != "" { path += "&labels=" + label } resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) if err != nil { mu.Lock() if firstErr == nil { firstErr = fmt.Errorf("fetching PRs for %s: %w", r.FullName, err) } mu.Unlock() return } defer resp.Body.Close() var prs []PullRequest if err := json.NewDecoder(resp.Body).Decode(&prs); err != nil { mu.Lock() if firstErr == nil { firstErr = fmt.Errorf("decoding PRs for %s: %w", r.FullName, err) } mu.Unlock() return } for i := range prs { prs[i].RepoOwner = r.Owner.Login prs[i].RepoName = r.Name } mu.Lock() allPRs = append(allPRs, prs...) mu.Unlock() }(repo) } wg.Wait() if firstErr != nil { return PaginatedPulls{}, firstErr } sort.Slice(allPRs, func(i, j int) bool { return allPRs[i].UpdatedAt.After(allPRs[j].UpdatedAt) }) c.setCache(cacheKey, allPRs) } // Paginate. start := (page - 1) * PageSize if start >= len(allPRs) { return PaginatedPulls{}, nil } end := start + PageSize hasMore := end < len(allPRs) if end > len(allPRs) { end = len(allPRs) } return PaginatedPulls{Pulls: allPRs[start:end], HasMore: hasMore}, nil } // GetTriageQueue returns unassigned issues and PRs needing review, sorted by priority. func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string) ([]TriageItem, error) { // Collect all open issues across all pages. var issues []Issue for page := 1; ; page++ { result, err := c.ListAllIssues(ctx, token, orgs, "open", page, "", "") if err != nil { return nil, fmt.Errorf("fetching issues for triage: %w", err) } issues = append(issues, result.Issues...) if !result.HasMore { break } } // Collect all open PRs across all pages. var prs []PullRequest for page := 1; ; page++ { result, err := c.ListAllPullRequests(ctx, token, orgs, "open", page, "", "") if err != nil { return nil, fmt.Errorf("fetching PRs for triage: %w", err) } prs = append(prs, result.Pulls...) if !result.HasMore { break } } var queue []TriageItem // Add unassigned issues. for _, issue := range issues { if issue.Assignee == nil && len(issue.Assignees) == 0 { var labels []string for _, l := range issue.Labels { labels = append(labels, l.Name) } queue = append(queue, TriageItem{ Type: "issue", RepoOwner: issue.RepoOwner, RepoName: issue.RepoName, Number: issue.Number, Title: issue.Title, HTMLURL: issue.HTMLURL, Labels: labels, UpdatedAt: issue.UpdatedAt, }) } } // Add PRs (all open PRs may need review attention). for _, pr := range prs { var labels []string for _, l := range pr.Labels { labels = append(labels, l.Name) } queue = append(queue, TriageItem{ Type: "pull", RepoOwner: pr.RepoOwner, RepoName: pr.RepoName, Number: pr.Number, Title: pr.Title, HTMLURL: pr.HTMLURL, Labels: labels, UpdatedAt: pr.UpdatedAt, }) } // Sort by priority labels (P1 > P2 > P3 > no priority), then by updated time. sort.Slice(queue, func(i, j int) bool { pi := priorityScore(queue[i].Labels) pj := priorityScore(queue[j].Labels) if pi != pj { return pi < pj // lower score = higher priority } return queue[i].UpdatedAt.After(queue[j].UpdatedAt) }) return queue, nil } // Comment represents a comment on an issue or pull request. type Comment struct { ID int64 `json:"id"` Body string `json:"body"` User string `json:"-"` // populated from nested object CreatedAt string `json:"-"` // formatted after fetch RawUser struct { Login string `json:"login"` } `json:"user"` RawCreatedAt time.Time `json:"created_at"` } // Label represents a Gitea label (used for available labels list). type Label struct { ID int64 `json:"id"` Name string `json:"name"` Color string `json:"color"` } // GetIssue fetches a single issue by owner, repo, and index. func (c *Client) GetIssue(ctx context.Context, token, owner, repo string, index int64) (*Issue, error) { path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index) resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) if err != nil { return nil, fmt.Errorf("fetching issue: %w", err) } defer resp.Body.Close() var issue Issue if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { return nil, fmt.Errorf("decoding issue: %w", err) } issue.RepoOwner = owner issue.RepoName = repo return &issue, nil } // GetPull fetches a single pull request by owner, repo, and index. func (c *Client) GetPull(ctx context.Context, token, owner, repo string, index int64) (*PullRequest, error) { path := fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, index) resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) if err != nil { return nil, fmt.Errorf("fetching pull request: %w", err) } defer resp.Body.Close() var pr PullRequest if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil { return nil, fmt.Errorf("decoding pull request: %w", err) } pr.RepoOwner = owner pr.RepoName = repo return &pr, nil } // ChangedFile represents a file changed in a pull request. type ChangedFile struct { Filename string `json:"filename"` Status string `json:"status"` // "added", "modified", "removed", "renamed" Additions int `json:"additions"` Deletions int `json:"deletions"` Changes int `json:"changes"` PreviousFilename string `json:"previous_filename,omitempty"` } // GetChangedFiles fetches the list of files changed in a pull request. func (c *Client) GetChangedFiles(ctx context.Context, token, owner, repo string, index int64) ([]ChangedFile, error) { path := fmt.Sprintf("/repos/%s/%s/pulls/%d/files?limit=50", owner, repo, index) resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) if err != nil { return nil, fmt.Errorf("fetching changed files: %w", err) } defer resp.Body.Close() var files []ChangedFile if err := json.NewDecoder(resp.Body).Decode(&files); err != nil { return nil, fmt.Errorf("decoding changed files: %w", err) } return files, nil } // GetIssueComments fetches comments for an issue or pull request. func (c *Client) GetIssueComments(ctx context.Context, token, owner, repo string, index int64) ([]Comment, error) { path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments?limit=50", owner, repo, index) resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) if err != nil { return nil, fmt.Errorf("fetching comments: %w", err) } defer resp.Body.Close() var comments []Comment if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil { return nil, fmt.Errorf("decoding comments: %w", err) } // Populate convenience fields. for i := range comments { comments[i].User = comments[i].RawUser.Login comments[i].CreatedAt = comments[i].RawCreatedAt.Format("Jan 2, 2006 15:04") } return comments, nil } // GetRepoLabels fetches all labels for a repository. func (c *Client) GetRepoLabels(ctx context.Context, token, owner, repo string) ([]Label, error) { path := fmt.Sprintf("/repos/%s/%s/labels?limit=50", owner, repo) resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) if err != nil { return nil, fmt.Errorf("fetching labels: %w", err) } defer resp.Body.Close() var labels []Label if err := json.NewDecoder(resp.Body).Decode(&labels); err != nil { return nil, fmt.Errorf("decoding labels: %w", err) } return labels, nil } // CreateIssue creates a new issue in the specified repository. func (c *Client) CreateIssue(ctx context.Context, token, owner, repo, title, body string, labels []int64) (*Issue, error) { payload := map[string]interface{}{ "title": title, "body": body, } if len(labels) > 0 { payload["labels"] = labels } jsonData, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("marshaling issue: %w", err) } path := fmt.Sprintf("/repos/%s/%s/issues", owner, repo) resp, err := c.doRequest(ctx, token, http.MethodPost, path, strings.NewReader(string(jsonData))) if err != nil { return nil, fmt.Errorf("creating issue: %w", err) } defer resp.Body.Close() var issue Issue if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { return nil, fmt.Errorf("decoding created issue: %w", err) } issue.RepoOwner = owner issue.RepoName = repo c.InvalidateAll() // Invalidate cache after write. return &issue, nil } // ApplyLabel adds a label to an issue. func (c *Client) ApplyLabel(ctx context.Context, token, owner, repo string, index int64, labelIDs []int64) error { payload := map[string]interface{}{ "labels": labelIDs, } jsonData, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshaling labels: %w", err) } path := fmt.Sprintf("/repos/%s/%s/issues/%d/labels", owner, repo, index) resp, err := c.doRequest(ctx, token, http.MethodPost, path, strings.NewReader(string(jsonData))) if err != nil { return fmt.Errorf("applying labels: %w", err) } resp.Body.Close() c.InvalidateAll() return nil } // ListCollaborators fetches the list of collaborators (users with access) for a repo. func (c *Client) ListCollaborators(ctx context.Context, token, owner, repo string) ([]string, error) { path := fmt.Sprintf("/repos/%s/%s/collaborators?limit=50", owner, repo) resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) if err != nil { return nil, fmt.Errorf("fetching collaborators: %w", err) } defer resp.Body.Close() var users []struct { Login string `json:"login"` } if err := json.NewDecoder(resp.Body).Decode(&users); err != nil { return nil, fmt.Errorf("decoding collaborators: %w", err) } var logins []string for _, u := range users { logins = append(logins, u.Login) } return logins, nil } // AssignIssue sets the assignees on an issue. func (c *Client) AssignIssue(ctx context.Context, token, owner, repo string, index int64, assignees []string) error { payload, err := json.Marshal(map[string]interface{}{ "assignees": assignees, }) if err != nil { return fmt.Errorf("marshaling assignees: %w", err) } path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index) resp, err := c.doRequest(ctx, token, http.MethodPatch, path, strings.NewReader(string(payload))) if err != nil { return fmt.Errorf("assigning issue: %w", err) } resp.Body.Close() c.InvalidateAll() return nil } // SubmitReview submits a review on a pull request. func (c *Client) SubmitReview(ctx context.Context, token, owner, repo string, index int64, reviewType, body string) error { payload := map[string]interface{}{ "event": reviewType, // "APPROVED", "REQUEST_CHANGES", "COMMENT" "body": body, } jsonData, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshaling review: %w", err) } path := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, index) resp, err := c.doRequest(ctx, token, http.MethodPost, path, strings.NewReader(string(jsonData))) if err != nil { return fmt.Errorf("submitting review: %w", err) } resp.Body.Close() c.InvalidateAll() return nil } // CloseIssue closes an issue by setting its state to "closed". func (c *Client) CloseIssue(ctx context.Context, token, owner, repo string, index int64) error { return c.SetIssueState(ctx, token, owner, repo, index, "closed") } // SetIssueState sets an issue's state (e.g. "open" or "closed"). func (c *Client) SetIssueState(ctx context.Context, token, owner, repo string, index int64, state string) error { payload, err := json.Marshal(map[string]string{"state": state}) if err != nil { return fmt.Errorf("marshaling state change: %w", err) } path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index) resp, err := c.doRequest(ctx, token, http.MethodPatch, path, strings.NewReader(string(payload))) if err != nil { return fmt.Errorf("setting issue state: %w", err) } resp.Body.Close() c.InvalidateAll() return nil } // MergePull merges a pull request using the specified merge style. // Valid styles: "merge", "rebase", "rebase-merge", "squash". // If style is empty, defaults to "merge". func (c *Client) MergePull(ctx context.Context, token, owner, repo string, index int64, style, title, message string) error { if style == "" { style = "merge" } payload := map[string]string{ "Do": style, } if title != "" { payload["merge_message_field"] = title } if message != "" { payload["merge_message_field"] = message } jsonData, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshaling merge request: %w", err) } path := fmt.Sprintf("/repos/%s/%s/pulls/%d/merge", owner, repo, index) resp, err := c.doRequest(ctx, token, http.MethodPost, path, strings.NewReader(string(jsonData))) if err != nil { return fmt.Errorf("merging pull request: %w", err) } resp.Body.Close() c.InvalidateAll() return nil } // AddComment creates a comment on an issue and returns the created Comment. func (c *Client) AddComment(ctx context.Context, token, owner, repo string, index int64, body string) (*Comment, error) { return c.PostComment(ctx, token, owner, repo, index, body) } // PostComment creates a comment on an issue and returns the created Comment. func (c *Client) PostComment(ctx context.Context, token, owner, repo string, index int64, body string) (*Comment, error) { payload, err := json.Marshal(map[string]string{"body": body}) if err != nil { return nil, fmt.Errorf("marshaling comment: %w", err) } path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, repo, index) resp, err := c.doRequest(ctx, token, http.MethodPost, path, strings.NewReader(string(payload))) if err != nil { return nil, fmt.Errorf("posting comment: %w", err) } defer resp.Body.Close() var comment Comment if err := json.NewDecoder(resp.Body).Decode(&comment); err != nil { return nil, fmt.Errorf("decoding comment: %w", err) } // Populate convenience fields. comment.User = comment.RawUser.Login comment.CreatedAt = comment.RawCreatedAt.Format("Jan 2, 2006 15:04") c.InvalidateAll() return &comment, nil } // RenderMarkdown renders raw markdown text to HTML using the Gitea API. // Falls back to the raw text if the API call fails. func (c *Client) RenderMarkdown(ctx context.Context, token, text string) (string, error) { payload, err := json.Marshal(map[string]string{ "Text": text, "Mode": "gfm", }) if err != nil { return text, fmt.Errorf("marshaling markdown request: %w", err) } url := c.baseURL + "/api/v1/markdown" req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(payload))) if err != nil { return text, fmt.Errorf("creating markdown request: %w", err) } req.Header.Set("Authorization", "token "+token) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/html") resp, err := c.httpClient.Do(req) if err != nil { return text, fmt.Errorf("executing markdown request: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return text, fmt.Errorf("markdown API error %d", resp.StatusCode) } rendered, err := io.ReadAll(resp.Body) if err != nil { return text, fmt.Errorf("reading markdown response: %w", err) } return string(rendered), nil } // Review represents a single review on a pull request. type Review struct { ID int64 `json:"id"` Body string `json:"body"` State string `json:"state"` // "APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING" User struct { Login string `json:"login"` } `json:"user"` } // GetPullReviewState fetches reviews for a PR and returns the aggregate state. // Priority: changes_requested > approved > pending > "" (no reviews). func (c *Client) GetPullReviewState(ctx context.Context, token, owner, repo string, index int64) string { path := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews?limit=50", owner, repo, index) resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) if err != nil { return "" } defer resp.Body.Close() var reviews []Review if err := json.NewDecoder(resp.Body).Decode(&reviews); err != nil { return "" } if len(reviews) == 0 { return "" } // Aggregate: last non-comment review per user wins, then pick the "worst" state. userState := make(map[string]string) for _, r := range reviews { switch r.State { case "APPROVED", "REQUEST_CHANGES": userState[r.User.Login] = r.State } } if len(userState) == 0 { return "pending" } for _, s := range userState { if s == "REQUEST_CHANGES" { return "changes_requested" } } return "approved" } // EnrichPullsWithReviewState fetches review state for each PR concurrently. func (c *Client) EnrichPullsWithReviewState(ctx context.Context, token string, pulls []PullRequest) { sem := make(chan struct{}, c.maxConcurrent) var wg sync.WaitGroup for i := range pulls { wg.Add(1) go func(idx int) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() pulls[idx].ReviewState = c.GetPullReviewState(ctx, token, pulls[idx].RepoOwner, pulls[idx].RepoName, pulls[idx].Number) }(i) } wg.Wait() } // priorityScore returns a numeric score for sorting (lower = higher priority). func priorityScore(labels []string) int { for _, l := range labels { switch l { case "P1": return 1 case "P2": return 2 case "P3": return 3 } } return 4 // no priority label }