From 485aa3f853c207fb5421abd86bb38d6dc54a8e71 Mon Sep 17 00:00:00 2001 From: agent-company Date: Fri, 27 Mar 2026 04:15:35 +0000 Subject: [PATCH] feat: add Assign action to issue detail view Add the ability to assign users to issues from the mobile app: - New ListCollaborators client method fetches repo collaborators - New AssignIssue client method sets assignees via PATCH API - New POST /issues/{owner}/{repo}/{index}/assignees handler - Assignee dropdown populated with repo collaborators in issue detail - HTMX inline response confirms assignment without page reload Closes leeworks-agents/gitea-mobile#50 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/gitea/client.go | 43 +++++++++++++++++++++++++ internal/handlers/handlers.go | 48 ++++++++++++++++++++++++++++ internal/templates/issue_detail.html | 12 +++++++ 3 files changed, 103 insertions(+) diff --git a/internal/gitea/client.go b/internal/gitea/client.go index 0b73a54..585ab44 100644 --- a/internal/gitea/client.go +++ b/internal/gitea/client.go @@ -753,6 +753,49 @@ func (c *Client) ApplyLabel(ctx context.Context, token, owner, repo string, inde return nil } +// ListCollaborators fetches the list of collaborators (users with access) for a repo. +func (c *Client) ListCollaborators(ctx context.Context, token, owner, repo string) ([]string, error) { + path := fmt.Sprintf("/repos/%s/%s/collaborators?limit=50", owner, repo) + resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("fetching collaborators: %w", err) + } + defer resp.Body.Close() + + var users []struct { + Login string `json:"login"` + } + if err := json.NewDecoder(resp.Body).Decode(&users); err != nil { + return nil, fmt.Errorf("decoding collaborators: %w", err) + } + + var logins []string + for _, u := range users { + logins = append(logins, u.Login) + } + return logins, nil +} + +// AssignIssue sets the assignees on an issue. +func (c *Client) AssignIssue(ctx context.Context, token, owner, repo string, index int64, assignees []string) error { + payload, err := json.Marshal(map[string]interface{}{ + "assignees": assignees, + }) + if err != nil { + return fmt.Errorf("marshaling assignees: %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("assigning issue: %w", err) + } + resp.Body.Close() + + c.InvalidateAll() + return nil +} + // SubmitReview submits a review on a pull request. func (c *Client) SubmitReview(ctx context.Context, token, owner, repo string, index int64, reviewType, body string) error { payload := map[string]interface{}{ diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index fbb3710..081aaa1 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -41,6 +41,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /issues/new", h.NewIssue) mux.HandleFunc("POST /issues", h.CreateIssue) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels) + mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/assignees", h.AssignIssue) 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) @@ -429,6 +430,12 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) { labels = nil } + collaborators, err := h.Client.ListCollaborators(r.Context(), token, owner, repo) + if err != nil { + slog.Error("failed to get collaborators", "error", err) + collaborators = nil + } + // Render markdown body if present. var renderedBody template.HTML if issue.Body != "" { @@ -453,6 +460,7 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) { RenderedBody template.HTML Comments []giteaclient.Comment AvailableLabels []giteaclient.Label + Collaborators []string } data := templateData{ @@ -460,6 +468,7 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) { RenderedBody: renderedBody, Comments: comments, AvailableLabels: labels, + Collaborators: collaborators, } var buf strings.Builder @@ -660,6 +669,45 @@ func (h *Handler) ApplyLabels(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther) } +// AssignIssue handles POST /issues/{owner}/{repo}/{index}/assignees. +func (h *Handler) AssignIssue(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 + } + + assignee := r.FormValue("assignee") + if assignee == "" { + http.Error(w, "assignee is required", http.StatusBadRequest) + return + } + + if err := h.Client.AssignIssue(r.Context(), token, owner, repo, index, []string{assignee}); err != nil { + slog.Error("failed to assign issue", "error", err, "owner", owner, "repo", repo, "index", index, "assignee", assignee) + http.Error(w, "failed to assign issue", http.StatusInternalServerError) + return + } + + if isHTMX(r) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `Assigned to %s`, template.HTMLEscapeString(assignee)) + return + } + + http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther) +} + // CloseIssue handles POST /issues/{owner}/{repo}/{index}/close. func (h *Handler) CloseIssue(w http.ResponseWriter, r *http.Request) { token := getToken(r) diff --git a/internal/templates/issue_detail.html b/internal/templates/issue_detail.html index a997b1a..a4e66d0 100644 --- a/internal/templates/issue_detail.html +++ b/internal/templates/issue_detail.html @@ -51,6 +51,18 @@

Actions

+ {{if .Collaborators}} +
+
+ + +
+
+ {{end}}