Files
gitea-mobile/internal/gitea/client.go
T
agent-company 8b6950f88b feat: render issue and PR body as markdown via Gitea API
Add RenderMarkdown method to gitea client that calls POST /api/v1/markdown
to convert raw markdown text to safe HTML. Wire it into IssueDetail and
PullDetail handlers to render body content as formatted markdown.
Falls back gracefully to plain text if the API call fails.

Templates updated to use RenderedBody (template.HTML) with fallback
to raw Issue.Body/Pull.Body when rendering fails.

Closes leeworks-agents/gitea-mobile#35

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

807 lines
21 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
}
// 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
}