// 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 } // 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. func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string, state string, page int) (PaginatedIssues, error) { if state == "" { state = "open" } if page < 1 { page = 1 } cacheKey := fmt.Sprintf("issues-%s-%s", state, strings.Join(orgs, ",")) 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...) } // 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) 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. func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string, state string, page int) (PaginatedPulls, error) { if state == "" { state = "open" } if page < 1 { page = 1 } cacheKey := fmt.Sprintf("pulls-%s-%s", state, strings.Join(orgs, ",")) 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...) } 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) 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 } // 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 } // 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 { payload, err := json.Marshal(map[string]string{"state": "closed"}) if err != nil { return fmt.Errorf("marshaling close request: %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("closing issue: %w", err) } resp.Body.Close() c.InvalidateAll() return nil } // 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 } // 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 }