Compare commits

..

1 Commits

Author SHA1 Message Date
agent-company ed638f52ce feat: add repo-level filter to issues and pulls list views
Add a repo dropdown to the filter bar that appears when an org is
selected. The dropdown lists all repos in the selected org and
filters issues/pulls to the chosen repo. Changing the org resets
the repo filter. Infinite scroll preserves the repo filter.

Closes leeworks-agents/gitea-mobile#83

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:10:19 +00:00
6 changed files with 24 additions and 102 deletions
+7 -15
View File
@@ -325,9 +325,8 @@ type PaginatedPulls struct {
// ListAllIssues fetches issues across all repos in the given orgs,
// using concurrent requests with a semaphore. Results are paginated.
// The label parameter filters issues by label name (empty string means no filter).
// 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) {
func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string, state string, page int, repoFilter string) (PaginatedIssues, error) {
if state == "" {
state = "open"
}
@@ -335,7 +334,7 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
page = 1
}
cacheKey := fmt.Sprintf("issues-%s-%s-%s-%s", state, strings.Join(orgs, ","), label, repoFilter)
cacheKey := fmt.Sprintf("issues-%s-%s-%s", state, strings.Join(orgs, ","), repoFilter)
var allIssues []Issue
if cached, ok := c.getFromCache(cacheKey); ok {
allIssues = cached.([]Issue)
@@ -375,9 +374,6 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
defer func() { <-sem }()
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)
if err != nil {
mu.Lock()
@@ -440,9 +436,8 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
}
// ListAllPullRequests fetches PRs across all repos in the given orgs.
// Results are paginated. The label parameter filters PRs by label name.
// 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) {
// Results are paginated. 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, repoFilter string) (PaginatedPulls, error) {
if state == "" {
state = "open"
}
@@ -450,7 +445,7 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
page = 1
}
cacheKey := fmt.Sprintf("pulls-%s-%s-%s-%s", state, strings.Join(orgs, ","), label, repoFilter)
cacheKey := fmt.Sprintf("pulls-%s-%s-%s", state, strings.Join(orgs, ","), repoFilter)
var allPRs []PullRequest
if cached, ok := c.getFromCache(cacheKey); ok {
allPRs = cached.([]PullRequest)
@@ -488,9 +483,6 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
defer func() { <-sem }()
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)
if err != nil {
mu.Lock()
@@ -555,7 +547,7 @@ func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string
// Collect all open issues across all pages.
var issues []Issue
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 {
return nil, fmt.Errorf("fetching issues for triage: %w", err)
}
@@ -568,7 +560,7 @@ func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string
// Collect all open PRs across all pages.
var prs []PullRequest
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 {
return nil, fmt.Errorf("fetching PRs for triage: %w", err)
}
+2 -17
View File
@@ -249,7 +249,6 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Orgs []string
SelectedOrg string
SelectedState string
SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool
@@ -262,7 +261,6 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
if selectedState == "" {
selectedState = "open"
}
selectedLabel := r.URL.Query().Get("label")
selectedRepo := r.URL.Query().Get("repo")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
@@ -273,7 +271,6 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Orgs: orgNames,
SelectedOrg: selectedOrg,
SelectedState: selectedState,
SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
}
@@ -296,7 +293,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
}
}
result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page, selectedRepo)
if err != nil {
slog.Error("failed to list issues", "error", err)
data.Error = "Error loading issues."
@@ -355,7 +352,6 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
Orgs []string
SelectedOrg string
SelectedState string
SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool
@@ -368,7 +364,6 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
if selectedState == "" {
selectedState = "open"
}
selectedLabel := r.URL.Query().Get("label")
selectedRepo := r.URL.Query().Get("repo")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
@@ -379,7 +374,6 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
Orgs: orgNames,
SelectedOrg: selectedOrg,
SelectedState: selectedState,
SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
}
@@ -401,7 +395,7 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
}
}
result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page, selectedRepo)
if err != nil {
slog.Error("failed to list pull requests", "error", err)
data.Error = "Error loading pull requests."
@@ -566,13 +560,6 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
}
}
// Fetch comments for this PR (Gitea uses the issues endpoint for PR comments).
comments, err := h.Client.GetIssueComments(r.Context(), token, owner, repo, index)
if err != nil {
slog.Warn("failed to fetch PR comments", "error", err, "owner", owner, "repo", repo, "index", index)
// Non-fatal: continue rendering without comments.
}
// Build the content HTML using the template.
tmpl, err := template.ParseFiles("internal/templates/pull_detail.html")
if err != nil {
@@ -584,13 +571,11 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
type templateData struct {
Pull *giteaclient.PullRequest
RenderedBody template.HTML
Comments []giteaclient.Comment
}
data := templateData{
Pull: pr,
RenderedBody: renderedBody,
Comments: comments,
}
var buf strings.Builder
+7 -39
View File
@@ -6,15 +6,16 @@
<form id="create-issue-form" hx-post="/issues" hx-swap="innerHTML" hx-target="#main-content">
<div class="form-group">
<label for="repo-select">Repository</label>
<input list="repo-options" id="repo-select" name="owner_repo"
placeholder="Type to search repositories..." required autocomplete="off">
<datalist id="repo-options">
<select id="repo-select" name="owner_repo" required>
<option value="">Select a repository...</option>
{{range $org, $repos := .Repos}}
<optgroup label="{{$org}}">
{{range $repos}}
<option value="{{.Owner.Login}}/{{.Name}}">{{.FullName}}</option>
{{end}}
</optgroup>
{{end}}
</datalist>
</select>
<input type="hidden" name="owner" id="owner-input">
<input type="hidden" name="repo" id="repo-input">
</div>
@@ -44,16 +45,9 @@
var repoInput = document.getElementById('repo-input');
var formError = document.getElementById('form-error');
// Build a set of valid repo values for validation.
var validRepos = {};
var options = document.getElementById('repo-options').options;
for (var i = 0; i < options.length; i++) {
validRepos[options[i].value] = true;
}
function splitOwnerRepo() {
var val = repoSelect.value;
if (val && val.indexOf('/') !== -1) {
if (val) {
var parts = val.split('/');
ownerInput.value = parts[0] || '';
repoInput.value = parts[1] || '';
@@ -63,31 +57,11 @@
}
}
var debounceTimer = null;
repoSelect.addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
splitOwnerRepo();
var labelSection = document.getElementById('label-section');
var labelList = document.getElementById('label-list');
if (ownerInput.value && repoInput.value && validRepos[repoSelect.value]) {
labelList.innerHTML = '<span class="empty">Loading labels...</span>';
labelSection.style.display = 'block';
htmx.ajax('GET', '/issues/new/labels?owner=' + encodeURIComponent(ownerInput.value) + '&repo=' + encodeURIComponent(repoInput.value), {target: '#label-list', swap: 'innerHTML'});
} else {
labelSection.style.display = 'none';
labelList.innerHTML = '';
}
}, 300);
});
// Also handle the change event for when a datalist option is selected directly.
repoSelect.addEventListener('change', function() {
clearTimeout(debounceTimer);
splitOwnerRepo();
var labelSection = document.getElementById('label-section');
var labelList = document.getElementById('label-list');
if (ownerInput.value && repoInput.value && validRepos[repoSelect.value]) {
if (ownerInput.value && repoInput.value) {
labelList.innerHTML = '<span class="empty">Loading labels...</span>';
labelSection.style.display = 'block';
htmx.ajax('GET', '/issues/new/labels?owner=' + encodeURIComponent(ownerInput.value) + '&repo=' + encodeURIComponent(repoInput.value), {target: '#label-list', swap: 'innerHTML'});
@@ -106,12 +80,6 @@
formError.style.display = 'block';
return false;
}
if (!validRepos[repoSelect.value]) {
evt.preventDefault();
formError.textContent = 'Please select a valid repository from the list.';
formError.style.display = 'block';
return false;
}
formError.style.display = 'none';
});
+4 -7
View File
@@ -14,7 +14,7 @@
</div>
{{end}}
{{if .HasMore}}
<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="scroll-sentinel" hx-get="/issues?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}&repo={{.SelectedRepo}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="spinner htmx-indicator"></div>
</div>
{{end}}
@@ -24,25 +24,22 @@
<h1>Issues</h1>
<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'],[name='label']">
<select name="org" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state']">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
{{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']">
<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']">
<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']">
<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']">
<option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option>
<option value="closed" {{if eq .SelectedState "closed"}}selected{{end}}>Closed</option>
</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'],[name='repo']">
</div>
{{if .Error}}
-17
View File
@@ -46,21 +46,4 @@
<button type="submit" class="btn btn-primary">Submit Review</button>
</form>
</div>
{{if .Comments}}
<h2>Comments</h2>
<div id="comments-list">
{{range .Comments}}
<div class="comment">
<div class="comment-header">
<strong>{{.User}}</strong>
<span>{{.CreatedAt}}</span>
</div>
<div class="comment-body">{{.Body}}</div>
</div>
{{end}}
</div>
{{else}}
<p class="empty" style="margin-top:1rem;">No comments yet.</p>
{{end}}
{{end}}
+4 -7
View File
@@ -17,7 +17,7 @@
</div>
{{end}}
{{if .HasMore}}
<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="scroll-sentinel" hx-get="/pulls?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}&repo={{.SelectedRepo}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="spinner htmx-indicator"></div>
</div>
{{end}}
@@ -27,25 +27,22 @@
<h1>Pull Requests</h1>
<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'],[name='label']">
<select name="org" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state']">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
{{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']">
<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']">
<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']">
<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']">
<option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option>
<option value="closed" {{if eq .SelectedState "closed"}}selected{{end}}>Closed</option>
</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'],[name='repo']">
</div>
{{if .Error}}