// 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" "net/http" "sort" "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 } 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 } // 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, } } // doRequest performs an authenticated HTTP request to the Gitea API. func (c *Client) doRequest(ctx context.Context, token, method, path string, body io.Reader) (*http.Response, error) { url := c.baseURL + "/api/v1" + path req, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("Authorization", "token "+token) req.Header.Set("Accept", "application/json") if body != 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) } 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 } // 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 } // ListAllIssues fetches all open issues across all repos in the given orgs, // using concurrent requests with a semaphore. func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string) ([]Issue, error) { cacheKey := fmt.Sprintf("issues-%s", strings.Join(orgs, ",")) if cached, ok := c.getFromCache(cacheKey); ok { return cached.([]Issue), nil } // 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 nil, fmt.Errorf("listing repos for %s: %w", org, err) } allRepos = append(allRepos, repos...) } // Fan out issue fetching across repos. var allIssues []Issue 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=open&type=issues&limit=50", r.FullName) 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 nil, 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) return allIssues, nil } // ListAllPullRequests fetches all open PRs across all repos in the given orgs. func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string) ([]PullRequest, error) { cacheKey := fmt.Sprintf("pulls-%s", strings.Join(orgs, ",")) if cached, ok := c.getFromCache(cacheKey); ok { return cached.([]PullRequest), nil } var allRepos []Repo for _, org := range orgs { repos, err := c.ListOrgRepos(ctx, token, org) if err != nil { return nil, fmt.Errorf("listing repos for %s: %w", org, err) } allRepos = append(allRepos, repos...) } var allPRs []PullRequest 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=open&limit=50", r.FullName) 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 nil, firstErr } sort.Slice(allPRs, func(i, j int) bool { return allPRs[i].UpdatedAt.After(allPRs[j].UpdatedAt) }) c.setCache(cacheKey, allPRs) return allPRs, 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) { issues, err := c.ListAllIssues(ctx, token, orgs) if err != nil { return nil, fmt.Errorf("fetching issues for triage: %w", err) } prs, err := c.ListAllPullRequests(ctx, token, orgs) if err != nil { return nil, fmt.Errorf("fetching PRs for triage: %w", err) } 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 } // 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 } // 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 } // 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 }