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>
This commit is contained in:
@@ -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".
|
// 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()
|
||||||
|
|
||||||
@@ -724,6 +729,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})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
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.
|
||||||
@@ -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)
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;">
|
||||||
|
|||||||
Reference in New Issue
Block a user