Compare commits

..

1 Commits

Author SHA1 Message Date
agent-company a707646200 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>
2026-03-27 02:08:54 +00:00
7 changed files with 328 additions and 386 deletions
+97 -37
View File
@@ -308,26 +308,47 @@ 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, ",")) // PaginatedIssues holds a page of issues along with pagination metadata.
if cached, ok := c.getFromCache(cacheKey); ok { type PaginatedIssues struct {
return cached.([]Issue), nil 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. // 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 { if err != nil {
return nil, fmt.Errorf("listing repos for %s: %w", org, err) return PaginatedIssues{}, fmt.Errorf("listing repos for %s: %w", org, err)
} }
allRepos = append(allRepos, repos...) allRepos = append(allRepos, repos...)
} }
// Fan out issue fetching across repos. // Fan out issue fetching across repos.
var allIssues []Issue
var mu sync.Mutex var mu sync.Mutex
sem := make(chan struct{}, c.maxConcurrent) sem := make(chan struct{}, c.maxConcurrent)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -340,7 +361,7 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string)
sem <- struct{}{} sem <- struct{}{}
defer func() { <-sem }() defer func() { <-sem }()
path := fmt.Sprintf("/repos/%s/issues?state=open&type=issues&limit=50", r.FullName) 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) resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil { if err != nil {
mu.Lock() mu.Lock()
@@ -377,7 +398,7 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string)
wg.Wait() wg.Wait()
if firstErr != nil { if firstErr != nil {
return nil, firstErr return PaginatedIssues{}, firstErr
} }
// Sort by updated time, newest first. // Sort by updated time, newest first.
@@ -386,26 +407,46 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string)
}) })
c.setCache(cacheKey, allIssues) c.setCache(cacheKey, allIssues)
return allIssues, nil
} }
// ListAllPullRequests fetches all open PRs across all repos in the given orgs. // Paginate.
func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string) ([]PullRequest, error) { start := (page - 1) * PageSize
cacheKey := fmt.Sprintf("pulls-%s", strings.Join(orgs, ",")) 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 { if cached, ok := c.getFromCache(cacheKey); ok {
return cached.([]PullRequest), nil allPRs = cached.([]PullRequest)
} } else {
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 { if err != nil {
return nil, fmt.Errorf("listing repos for %s: %w", org, err) return PaginatedPulls{}, fmt.Errorf("listing repos for %s: %w", org, err)
} }
allRepos = append(allRepos, repos...) allRepos = append(allRepos, repos...)
} }
var allPRs []PullRequest
var mu sync.Mutex var mu sync.Mutex
sem := make(chan struct{}, c.maxConcurrent) sem := make(chan struct{}, c.maxConcurrent)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -418,7 +459,7 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
sem <- struct{}{} sem <- struct{}{}
defer func() { <-sem }() defer func() { <-sem }()
path := fmt.Sprintf("/repos/%s/pulls?state=open&limit=50", r.FullName) path := fmt.Sprintf("/repos/%s/pulls?state=%s&limit=50", r.FullName, state)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil { if err != nil {
mu.Lock() mu.Lock()
@@ -454,7 +495,7 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
wg.Wait() wg.Wait()
if firstErr != nil { if firstErr != nil {
return nil, firstErr return PaginatedPulls{}, firstErr
} }
sort.Slice(allPRs, func(i, j int) bool { sort.Slice(allPRs, func(i, j int) bool {
@@ -462,20 +503,49 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
}) })
c.setCache(cacheKey, allPRs) c.setCache(cacheKey, allPRs)
return allPRs, nil }
// 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.
var issues []Issue
for page := 1; ; page++ {
result, err := c.ListAllIssues(ctx, token, orgs, "open", page)
if err != nil { if err != nil {
return nil, fmt.Errorf("fetching issues for triage: %w", err) 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.
var prs []PullRequest
for page := 1; ; page++ {
result, err := c.ListAllPullRequests(ctx, token, orgs, "open", page)
if err != nil { if err != nil {
return nil, fmt.Errorf("fetching PRs for triage: %w", err) 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
@@ -708,20 +778,15 @@ func (c *Client) SubmitReview(ctx context.Context, token, owner, repo string, in
// CloseIssue closes an issue by setting its state to "closed". // CloseIssue closes an issue by setting its state to "closed".
func (c *Client) CloseIssue(ctx context.Context, token, owner, repo string, index int64) error { func (c *Client) CloseIssue(ctx context.Context, token, owner, repo string, index int64) error {
return c.SetIssueState(ctx, token, owner, repo, index, "closed") payload, err := json.Marshal(map[string]string{"state": "closed"})
}
// SetIssueState sets an issue's state (e.g. "open" or "closed").
func (c *Client) SetIssueState(ctx context.Context, token, owner, repo string, index int64, state string) error {
payload, err := json.Marshal(map[string]string{"state": state})
if err != nil { if err != nil {
return fmt.Errorf("marshaling state change: %w", err) return fmt.Errorf("marshaling close request: %w", err)
} }
path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index) path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodPatch, path, strings.NewReader(string(payload))) resp, err := c.doRequest(ctx, token, http.MethodPatch, path, strings.NewReader(string(payload)))
if err != nil { if err != nil {
return fmt.Errorf("setting issue state: %w", err) return fmt.Errorf("closing issue: %w", err)
} }
resp.Body.Close() resp.Body.Close()
@@ -729,11 +794,6 @@ func (c *Client) SetIssueState(ctx context.Context, token, owner, repo string, i
return nil return nil
} }
// AddComment creates a comment on an issue and returns the created Comment.
func (c *Client) AddComment(ctx context.Context, token, owner, repo string, index int64, body string) (*Comment, error) {
return c.PostComment(ctx, token, owner, repo, index, body)
}
// PostComment creates a comment on an issue and returns the created Comment. // 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) { 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}) payload, err := json.Marshal(map[string]string{"body": body})
-70
View File
@@ -241,76 +241,6 @@ func TestCloseIssue(t *testing.T) {
} }
} }
func TestSetIssueState(t *testing.T) {
tests := []struct {
name string
state string
}{
{"close", "closed"},
{"reopen", "open"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
t.Errorf("expected PATCH, got %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/owner1/repo1/issues/42" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
if body["state"] != tt.state {
t.Errorf("expected state=%q, got %q", tt.state, body["state"])
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"state": tt.state})
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("issues-org1", "should-be-invalidated")
err := c.SetIssueState(context.Background(), "test-token", "owner1", "repo1", 42, tt.state)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
_, ok := c.getFromCache("issues-org1")
if ok {
t.Error("expected cache to be invalidated after SetIssueState")
}
})
}
}
func TestAddComment(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
comment := map[string]interface{}{
"id": 1,
"body": "test",
"user": map[string]string{"login": "testuser"},
"created_at": "2026-03-26T12:00:00Z",
}
json.NewEncoder(w).Encode(comment)
}))
defer server.Close()
c := NewClient(server.URL)
comment, err := c.AddComment(context.Background(), "test-token", "owner1", "repo1", 42, "test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if comment.Body != "test" {
t.Errorf("comment.Body = %q, want %q", comment.Body, "test")
}
}
func TestPostComment(t *testing.T) { func TestPostComment(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
+60 -53
View File
@@ -42,8 +42,6 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /issues", h.CreateIssue) mux.HandleFunc("POST /issues", h.CreateIssue)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/close", h.CloseIssue) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/close", h.CloseIssue)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/state", h.SetIssueState)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comments", h.AddComment)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comment", h.AddComment) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comment", h.AddComment)
// Issue detail. // Issue detail.
@@ -241,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,
@@ -257,14 +259,37 @@ 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 {
@@ -292,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,
@@ -310,14 +341,37 @@ 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 {
@@ -628,53 +682,6 @@ func (h *Handler) CloseIssue(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
} }
// SetIssueState handles POST /issues/{owner}/{repo}/{index}/state.
func (h *Handler) SetIssueState(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid issue index", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
state := r.FormValue("state")
if state != "open" && state != "closed" {
http.Error(w, "state must be 'open' or 'closed'", http.StatusBadRequest)
return
}
if err := h.Client.SetIssueState(r.Context(), token, owner, repo, index, state); err != nil {
slog.Error("failed to set issue state", "error", err, "owner", owner, "repo", repo, "index", index, "state", state)
http.Error(w, "failed to update issue state", http.StatusInternalServerError)
return
}
if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if state == "closed" {
fmt.Fprintf(w, `<span class="state-closed" id="issue-state">closed</span>
<button class="btn btn-secondary" hx-post="/issues/%s/%s/%d/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen Issue</button>`,
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
} else {
fmt.Fprintf(w, `<span class="state-open" id="issue-state">open</span>
<button class="btn btn-danger" hx-post="/issues/%s/%s/%d/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close Issue</button>`,
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
}
return
}
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}
// AddComment handles POST /issues/{owner}/{repo}/{index}/comment. // AddComment handles POST /issues/{owner}/{repo}/{index}/comment.
func (h *Handler) AddComment(w http.ResponseWriter, r *http.Request) { func (h *Handler) AddComment(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
-48
View File
@@ -135,54 +135,6 @@ func TestSubmitReview_MissingEventType(t *testing.T) {
} }
} }
func TestSetIssueState_InvalidState(t *testing.T) {
h := newTestHandler()
mux := http.NewServeMux()
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/state", h.SetIssueState)
req := httptest.NewRequest(http.MethodPost, "/issues/org/repo/1/state", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestSetIssueState_InvalidIndex(t *testing.T) {
h := newTestHandler()
mux := http.NewServeMux()
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/state", h.SetIssueState)
req := httptest.NewRequest(http.MethodPost, "/issues/org/repo/abc/state", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestAddComment_EmptyBody(t *testing.T) {
h := newTestHandler()
mux := http.NewServeMux()
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comments", h.AddComment)
req := httptest.NewRequest(http.MethodPost, "/issues/org/repo/1/comments", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func contains(s, substr string) bool { func contains(s, substr string) bool {
return len(s) >= len(substr) && searchString(s, substr) return len(s) >= len(substr) && searchString(s, substr)
} }
+1 -21
View File
@@ -3,15 +3,7 @@
<div class="card"> <div class="card">
<div class="card-meta"> <div class="card-meta">
<span id="state-section"> <span class="state-open">{{.Issue.State}}</span>
{{if eq .Issue.State "closed"}}
<span class="state-closed" id="issue-state">{{.Issue.State}}</span>
<button class="btn btn-secondary" hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen Issue</button>
{{else}}
<span class="state-open" id="issue-state">{{.Issue.State}}</span>
<button class="btn btn-danger" hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close Issue</button>
{{end}}
</span>
<span>{{.Issue.RepoOwner}}/{{.Issue.RepoName}} #{{.Issue.Number}}</span> <span>{{.Issue.RepoOwner}}/{{.Issue.RepoName}} #{{.Issue.Number}}</span>
{{range .Issue.Labels}} {{range .Issue.Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span> <span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
@@ -26,7 +18,6 @@
{{if .Comments}} {{if .Comments}}
<h2>Comments</h2> <h2>Comments</h2>
<div id="comments-list">
{{range .Comments}} {{range .Comments}}
<div class="comment"> <div class="comment">
<div class="comment-header"> <div class="comment-header">
@@ -36,19 +27,8 @@
<div class="comment-body">{{.Body}}</div> <div class="comment-body">{{.Body}}</div>
</div> </div>
{{end}} {{end}}
</div>
{{else}}
<div id="comments-list"></div>
{{end}} {{end}}
<div class="card" style="margin-top:1rem;">
<h2>Add Comment</h2>
<form hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/comments" hx-target="#comments-list" hx-swap="beforeend" hx-on::after-request="if(event.detail.successful) this.reset()">
<textarea name="body" rows="4" placeholder="Write a comment..." required style="width:100%;margin-bottom:0.5rem;"></textarea>
<button type="submit" class="btn btn-primary" style="width:auto;padding:0.5rem 1rem;">Comment</button>
</form>
</div>
<div class="card" style="margin-top:1rem;"> <div class="card" style="margin-top:1rem;">
<h2>Actions</h2> <h2>Actions</h2>
<form hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/labels" hx-swap="outerHTML" style="margin-bottom:0.5rem;"> <form hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/labels" hx-swap="outerHTML" style="margin-bottom:0.5rem;">
+23 -19
View File
@@ -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}}
+26 -17
View File
@@ -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}}