diff --git a/internal/gitea/client.go b/internal/gitea/client.go index 105165d..bdf63c8 100644 --- a/internal/gitea/client.go +++ b/internal/gitea/client.go @@ -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 diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index b592438..06f01c3 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -239,6 +239,10 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { if selectedState == "" { selectedState = "open" } + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } data := issuesData{ Orgs: orgNames, @@ -255,15 +259,38 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { queryOrgs = []string{selectedOrg} } - issues, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs) + result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page) if err != nil { slog.Error("failed to list issues", "error", err) data.Error = "Error loading issues." } else { - data.Issues = issues + data.Issues = result.Issues + data.HasMore = result.HasMore + if result.HasMore { + data.NextPage = page + 1 + } } } + // For HTMX infinite-scroll requests (page > 1), return only the card fragment. + if isHTMX(r) && page > 1 { + tmpl, err := template.ParseFiles("internal/templates/issues.html") + if err != nil { + slog.Error("failed to parse issues template", "error", err) + http.Error(w, "template error", http.StatusInternalServerError) + return + } + var buf strings.Builder + if err := tmpl.ExecuteTemplate(&buf, "cards", data); err != nil { + slog.Error("failed to execute issues cards template", "error", err) + http.Error(w, "template error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, buf.String()) + return + } + tmpl, err := template.ParseFiles("internal/templates/issues.html") if err != nil { slog.Error("failed to parse issues template", "error", err) @@ -290,10 +317,16 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) { Pulls []giteaclient.PullRequest Orgs []string SelectedOrg string + HasMore bool + NextPage int Error string } selectedOrg := r.URL.Query().Get("org") + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } data := pullsData{ Orgs: orgNames, @@ -308,15 +341,38 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) { queryOrgs = []string{selectedOrg} } - prs, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs) + result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, "open", page) if err != nil { slog.Error("failed to list pull requests", "error", err) data.Error = "Error loading pull requests." } else { - data.Pulls = prs + data.Pulls = result.Pulls + data.HasMore = result.HasMore + if result.HasMore { + data.NextPage = page + 1 + } } } + // For HTMX infinite-scroll requests (page > 1), return only the card fragment. + if isHTMX(r) && page > 1 { + tmpl, err := template.ParseFiles("internal/templates/pulls.html") + if err != nil { + slog.Error("failed to parse pulls template", "error", err) + http.Error(w, "template error", http.StatusInternalServerError) + return + } + var buf strings.Builder + if err := tmpl.ExecuteTemplate(&buf, "cards", data); err != nil { + slog.Error("failed to execute pulls cards template", "error", err) + http.Error(w, "template error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, buf.String()) + return + } + tmpl, err := template.ParseFiles("internal/templates/pulls.html") if err != nil { slog.Error("failed to parse pulls template", "error", err) diff --git a/internal/templates/issues.html b/internal/templates/issues.html index 49451ac..c9ee485 100644 --- a/internal/templates/issues.html +++ b/internal/templates/issues.html @@ -1,3 +1,25 @@ +{{define "cards"}} + {{range .Issues}} +
No issues found.
{{else}}No open pull requests found.
{{else}}