From bcd61ff1391a2124354eb73f8e6f4a23d6030ae4 Mon Sep 17 00:00:00 2001 From: agent-company Date: Fri, 27 Mar 2026 02:07:58 +0000 Subject: [PATCH] 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) --- internal/gitea/client.go | 16 +++++-- internal/gitea/client_test.go | 70 ++++++++++++++++++++++++++++ internal/handlers/handlers.go | 49 +++++++++++++++++++ internal/handlers/handlers_test.go | 48 +++++++++++++++++++ internal/templates/issue_detail.html | 22 ++++++++- 5 files changed, 201 insertions(+), 4 deletions(-) diff --git a/internal/gitea/client.go b/internal/gitea/client.go index 105165d..9915bb9 100644 --- a/internal/gitea/client.go +++ b/internal/gitea/client.go @@ -708,15 +708,20 @@ func (c *Client) SubmitReview(ctx context.Context, token, owner, repo string, in // CloseIssue closes an issue by setting its state to "closed". 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 { - 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) resp, err := c.doRequest(ctx, token, http.MethodPatch, path, strings.NewReader(string(payload))) if err != nil { - return fmt.Errorf("closing issue: %w", err) + return fmt.Errorf("setting issue state: %w", err) } resp.Body.Close() @@ -724,6 +729,11 @@ func (c *Client) CloseIssue(ctx context.Context, token, owner, repo string, inde 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. 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}) diff --git a/internal/gitea/client_test.go b/internal/gitea/client_test.go index e261cc1..5c839ed 100644 --- a/internal/gitea/client_test.go +++ b/internal/gitea/client_test.go @@ -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) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index b592438..4538658 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -42,6 +42,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /issues", h.CreateIssue) 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}/state", h.SetIssueState) + mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comments", h.AddComment) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comment", h.AddComment) // Issue detail. @@ -626,6 +628,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) } +// 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, `closed +`, + template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index) + } else { + fmt.Fprintf(w, `open +`, + 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. func (h *Handler) AddComment(w http.ResponseWriter, r *http.Request) { token := getToken(r) diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index af96f5a..659549c 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -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 { return len(s) >= len(substr) && searchString(s, substr) } diff --git a/internal/templates/issue_detail.html b/internal/templates/issue_detail.html index 8ad3038..a997b1a 100644 --- a/internal/templates/issue_detail.html +++ b/internal/templates/issue_detail.html @@ -3,7 +3,15 @@
- {{.Issue.State}} + + {{if eq .Issue.State "closed"}} + {{.Issue.State}} + + {{else}} + {{.Issue.State}} + + {{end}} + {{.Issue.RepoOwner}}/{{.Issue.RepoName}} #{{.Issue.Number}} {{range .Issue.Labels}} {{.Name}} @@ -18,6 +26,7 @@ {{if .Comments}}

Comments

+
{{range .Comments}}
@@ -27,8 +36,19 @@
{{.Body}}
{{end}} +
+{{else}} +
{{end}} +
+

Add Comment

+
+ + +
+
+

Actions