added0778e
Add CloseIssue (PATCH state=closed) and PostComment (POST comment body)
methods to the Gitea client with cache invalidation. Add corresponding
handler routes POST /issues/{owner}/{repo}/{index}/close and
POST /issues/{owner}/{repo}/{index}/comment with HTMX support.
Include unit tests for both client methods.
Closes leeworks-agents/gitea-mobile#36
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
768 lines
20 KiB
Go
768 lines
20 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
|
|
}
|
|
|
|
// 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
|
|
}
|