feat: add label filter to issues and pulls list views

Add a label text input to the filter bar on both issues and pulls
list views. The filter is passed through to the Gitea API's native
labels query parameter for server-side filtering. Infinite scroll
preserves the label filter across page loads.

Closes leeworks-agents/gitea-mobile#82

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent-company
2026-03-27 14:07:46 +00:00
parent 011addea5b
commit 3b17d76960
4 changed files with 34 additions and 15 deletions
+14 -7
View File
@@ -325,7 +325,8 @@ type PaginatedPulls struct {
// ListAllIssues fetches issues across all repos in the given orgs, // ListAllIssues fetches issues across all repos in the given orgs,
// using concurrent requests with a semaphore. Results are paginated. // 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) { // The label parameter filters issues by label name (empty string means no filter).
func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string, state string, page int, label string) (PaginatedIssues, error) {
if state == "" { if state == "" {
state = "open" state = "open"
} }
@@ -333,7 +334,7 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
page = 1 page = 1
} }
cacheKey := fmt.Sprintf("issues-%s-%s", state, strings.Join(orgs, ",")) cacheKey := fmt.Sprintf("issues-%s-%s-%s", state, strings.Join(orgs, ","), label)
var allIssues []Issue var allIssues []Issue
if cached, ok := c.getFromCache(cacheKey); ok { if cached, ok := c.getFromCache(cacheKey); ok {
allIssues = cached.([]Issue) allIssues = cached.([]Issue)
@@ -362,6 +363,9 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
defer func() { <-sem }() defer func() { <-sem }()
path := fmt.Sprintf("/repos/%s/issues?state=%s&type=issues&limit=50", r.FullName, state) path := fmt.Sprintf("/repos/%s/issues?state=%s&type=issues&limit=50", r.FullName, state)
if label != "" {
path += "&labels=" + label
}
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()
@@ -424,8 +428,8 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
} }
// ListAllPullRequests fetches PRs across all repos in the given orgs. // ListAllPullRequests fetches PRs across all repos in the given orgs.
// Results are paginated. // Results are paginated. The label parameter filters PRs by label name.
func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string, state string, page int) (PaginatedPulls, error) { func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string, state string, page int, label string) (PaginatedPulls, error) {
if state == "" { if state == "" {
state = "open" state = "open"
} }
@@ -433,7 +437,7 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
page = 1 page = 1
} }
cacheKey := fmt.Sprintf("pulls-%s-%s", state, strings.Join(orgs, ",")) cacheKey := fmt.Sprintf("pulls-%s-%s-%s", state, strings.Join(orgs, ","), label)
var allPRs []PullRequest var allPRs []PullRequest
if cached, ok := c.getFromCache(cacheKey); ok { if cached, ok := c.getFromCache(cacheKey); ok {
allPRs = cached.([]PullRequest) allPRs = cached.([]PullRequest)
@@ -460,6 +464,9 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
defer func() { <-sem }() defer func() { <-sem }()
path := fmt.Sprintf("/repos/%s/pulls?state=%s&limit=50", r.FullName, state) path := fmt.Sprintf("/repos/%s/pulls?state=%s&limit=50", r.FullName, state)
if label != "" {
path += "&labels=" + label
}
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()
@@ -524,7 +531,7 @@ func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string
// Collect all open issues across all pages. // Collect all open issues across all pages.
var issues []Issue var issues []Issue
for page := 1; ; page++ { for page := 1; ; page++ {
result, err := c.ListAllIssues(ctx, token, orgs, "open", 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)
} }
@@ -537,7 +544,7 @@ func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string
// Collect all open PRs across all pages. // Collect all open PRs across all pages.
var prs []PullRequest var prs []PullRequest
for page := 1; ; page++ { for page := 1; ; page++ {
result, err := c.ListAllPullRequests(ctx, token, orgs, "open", 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)
} }
+8 -2
View File
@@ -249,6 +249,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Orgs []string Orgs []string
SelectedOrg string SelectedOrg string
SelectedState string SelectedState string
SelectedLabel string
HasMore bool HasMore bool
NextPage int NextPage int
Error string Error string
@@ -259,6 +260,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
if selectedState == "" { if selectedState == "" {
selectedState = "open" selectedState = "open"
} }
selectedLabel := r.URL.Query().Get("label")
page, _ := strconv.Atoi(r.URL.Query().Get("page")) page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 { if page < 1 {
page = 1 page = 1
@@ -268,6 +270,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Orgs: orgNames, Orgs: orgNames,
SelectedOrg: selectedOrg, SelectedOrg: selectedOrg,
SelectedState: selectedState, SelectedState: selectedState,
SelectedLabel: selectedLabel,
} }
if len(orgNames) == 0 { if len(orgNames) == 0 {
@@ -279,7 +282,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
queryOrgs = []string{selectedOrg} queryOrgs = []string{selectedOrg}
} }
result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page) result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page, selectedLabel)
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."
@@ -338,6 +341,7 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
Orgs []string Orgs []string
SelectedOrg string SelectedOrg string
SelectedState string SelectedState string
SelectedLabel string
HasMore bool HasMore bool
NextPage int NextPage int
Error string Error string
@@ -348,6 +352,7 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
if selectedState == "" { if selectedState == "" {
selectedState = "open" selectedState = "open"
} }
selectedLabel := r.URL.Query().Get("label")
page, _ := strconv.Atoi(r.URL.Query().Get("page")) page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 { if page < 1 {
page = 1 page = 1
@@ -357,6 +362,7 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
Orgs: orgNames, Orgs: orgNames,
SelectedOrg: selectedOrg, SelectedOrg: selectedOrg,
SelectedState: selectedState, SelectedState: selectedState,
SelectedLabel: selectedLabel,
} }
if len(orgNames) == 0 { if len(orgNames) == 0 {
@@ -367,7 +373,7 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
queryOrgs = []string{selectedOrg} queryOrgs = []string{selectedOrg}
} }
result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page) result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page, selectedLabel)
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."
+6 -3
View File
@@ -14,7 +14,7 @@
</div> </div>
{{end}} {{end}}
{{if .HasMore}} {{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="scroll-sentinel" hx-get="/issues?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}&label={{.SelectedLabel}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="spinner htmx-indicator"></div> <div class="spinner htmx-indicator"></div>
</div> </div>
{{end}} {{end}}
@@ -24,16 +24,19 @@
<h1>Issues</h1> <h1>Issues</h1>
<div class="filter-bar"> <div class="filter-bar">
<select name="org" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state']"> <select name="org" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state'],[name='label']">
<option value="">All orgs</option> <option value="">All orgs</option>
{{range .Orgs}} {{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option> <option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}} {{end}}
</select> </select>
<select name="state" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org']"> <select name="state" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='label']">
<option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option> <option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option>
<option value="closed" {{if eq .SelectedState "closed"}}selected{{end}}>Closed</option> <option value="closed" {{if eq .SelectedState "closed"}}selected{{end}}>Closed</option>
</select> </select>
<input type="text" name="label" placeholder="Filter by label..." value="{{.SelectedLabel}}"
hx-get="/issues" hx-trigger="input changed delay:400ms" hx-target="#main-content"
hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='state']">
</div> </div>
{{if .Error}} {{if .Error}}
+6 -3
View File
@@ -17,7 +17,7 @@
</div> </div>
{{end}} {{end}}
{{if .HasMore}} {{if .HasMore}}
<div class="scroll-sentinel" hx-get="/pulls?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this"> <div class="scroll-sentinel" hx-get="/pulls?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}&label={{.SelectedLabel}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="spinner htmx-indicator"></div> <div class="spinner htmx-indicator"></div>
</div> </div>
{{end}} {{end}}
@@ -27,16 +27,19 @@
<h1>Pull Requests</h1> <h1>Pull Requests</h1>
<div class="filter-bar"> <div class="filter-bar">
<select name="org" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state']"> <select name="org" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state'],[name='label']">
<option value="">All orgs</option> <option value="">All orgs</option>
{{range .Orgs}} {{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option> <option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}} {{end}}
</select> </select>
<select name="state" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org']"> <select name="state" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='label']">
<option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option> <option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option>
<option value="closed" {{if eq .SelectedState "closed"}}selected{{end}}>Closed</option> <option value="closed" {{if eq .SelectedState "closed"}}selected{{end}}>Closed</option>
</select> </select>
<input type="text" name="label" placeholder="Filter by label..." value="{{.SelectedLabel}}"
hx-get="/pulls" hx-trigger="input changed delay:400ms" hx-target="#main-content"
hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='state']">
</div> </div>
{{if .Error}} {{if .Error}}