Compare commits

..

12 Commits

Author SHA1 Message Date
agent-company 047e90cd76 feat: display assignee avatar in issue list rows
Replace plain-text assignee login with a circular avatar image using
the existing .avatar CSS class. Includes title attribute for
accessibility. Unassigned issues show no avatar.

Closes leeworks-agents/gitea-mobile#98

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 03:06:00 +00:00
AI-Manager f1652bb77a Merge pull request 'feat: add close/reopen action to PR detail view' (#92) from feature/pr-close-reopen-91 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 20:42:09 +00:00
agent-company dbcfbe9138 feat: add close/reopen action to PR detail view
Add POST /pulls/{owner}/{repo}/{index}/state handler that reuses the
existing SetIssueState Gitea API call (PRs share the issues state
endpoint). The PR detail template now shows a Close PR / Reopen PR
button with HTMX for seamless state toggling without full page reload.
Also fixes the state badge to use the correct CSS class when a PR is
closed.

Closes leeworks-agents/gitea-mobile#91

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:05:21 +00:00
AI-Manager 732cedda3d Merge pull request 'fix: remove go.sum from Dockerfile COPY (no external deps)' (#90) from fix/dockerfile-go-sum-89 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 18:04:01 +00:00
agent-company 937da1962b fix: remove go.sum from Dockerfile COPY since project has no external dependencies
The project uses only Go stdlib with zero external dependencies, so go.sum
does not exist. The Dockerfile COPY instruction fails when go.sum is missing.

Closes leeworks-agents/gitea-mobile#89

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:03:03 +00:00
AI-Manager 42a61b4428 Merge pull request 'feat: make repo selector searchable on create issue form' (#88) from feature/searchable-repo-selector-87 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 16:43:35 +00:00
agent-company 96b9ef2f89 feat: make repo selector searchable on create issue form
Replace the plain <select> with an HTML5 <input> + <datalist> pair so
users can type to filter repositories. Add debounced input handler for
label loading, change event for direct datalist selection, and client-side
validation that the entered value is a known repository.

Closes leeworks-agents/gitea-mobile#87

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:04:07 +00:00
AI-Manager 338b62c294 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
2026-03-27 15:07:51 +00:00
agent-company 6033278a86 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 15:07:31 +00:00
AI-Manager 40f5498402 Merge pull request 'feat: add label filter to issues and pulls list views' (#85) from feature/label-filter-82 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 15:03:47 +00:00
AI-Manager f15425d7f2 Merge pull request 'feat: add comments thread to PR detail view' (#84) from feature/pr-comments-81 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 15:03:33 +00:00
agent-company 3b17d76960 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>
2026-03-27 14:07:46 +00:00
7 changed files with 199 additions and 25 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /gitea-mobile ./cmd/server
+38 -7
View File
@@ -325,7 +325,9 @@ type PaginatedPulls struct {
// 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) {
// 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) {
if state == "" {
state = "open"
}
@@ -333,7 +335,7 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
page = 1
}
cacheKey := fmt.Sprintf("issues-%s-%s", state, strings.Join(orgs, ","))
cacheKey := fmt.Sprintf("issues-%s-%s-%s-%s", state, strings.Join(orgs, ","), label, repoFilter)
var allIssues []Issue
if cached, ok := c.getFromCache(cacheKey); ok {
allIssues = cached.([]Issue)
@@ -348,6 +350,17 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
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.
var mu sync.Mutex
sem := make(chan struct{}, c.maxConcurrent)
@@ -362,6 +375,9 @@ 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()
@@ -424,8 +440,9 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
}
// 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) {
// 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) {
if state == "" {
state = "open"
}
@@ -433,7 +450,7 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
page = 1
}
cacheKey := fmt.Sprintf("pulls-%s-%s", state, strings.Join(orgs, ","))
cacheKey := fmt.Sprintf("pulls-%s-%s-%s-%s", state, strings.Join(orgs, ","), label, repoFilter)
var allPRs []PullRequest
if cached, ok := c.getFromCache(cacheKey); ok {
allPRs = cached.([]PullRequest)
@@ -447,6 +464,17 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
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
sem := make(chan struct{}, c.maxConcurrent)
var wg sync.WaitGroup
@@ -460,6 +488,9 @@ 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()
@@ -524,7 +555,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)
}
@@ -537,7 +568,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)
}
+84 -2
View File
@@ -55,6 +55,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /pulls", h.ListPulls)
mux.HandleFunc("GET /pulls/{owner}/{repo}/{index}", h.PullDetail)
mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/review", h.SubmitReview)
mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/state", h.SetPullState)
// Settings (handled separately for auth bypass).
settingsHandler := &SettingsHandler{
@@ -249,6 +250,9 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Orgs []string
SelectedOrg string
SelectedState string
SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool
NextPage int
Error string
@@ -259,6 +263,8 @@ 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 {
page = 1
@@ -268,6 +274,8 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Orgs: orgNames,
SelectedOrg: selectedOrg,
SelectedState: selectedState,
SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
}
if len(orgNames) == 0 {
@@ -277,9 +285,19 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
queryOrgs := orgNames
if 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)
result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
if err != nil {
slog.Error("failed to list issues", "error", err)
data.Error = "Error loading issues."
@@ -338,6 +356,9 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
Orgs []string
SelectedOrg string
SelectedState string
SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool
NextPage int
Error string
@@ -348,6 +369,8 @@ 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 {
page = 1
@@ -357,6 +380,8 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
Orgs: orgNames,
SelectedOrg: selectedOrg,
SelectedState: selectedState,
SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
}
if len(orgNames) == 0 {
@@ -365,9 +390,19 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
queryOrgs := orgNames
if 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)
result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
if err != nil {
slog.Error("failed to list pull requests", "error", err)
data.Error = "Error loading pull requests."
@@ -852,6 +887,53 @@ func (h *Handler) SetIssueState(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}
// SetPullState handles POST /pulls/{owner}/{repo}/{index}/state.
func (h *Handler) SetPullState(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 pull request 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 pull request state", "error", err, "owner", owner, "repo", repo, "index", index, "state", state)
http.Error(w, "failed to update pull request 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="pull-state">closed</span>
<button class="btn btn-secondary" hx-post="/pulls/%s/%s/%d/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen PR</button>`,
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
} else {
fmt.Fprintf(w, `<span class="state-open" id="pull-state">open</span>
<button class="btn btn-danger" hx-post="/pulls/%s/%s/%d/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close PR</button>`,
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
}
return
}
http.Redirect(w, r, fmt.Sprintf("/pulls/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}
// AddComment handles POST /issues/{owner}/{repo}/{index}/comment.
func (h *Handler) AddComment(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
+40 -8
View File
@@ -6,16 +6,15 @@
<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>
<select id="repo-select" name="owner_repo" required>
<option value="">Select a repository...</option>
<input list="repo-options" id="repo-select" name="owner_repo"
placeholder="Type to search repositories..." required autocomplete="off">
<datalist id="repo-options">
{{range $org, $repos := .Repos}}
<optgroup label="{{$org}}">
{{range $repos}}
<option value="{{.Owner.Login}}/{{.Name}}">{{.FullName}}</option>
{{end}}
</optgroup>
{{end}}
</select>
</datalist>
<input type="hidden" name="owner" id="owner-input">
<input type="hidden" name="repo" id="repo-input">
</div>
@@ -45,9 +44,16 @@
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) {
if (val && val.indexOf('/') !== -1) {
var parts = val.split('/');
ownerInput.value = parts[0] || '';
repoInput.value = parts[1] || '';
@@ -57,11 +63,31 @@
}
}
repoSelect.addEventListener('change', function() {
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) {
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]) {
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'});
@@ -80,6 +106,12 @@
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';
});
+13 -4
View File
@@ -8,13 +8,13 @@
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
{{if .Assignee}}
<span>{{.Assignee.Login}}</span>
<img src="{{.Assignee.AvatarURL}}" alt="{{.Assignee.Login}}" class="avatar" title="Assigned to {{.Assignee.Login}}">
{{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="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>
{{end}}
@@ -24,16 +24,25 @@
<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']">
<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>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</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']">
{{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="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}}
+12 -1
View File
@@ -4,7 +4,7 @@
<div class="card">
<div class="card-meta">
<span class="type-badge type-pull">PR</span>
<span class="state-open">{{.Pull.State}}</span>
{{if eq .Pull.State "closed"}}<span class="state-closed">{{.Pull.State}}</span>{{else}}<span class="state-open">{{.Pull.State}}</span>{{end}}
<span>{{.Pull.RepoOwner}}/{{.Pull.RepoName}} #{{.Pull.Number}}</span>
{{range .Pull.Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
@@ -15,6 +15,17 @@
<span class="diff-del">-{{.Pull.Deletions}}</span>
{{if .Pull.Mergeable}}<span style="color:var(--accent-green);">Mergeable</span>{{end}}
</div>
<div class="card-meta" style="margin-top:0.5rem;">
<span id="state-section">
{{if eq .Pull.State "closed"}}
<span class="state-closed" id="pull-state">{{.Pull.State}}</span>
<button class="btn btn-secondary" hx-post="/pulls/{{.Pull.RepoOwner}}/{{.Pull.RepoName}}/{{.Pull.Number}}/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen PR</button>
{{else}}
<span class="state-open" id="pull-state">{{.Pull.State}}</span>
<button class="btn btn-danger" hx-post="/pulls/{{.Pull.RepoOwner}}/{{.Pull.RepoName}}/{{.Pull.Number}}/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close PR</button>
{{end}}
</span>
</div>
{{if .RenderedBody}}
<div class="card-body markdown-body">{{.RenderedBody}}</div>
{{else if .Pull.Body}}
+12 -3
View File
@@ -17,7 +17,7 @@
</div>
{{end}}
{{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}}&repo={{.SelectedRepo}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="spinner htmx-indicator"></div>
</div>
{{end}}
@@ -27,16 +27,25 @@
<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']">
<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>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</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']">
{{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="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}}