Compare commits

..

6 Commits

Author SHA1 Message Date
agent-company c98b73bf39 feat: add label multi-select to Create Issue form
Add dynamic label selection when creating a new issue:
- New GET /issues/new/labels endpoint returns label checkboxes for a repo
- When a repo is selected, labels are fetched via HTMX and displayed as
  checkboxes with colored label badges
- CreateIssue handler now parses label_ids from form and passes them to
  the Gitea API
- Shows "No labels available" message when repo has no labels

Closes leeworks-agents/gitea-mobile#67

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 04:13:55 +00:00
AI-Manager 88efe831bc Merge pull request 'feat: add backend pagination for infinite scroll in issues and pulls' (#66) from feature/pagination-infinite-scroll into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 04:05:57 +00:00
AI-Manager 65863a3098 Merge pull request 'feat: add close/reopen and comment actions to issue detail view' (#65) from feature/close-comment-actions into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 04:05:52 +00:00
AI-Manager b8f217b9b8 Merge pull request 'feat: add pull-to-refresh button for all list views' (#64) from feature/pull-to-refresh into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 04:05:47 +00:00
agent-company bcd61ff139 feat: add close/reopen and comment actions to issue detail view
Add SetIssueState client method and handler for toggling issue state
between open and closed via PATCH API. Add AddComment client method
wrapping PostComment. Register new routes POST /issues/{owner}/{repo}/{index}/state
and POST /issues/{owner}/{repo}/{index}/comments. Update issue_detail.html
template with comment form (HTMX inline append) and close/reopen button
(HTMX inline swap of state badge).

Closes leeworks-agents/gitea-mobile#29

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:07:58 +00:00
agent-company 74a5426755 feat: add pull-to-refresh button to top bar for all list views
Add a sticky top bar with a refresh button that uses HTMX to re-fetch
the current view content without a full page reload. Works on dashboard,
issues, and pulls views via the shared layout template.

Closes leeworks-agents/gitea-mobile#51

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:07:01 +00:00
8 changed files with 314 additions and 7 deletions
+13 -3
View File
@@ -778,15 +778,20 @@ 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 {
payload, err := json.Marshal(map[string]string{"state": "closed"}) return c.SetIssueState(ctx, token, owner, repo, index, "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 close request: %w", err) return fmt.Errorf("marshaling state change: %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("closing issue: %w", err) return fmt.Errorf("setting issue state: %w", err)
} }
resp.Body.Close() resp.Body.Close()
@@ -794,6 +799,11 @@ func (c *Client) CloseIssue(ctx context.Context, token, owner, repo string, inde
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,6 +241,76 @@ 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 {
+96 -1
View File
@@ -39,9 +39,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Issues. // Issues.
mux.HandleFunc("GET /issues", h.ListIssues) mux.HandleFunc("GET /issues", h.ListIssues)
mux.HandleFunc("GET /issues/new", h.NewIssue) mux.HandleFunc("GET /issues/new", h.NewIssue)
mux.HandleFunc("GET /issues/new/labels", h.NewIssueLabels)
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.
@@ -115,6 +118,10 @@ var basePage = template.Must(template.New("base").Parse(`<!DOCTYPE html>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
</head> </head>
<body> <body>
<header class="top-bar">
<span class="top-bar-title">Gitea Mobile</span>
<button class="refresh-btn" hx-get="" hx-target="#main-content" hx-swap="innerHTML" aria-label="Refresh">&#8635;</button>
</header>
<div class="content" id="main-content"> <div class="content" id="main-content">
{{.Content}} {{.Content}}
</div> </div>
@@ -561,6 +568,38 @@ func (h *Handler) NewIssue(w http.ResponseWriter, r *http.Request) {
renderPage(w, r, "New Issue", "issues", buf.String()) renderPage(w, r, "New Issue", "issues", buf.String())
} }
// NewIssueLabels handles GET /issues/new/labels — returns label checkboxes for a repo.
func (h *Handler) NewIssueLabels(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.URL.Query().Get("owner")
repo := r.URL.Query().Get("repo")
if owner == "" || repo == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<span class="empty">Select a repository first.</span>`)
return
}
labels, err := h.Client.GetRepoLabels(r.Context(), token, owner, repo)
if err != nil {
slog.Error("failed to fetch labels", "error", err, "owner", owner, "repo", repo)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<span class="empty">Error loading labels.</span>`)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if len(labels) == 0 {
fmt.Fprint(w, `<span class="empty">No labels available for this repository.</span>`)
return
}
for _, l := range labels {
fmt.Fprintf(w, `<label style="display:inline-block;margin:0.25rem 0.5rem 0.25rem 0;cursor:pointer;"><input type="checkbox" name="label_ids" value="%d" style="margin-right:0.25rem;"> <span class="label" style="color:#%s;border:1px solid #%s">%s</span></label>`,
l.ID, template.HTMLEscapeString(l.Color), template.HTMLEscapeString(l.Color), template.HTMLEscapeString(l.Name))
}
}
// CreateIssue handles POST /issues. // CreateIssue handles POST /issues.
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
@@ -574,6 +613,15 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
title := r.FormValue("title") title := r.FormValue("title")
body := r.FormValue("body") body := r.FormValue("body")
// Parse label IDs from form checkboxes.
var labelIDs []int64
for _, idStr := range r.Form["label_ids"] {
id, err := strconv.ParseInt(idStr, 10, 64)
if err == nil {
labelIDs = append(labelIDs, id)
}
}
if owner == "" || repo == "" || title == "" { if owner == "" || repo == "" || title == "" {
if isHTMX(r) { if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -585,7 +633,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
return return
} }
issue, err := h.Client.CreateIssue(r.Context(), token, owner, repo, title, body, nil) issue, err := h.Client.CreateIssue(r.Context(), token, owner, repo, title, body, labelIDs)
if err != nil { if err != nil {
slog.Error("failed to create issue", "error", err) slog.Error("failed to create issue", "error", err)
if isHTMX(r) { if isHTMX(r) {
@@ -682,6 +730,53 @@ 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,6 +135,54 @@ 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)
} }
+18 -1
View File
@@ -20,6 +20,11 @@
<input type="hidden" name="repo" id="repo-input"> <input type="hidden" name="repo" id="repo-input">
</div> </div>
<div class="form-group" id="label-section" style="display:none;">
<label>Labels</label>
<div id="label-list"></div>
</div>
<div class="form-group"> <div class="form-group">
<label for="title">Title</label> <label for="title">Title</label>
<input type="text" id="title" name="title" placeholder="Issue title" required> <input type="text" id="title" name="title" placeholder="Issue title" required>
@@ -52,7 +57,19 @@
} }
} }
repoSelect.addEventListener('change', splitOwnerRepo); repoSelect.addEventListener('change', function() {
splitOwnerRepo();
var labelSection = document.getElementById('label-section');
var labelList = document.getElementById('label-list');
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'});
} else {
labelSection.style.display = 'none';
labelList.innerHTML = '';
}
});
// Validate before HTMX submit. // Validate before HTMX submit.
document.getElementById('create-issue-form').addEventListener('htmx:configRequest', function(evt) { document.getElementById('create-issue-form').addEventListener('htmx:configRequest', function(evt) {
+21 -1
View File
@@ -3,7 +3,15 @@
<div class="card"> <div class="card">
<div class="card-meta"> <div class="card-meta">
<span class="state-open">{{.Issue.State}}</span> <span id="state-section">
{{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>
@@ -18,6 +26,7 @@
{{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">
@@ -27,8 +36,19 @@
<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;">
+4
View File
@@ -13,6 +13,10 @@
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
</head> </head>
<body> <body>
<header class="top-bar">
<span class="top-bar-title">Gitea Mobile</span>
<button class="refresh-btn" hx-get="" hx-target="#main-content" hx-swap="innerHTML" aria-label="Refresh">&#8635;</button>
</header>
<div class="content" id="main-content"> <div class="content" id="main-content">
{{template "content" .}} {{template "content" .}}
</div> </div>
+44 -1
View File
@@ -47,10 +47,53 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/* Top bar */
.top-bar {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-sm) var(--spacing-lg);
padding-top: max(var(--spacing-sm), env(safe-area-inset-top));
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.top-bar-title {
font-size: var(--font-base);
font-weight: 600;
color: var(--text-primary);
}
.refresh-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.4rem;
padding: var(--spacing-xs) var(--spacing-sm);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
line-height: 1;
border-radius: var(--radius-sm);
transition: color 0.15s ease, background 0.15s ease;
}
.refresh-btn:active {
color: var(--accent-blue);
background: var(--bg-tertiary);
}
.refresh-btn.htmx-request {
animation: spin 0.6s linear infinite;
pointer-events: none;
}
/* Content area */ /* Content area */
.content { .content {
padding: var(--spacing-lg); padding: var(--spacing-lg);
padding-top: max(var(--spacing-lg), env(safe-area-inset-top)); padding-top: var(--spacing-lg);
max-width: 640px; max-width: 640px;
margin: 0 auto; margin: 0 auto;
} }