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 364df0f..2207ecf 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. @@ -630,6 +632,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 @@
Add Comment
+ +Actions