a707646200
Update ListAllIssues and ListAllPullRequests to accept state and page parameters, returning paginated results (20 per page) with HasMore metadata. ListIssues and ListPulls handlers now read page, org, and state query params; HTMX requests for page > 1 return only card HTML fragments for seamless infinite scroll. Both templates extract a reusable "cards" block and pulls.html gains a scroll sentinel matching the existing issues.html pattern. Filter changes reset to page 1. Closes leeworks-agents/gitea-mobile#32 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
877 lines
23 KiB
Go
877 lines
23 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
|
|
}
|
|
|
|
// 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
|
|
}
|