Files
projax/web/gitea_writeback.go
mAi 085e672dd5 refactor(dashboard): cache via internal/cache.TTLCache
Phase 5b slice B. dashboardCache deleted. The Server's dashboard field
is now `*cache.TTLCache[*dashboardPayload]` constructed via
`cache.NewTTL[*dashboardPayload](dashboardCacheTTL)`. All call sites
renamed:

- s.dashboard.get(k)         → s.dashboard.Get(k)
- s.dashboard.set(k, p)      → s.dashboard.Set(k, p)
- s.dashboard.invalidate(k)  → s.dashboard.Invalidate(k)
- s.dashboard.invalidateAll  → s.dashboard.InvalidateAll
  (across web/dashboard.go, web/server.go, web/caldav.go,
   web/links.go, web/gitea_writeback.go)

The 64-line dashboardCache struct + methods are gone; the dashboard
file shrinks by ~63 lines. TTL constant lifted out to
`dashboardCacheTTL = 60 * time.Second` so the const lives next to its
semantics rather than a magic-number literal in New().

All web/dashboard_*test.go pass unmodified.

Task: t-projax-5b-cache
2026-05-22 00:25:13 +02:00

174 lines
5.2 KiB
Go

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.