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
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAllIssues fetches all open issues across all repos in the given orgs,
|
// PageSize is the number of items returned per page for paginated listings.
|
||||||
// using concurrent requests with a semaphore.
|
const PageSize = 20
|
||||||
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.
|
// PaginatedIssues holds a page of issues along with pagination metadata.
|
||||||
var allRepos []Repo
|
type PaginatedIssues struct {
|
||||||
for _, org := range orgs {
|
Issues []Issue
|
||||||
repos, err := c.ListOrgRepos(ctx, token, org)
|
HasMore bool
|
||||||
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.
|
// PaginatedPulls holds a page of pull requests along with pagination metadata.
|
||||||
func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string) ([]PullRequest, error) {
|
type PaginatedPulls struct {
|
||||||
cacheKey := fmt.Sprintf("pulls-%s", strings.Join(orgs, ","))
|
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 {
|
if cached, ok := c.getFromCache(cacheKey); ok {
|
||||||
return cached.([]PullRequest), nil
|
allIssues = cached.([]Issue)
|
||||||
}
|
} else {
|
||||||
|
// First, collect all repos for the given orgs.
|
||||||
var allRepos []Repo
|
var allRepos []Repo
|
||||||
for _, org := range orgs {
|
for _, org := range orgs {
|
||||||
repos, err := c.ListOrgRepos(ctx, token, org)
|
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 {
|
if err != nil {
|
||||||
mu.Lock()
|
return PaginatedIssues{}, fmt.Errorf("listing repos for %s: %w", org, err)
|
||||||
if firstErr == nil {
|
}
|
||||||
firstErr = fmt.Errorf("fetching PRs for %s: %w", r.FullName, 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()
|
defer resp.Body.Close()
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var prs []PullRequest
|
var issues []Issue
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&prs); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
if firstErr == nil {
|
if firstErr == nil {
|
||||||
firstErr = fmt.Errorf("decoding PRs for %s: %w", r.FullName, err)
|
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()
|
mu.Unlock()
|
||||||
return
|
}(repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range prs {
|
wg.Wait()
|
||||||
prs[i].RepoOwner = r.Owner.Login
|
|
||||||
prs[i].RepoName = r.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
if firstErr != nil {
|
||||||
allPRs = append(allPRs, prs...)
|
return PaginatedIssues{}, firstErr
|
||||||
mu.Unlock()
|
}
|
||||||
}(repo)
|
|
||||||
|
// 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()
|
// Paginate.
|
||||||
|
start := (page - 1) * PageSize
|
||||||
if firstErr != nil {
|
if start >= len(allIssues) {
|
||||||
return nil, firstErr
|
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 PaginatedIssues{Issues: allIssues[start:end], HasMore: hasMore}, nil
|
||||||
return allPRs[i].UpdatedAt.After(allPRs[j].UpdatedAt)
|
}
|
||||||
})
|
|
||||||
|
|
||||||
c.setCache(cacheKey, allPRs)
|
// ListAllPullRequests fetches PRs across all repos in the given orgs.
|
||||||
return allPRs, nil
|
// 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.
|
// GetTriageQueue returns unassigned issues and PRs needing review, sorted by priority.
|
||||||
func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string) ([]TriageItem, error) {
|
func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string) ([]TriageItem, error) {
|
||||||
issues, err := c.ListAllIssues(ctx, token, orgs)
|
// Collect all open issues across all pages.
|
||||||
if err != nil {
|
var issues []Issue
|
||||||
return nil, fmt.Errorf("fetching issues for triage: %w", err)
|
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)
|
// Collect all open PRs across all pages.
|
||||||
if err != nil {
|
var prs []PullRequest
|
||||||
return nil, fmt.Errorf("fetching PRs for triage: %w", err)
|
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
|
var queue []TriageItem
|
||||||
|
|||||||
@@ -239,6 +239,10 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
|||||||
if selectedState == "" {
|
if selectedState == "" {
|
||||||
selectedState = "open"
|
selectedState = "open"
|
||||||
}
|
}
|
||||||
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
data := issuesData{
|
data := issuesData{
|
||||||
Orgs: orgNames,
|
Orgs: orgNames,
|
||||||
@@ -255,15 +259,38 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
|||||||
queryOrgs = []string{selectedOrg}
|
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 {
|
if err != nil {
|
||||||
slog.Error("failed to list issues", "error", err)
|
slog.Error("failed to list issues", "error", err)
|
||||||
data.Error = "Error loading issues."
|
data.Error = "Error loading issues."
|
||||||
} else {
|
} 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")
|
tmpl, err := template.ParseFiles("internal/templates/issues.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to parse issues template", "error", err)
|
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
|
Pulls []giteaclient.PullRequest
|
||||||
Orgs []string
|
Orgs []string
|
||||||
SelectedOrg string
|
SelectedOrg string
|
||||||
|
HasMore bool
|
||||||
|
NextPage int
|
||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedOrg := r.URL.Query().Get("org")
|
selectedOrg := r.URL.Query().Get("org")
|
||||||
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
data := pullsData{
|
data := pullsData{
|
||||||
Orgs: orgNames,
|
Orgs: orgNames,
|
||||||
@@ -308,15 +341,38 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
|
|||||||
queryOrgs = []string{selectedOrg}
|
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 {
|
if err != nil {
|
||||||
slog.Error("failed to list pull requests", "error", err)
|
slog.Error("failed to list pull requests", "error", err)
|
||||||
data.Error = "Error loading pull requests."
|
data.Error = "Error loading pull requests."
|
||||||
} else {
|
} 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")
|
tmpl, err := template.ParseFiles("internal/templates/pulls.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to parse pulls template", "error", err)
|
slog.Error("failed to parse pulls template", "error", err)
|
||||||
|
|||||||
@@ -1,3 +1,25 @@
|
|||||||
|
{{define "cards"}}
|
||||||
|
{{range .Issues}}
|
||||||
|
<div class="card" hx-get="/issues/{{.RepoOwner}}/{{.RepoName}}/{{.Number}}" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
|
||||||
|
<div class="card-title">{{.Title}}</div>
|
||||||
|
<div class="card-meta">
|
||||||
|
<span>{{.RepoOwner}}/{{.RepoName}} #{{.Number}}</span>
|
||||||
|
{{range .Labels}}
|
||||||
|
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
|
||||||
|
{{end}}
|
||||||
|
{{if .Assignee}}
|
||||||
|
<span>{{.Assignee.Login}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .HasMore}}
|
||||||
|
<div class="scroll-sentinel" hx-get="/issues?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
|
||||||
|
<div class="spinner htmx-indicator"></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Issues</h1>
|
<h1>Issues</h1>
|
||||||
|
|
||||||
@@ -20,25 +42,7 @@
|
|||||||
<p class="empty">No issues found.</p>
|
<p class="empty">No issues found.</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div id="issue-list">
|
<div id="issue-list">
|
||||||
{{range .Issues}}
|
{{template "cards" .}}
|
||||||
<div class="card" hx-get="/issues/{{.RepoOwner}}/{{.RepoName}}/{{.Number}}" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
|
|
||||||
<div class="card-title">{{.Title}}</div>
|
|
||||||
<div class="card-meta">
|
|
||||||
<span>{{.RepoOwner}}/{{.RepoName}} #{{.Number}}</span>
|
|
||||||
{{range .Labels}}
|
|
||||||
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
|
|
||||||
{{end}}
|
|
||||||
{{if .Assignee}}
|
|
||||||
<span>{{.Assignee.Login}}</span>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{if .HasMore}}
|
|
||||||
<div class="scroll-sentinel" hx-get="/issues?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
|
|
||||||
<div class="spinner htmx-indicator"></div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,3 +1,28 @@
|
|||||||
|
{{define "cards"}}
|
||||||
|
{{range .Pulls}}
|
||||||
|
<div class="card" hx-get="/pulls/{{.RepoOwner}}/{{.RepoName}}/{{.Number}}" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
|
||||||
|
<div class="card-title">
|
||||||
|
<span class="type-badge type-pull">PR</span>
|
||||||
|
{{.Title}}
|
||||||
|
</div>
|
||||||
|
<div class="card-meta">
|
||||||
|
<span>{{.RepoOwner}}/{{.RepoName}} #{{.Number}}</span>
|
||||||
|
{{range .Labels}}
|
||||||
|
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
|
||||||
|
{{end}}
|
||||||
|
<span class="diff-add">+{{.Additions}}</span>
|
||||||
|
<span class="diff-del">-{{.Deletions}}</span>
|
||||||
|
{{if .Mergeable}}<span style="color:var(--accent-green);font-size:0.7rem;">mergeable</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .HasMore}}
|
||||||
|
<div class="scroll-sentinel" hx-get="/pulls?page={{.NextPage}}&org={{.SelectedOrg}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
|
||||||
|
<div class="spinner htmx-indicator"></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Pull Requests</h1>
|
<h1>Pull Requests</h1>
|
||||||
|
|
||||||
@@ -16,23 +41,7 @@
|
|||||||
<p class="empty">No open pull requests found.</p>
|
<p class="empty">No open pull requests found.</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div id="pull-list">
|
<div id="pull-list">
|
||||||
{{range .Pulls}}
|
{{template "cards" .}}
|
||||||
<div class="card" hx-get="/pulls/{{.RepoOwner}}/{{.RepoName}}/{{.Number}}" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
|
|
||||||
<div class="card-title">
|
|
||||||
<span class="type-badge type-pull">PR</span>
|
|
||||||
{{.Title}}
|
|
||||||
</div>
|
|
||||||
<div class="card-meta">
|
|
||||||
<span>{{.RepoOwner}}/{{.RepoName}} #{{.Number}}</span>
|
|
||||||
{{range .Labels}}
|
|
||||||
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
|
|
||||||
{{end}}
|
|
||||||
<span class="diff-add">+{{.Additions}}</span>
|
|
||||||
<span class="diff-del">-{{.Deletions}}</span>
|
|
||||||
{{if .Mergeable}}<span style="color:var(--accent-green);font-size:0.7rem;">mergeable</span>{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user