feat: add backend pagination support for infinite scroll in issues and pulls
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>
This commit is contained in:
+215
-145
@@ -308,173 +308,243 @@ func (c *Client) ListOrgsAndRepos(ctx context.Context, token string) (map[string
|
||||
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
|
||||
}
|
||||
// PageSize is the number of items returned per page for paginated listings.
|
||||
const PageSize = 20
|
||||
|
||||
// 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
|
||||
// PaginatedIssues holds a page of issues along with pagination metadata.
|
||||
type PaginatedIssues struct {
|
||||
Issues []Issue
|
||||
HasMore bool
|
||||
}
|
||||
|
||||
// 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, ","))
|
||||
// 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 {
|
||||
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)
|
||||
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 {
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("fetching PRs for %s: %w", r.FullName, err)
|
||||
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
|
||||
}
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
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)
|
||||
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()
|
||||
return
|
||||
}
|
||||
}(repo)
|
||||
}
|
||||
|
||||
for i := range prs {
|
||||
prs[i].RepoOwner = r.Owner.Login
|
||||
prs[i].RepoName = r.Name
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
mu.Lock()
|
||||
allPRs = append(allPRs, prs...)
|
||||
mu.Unlock()
|
||||
}(repo)
|
||||
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)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if firstErr != nil {
|
||||
return nil, firstErr
|
||||
// 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)
|
||||
}
|
||||
|
||||
sort.Slice(allPRs, func(i, j int) bool {
|
||||
return allPRs[i].UpdatedAt.After(allPRs[j].UpdatedAt)
|
||||
})
|
||||
return PaginatedIssues{Issues: allIssues[start:end], HasMore: hasMore}, nil
|
||||
}
|
||||
|
||||
c.setCache(cacheKey, allPRs)
|
||||
return allPRs, 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) {
|
||||
issues, err := c.ListAllIssues(ctx, token, orgs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching issues for triage: %w", err)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
prs, err := c.ListAllPullRequests(ctx, token, orgs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching PRs for triage: %w", err)
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user