package web import ( "context" "errors" "net/http" "strconv" "strings" "github.com/m/projax/gitea" "github.com/m/projax/store" ) // handleIssueAction dispatches POST /i/{path}/issues/{action} where action is // close|reopen|comment|create. Form fields: repo (owner/repo), number // (optional for create), body (optional for create/comment). // // On success the handler busts the dashboard cache and re-renders the // detail-page issues_section partial so HTMX swaps it into place. func (s *Server) handleIssueAction(w http.ResponseWriter, r *http.Request, path, action string) { if s.Gitea == nil { http.Error(w, "gitea not configured", http.StatusServiceUnavailable) return } it, err := s.Store.GetByPath(r.Context(), path) if err != nil { s.fail(w, r, err) return } if err := r.ParseForm(); err != nil { s.fail(w, r, err) return } repoRef := strings.TrimSpace(r.FormValue("repo")) if repoRef == "" { http.Error(w, "repo required", http.StatusBadRequest) return } // Guard: repo must be linked to this item. if !s.repoLinkedToItem(r.Context(), it.ID, repoRef) { http.Error(w, "repo not linked to this item", http.StatusForbidden) return } owner, repo := gitea.ParseRepoRef(repoRef) if owner == "" || repo == "" { http.Error(w, "malformed repo ref", http.StatusBadRequest) return } banner := "" switch action { case "close": num, ok := parseIssueNumber(r.FormValue("number")) if !ok { http.Error(w, "number required", http.StatusBadRequest) return } if err := s.Gitea.Client.CloseIssue(r.Context(), owner, repo, num); err != nil { banner = giteaWritebackBanner("close", repoRef, err) } case "reopen": num, ok := parseIssueNumber(r.FormValue("number")) if !ok { http.Error(w, "number required", http.StatusBadRequest) return } if err := s.Gitea.Client.ReopenIssue(r.Context(), owner, repo, num); err != nil { banner = giteaWritebackBanner("reopen", repoRef, err) } case "comment": num, ok := parseIssueNumber(r.FormValue("number")) if !ok { http.Error(w, "number required", http.StatusBadRequest) return } body := strings.TrimSpace(r.FormValue("body")) if body == "" { banner = "Cannot post empty comment." break } if _, err := s.Gitea.Client.AddComment(r.Context(), owner, repo, num, body); err != nil { banner = giteaWritebackBanner("comment", repoRef, err) } case "create": title := strings.TrimSpace(r.FormValue("title")) if title == "" { banner = "Cannot create issue without a title." break } body := r.FormValue("body") if _, err := s.Gitea.Client.CreateIssue(r.Context(), owner, repo, title, body); err != nil { banner = giteaWritebackBanner("create", repoRef, err) } default: http.Error(w, "unknown action: "+action, http.StatusBadRequest) return } // Bust caches so the next fetch reflects the upstream change. s.Gitea.Cache.Invalidate(repoRef + "|open") s.Gitea.Cache.Invalidate(repoRef + "|closed-recent") if s.dashboard != nil { s.dashboard.InvalidateAll() } if r.Header.Get("HX-Request") == "true" { s.renderIssuesSection(w, r, it, banner) return } http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther) } // renderIssuesSection re-fetches the issues for the item and renders the // issues_section partial. Used by HTMX swaps after a writeback. func (s *Server) renderIssuesSection(w http.ResponseWriter, r *http.Request, it *store.Item, banner string) { issues, err := s.detailIssues(r.Context(), it) if err != nil { s.fail(w, r, err) return } openTotal := 0 for _, ri := range issues { openTotal += ri.OpenCount } s.render(w, r, "issues_section", map[string]any{ "Item": it, "Issues": issues, "IssuesOpenTotal": openTotal, "GiteaOn": s.Gitea != nil, "Banner": banner, }) } // repoLinkedToItem checks that the given owner/repo ref is actually attached // to this item via a gitea-repo item_link. Prevents form-crafted writeback // against unrelated repos. func (s *Server) repoLinkedToItem(ctx context.Context, itemID, repoRef string) bool { links, err := s.Store.LinksByType(ctx, itemID, refTypeGiteaRepo) if err != nil { return false } for _, l := range links { if l.RefID == repoRef { return true } } return false } func parseIssueNumber(s string) (int, bool) { n, err := strconv.Atoi(strings.TrimSpace(s)) if err != nil || n <= 0 { return 0, false } return n, true } // giteaWritebackBanner formats an inline error banner so the issues section // surfaces upstream failures (token lacks perms, repo not found, network) // without breaking the page render. func giteaWritebackBanner(action, repo string, err error) string { switch { case errors.Is(err, gitea.ErrForbidden): return "Could not " + action + " on " + repo + ": Gitea token lacks write access. Check GITEA_TOKEN_AI scope." case errors.Is(err, gitea.ErrNotFound): return "Repo " + repo + " not found on Gitea (renamed, deleted, or token lacks access)." } return "Could not " + action + " issue on " + repo + ": " + err.Error() } // Server.repoLinkedToItem requires the handler to pass r.Context(); the // signature is plain context.Context so package importers (tests, other web // helpers) don't need to know which package the context came from.