8b6950f88b
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>
807 lines
21 KiB
Go
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
|
|
}
|