Merge pull request 'feat: add repo-level filter to issues and pulls list views' (#86) from feature/repo-filter-83 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled

This commit was merged in pull request #86.
This commit is contained in:
2026-03-27 15:07:51 +00:00
4 changed files with 78 additions and 14 deletions
+30 -6
View File
@@ -326,7 +326,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.
// The label parameter filters issues by label name (empty string means no filter). // 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) { // The repoFilter parameter narrows results to a single repo name (empty means all repos).
func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string, state string, page int, label string, repoFilter string) (PaginatedIssues, error) {
if state == "" { if state == "" {
state = "open" state = "open"
} }
@@ -334,7 +335,7 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
page = 1 page = 1
} }
cacheKey := fmt.Sprintf("issues-%s-%s-%s", state, strings.Join(orgs, ","), label) cacheKey := fmt.Sprintf("issues-%s-%s-%s-%s", state, strings.Join(orgs, ","), label, repoFilter)
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)
@@ -349,6 +350,17 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
allRepos = append(allRepos, repos...) allRepos = append(allRepos, repos...)
} }
// Filter to a single repo if specified.
if repoFilter != "" {
var filtered []Repo
for _, r := range allRepos {
if r.Name == repoFilter {
filtered = append(filtered, r)
}
}
allRepos = filtered
}
// Fan out issue fetching across repos. // Fan out issue fetching across repos.
var mu sync.Mutex var mu sync.Mutex
sem := make(chan struct{}, c.maxConcurrent) sem := make(chan struct{}, c.maxConcurrent)
@@ -429,7 +441,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. The label parameter filters PRs by label name. // 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, label string) (PaginatedPulls, error) { // The repoFilter parameter narrows results to a single repo name.
func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string, state string, page int, label string, repoFilter string) (PaginatedPulls, error) {
if state == "" { if state == "" {
state = "open" state = "open"
} }
@@ -437,7 +450,7 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
page = 1 page = 1
} }
cacheKey := fmt.Sprintf("pulls-%s-%s-%s", state, strings.Join(orgs, ","), label) cacheKey := fmt.Sprintf("pulls-%s-%s-%s-%s", state, strings.Join(orgs, ","), label, repoFilter)
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)
@@ -451,6 +464,17 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
allRepos = append(allRepos, repos...) allRepos = append(allRepos, repos...)
} }
// Filter to a single repo if specified.
if repoFilter != "" {
var filtered []Repo
for _, r := range allRepos {
if r.Name == repoFilter {
filtered = append(filtered, r)
}
}
allRepos = filtered
}
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
@@ -531,7 +555,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)
} }
@@ -544,7 +568,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)
} }
+30 -2
View File
@@ -250,6 +250,8 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
SelectedOrg string SelectedOrg string
SelectedState string SelectedState string
SelectedLabel string SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool HasMore bool
NextPage int NextPage int
Error string Error string
@@ -261,6 +263,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
selectedState = "open" selectedState = "open"
} }
selectedLabel := r.URL.Query().Get("label") selectedLabel := r.URL.Query().Get("label")
selectedRepo := r.URL.Query().Get("repo")
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
@@ -271,6 +274,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
SelectedOrg: selectedOrg, SelectedOrg: selectedOrg,
SelectedState: selectedState, SelectedState: selectedState,
SelectedLabel: selectedLabel, SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
} }
if len(orgNames) == 0 { if len(orgNames) == 0 {
@@ -280,9 +284,19 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
queryOrgs := orgNames queryOrgs := orgNames
if selectedOrg != "" { if selectedOrg != "" {
queryOrgs = []string{selectedOrg} queryOrgs = []string{selectedOrg}
// Populate repo list for the selected org.
repos, err := h.Client.ListOrgRepos(r.Context(), token, selectedOrg)
if err != nil {
slog.Warn("failed to list repos for org filter", "error", err, "org", selectedOrg)
} else {
for _, repo := range repos {
data.Repos = append(data.Repos, repo.Name)
}
}
} }
result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page, selectedLabel) result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
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."
@@ -342,6 +356,8 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
SelectedOrg string SelectedOrg string
SelectedState string SelectedState string
SelectedLabel string SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool HasMore bool
NextPage int NextPage int
Error string Error string
@@ -353,6 +369,7 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
selectedState = "open" selectedState = "open"
} }
selectedLabel := r.URL.Query().Get("label") selectedLabel := r.URL.Query().Get("label")
selectedRepo := r.URL.Query().Get("repo")
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
@@ -363,6 +380,7 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
SelectedOrg: selectedOrg, SelectedOrg: selectedOrg,
SelectedState: selectedState, SelectedState: selectedState,
SelectedLabel: selectedLabel, SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
} }
if len(orgNames) == 0 { if len(orgNames) == 0 {
@@ -371,9 +389,19 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
queryOrgs := orgNames queryOrgs := orgNames
if selectedOrg != "" { if selectedOrg != "" {
queryOrgs = []string{selectedOrg} queryOrgs = []string{selectedOrg}
// Populate repo list for the selected org.
repos, err := h.Client.ListOrgRepos(r.Context(), token, selectedOrg)
if err != nil {
slog.Warn("failed to list repos for org filter", "error", err, "org", selectedOrg)
} else {
for _, repo := range repos {
data.Repos = append(data.Repos, repo.Name)
}
}
} }
result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page, selectedLabel) result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
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."
+9 -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}}&label={{.SelectedLabel}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this"> <div class="scroll-sentinel" hx-get="/issues?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}&label={{.SelectedLabel}}&repo={{.SelectedRepo}}" 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}}
@@ -30,13 +30,19 @@
<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'],[name='label']"> {{if .Repos}}
<select name="repo" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='state'],[name='label']">
<option value="">All repos</option>
{{range .Repos}}<option value="{{.}}" {{if eq . $.SelectedRepo}}selected{{end}}>{{.}}</option>{{end}}
</select>
{{end}}
<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='repo'],[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}}" <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-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']"> hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='state'],[name='repo']">
</div> </div>
{{if .Error}} {{if .Error}}
+9 -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}}&label={{.SelectedLabel}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this"> <div class="scroll-sentinel" hx-get="/pulls?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}&label={{.SelectedLabel}}&repo={{.SelectedRepo}}" 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}}
@@ -33,13 +33,19 @@
<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'],[name='label']"> {{if .Repos}}
<select name="repo" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='state'],[name='label']">
<option value="">All repos</option>
{{range .Repos}}<option value="{{.}}" {{if eq . $.SelectedRepo}}selected{{end}}>{{.}}</option>{{end}}
</select>
{{end}}
<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='repo'],[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}}" <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-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']"> hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='state'],[name='repo']">
</div> </div>
{{if .Error}} {{if .Error}}