Files
gitea-mobile/internal/gitea/client.go
T
agent-company 04e1f21405 feat: implement issue and PR detail handlers with routes
Add GET /issues/{owner}/{repo}/{index} and GET /pulls/{owner}/{repo}/{index}
routes that fetch individual issues/PRs from the Gitea API and render them
using the existing issue_detail.html and pull_detail.html templates.

New client methods:
- GetIssue: fetch a single issue by owner/repo/index
- GetPull: fetch a single pull request by owner/repo/index
- GetIssueComments: fetch comments for an issue
- GetRepoLabels: fetch available labels for a repository

Both handlers support HTMX fragment responses and full-page rendering,
consistent with the existing handler pattern.

Closes leeworks-agents/gitea-mobile#24

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:05:17 +00:00

723 lines
18 KiB
Go

// 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
}
// 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
}
// 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
}