Per t-projax-6-sliceB-readpath. mBrian migration (m/mBrian#73) is live on msupabase with 65 nodes + 78 child_of + 81 projax-* edges. This commit makes the projax read path source from there behind an env switch. CLIENT ARCH: direct pgxpool against mbrian.* schema (same SUPABASE_DATABASE_URL the projax binary already uses for projax.*) — matches flexsiebels/head's cross-coupling pattern. No MCP token plumbing. CONTRACT (all three honoured) - External links are SELF-EDGES (source=target=item, rel='projax-*', payload in edges.metadata). linkFromEdge reads the node's outbound projax-* edges; ref_id derived per ref_type from metadata (caldav url, gitea owner/repo, mai-project mai_project_id). - Slugs finalised: 'work'/'dania' resolve to mBrian's canonical nodes; projax-side squatters (renamed-aside, not deleted) are documented in the parity test as legacy-only and skipped from field comparison. - created_at/updated_at NOT preserved — ItemsCreatedInRange orders off metadata.projax.start_time when present, fall back to mBrian created_at. Aggregator surfaces (timeline / dashboard) read off caldav DTSTART + gitea updated_at, so they're unaffected. NEW FILES - store/mbrian.go: MBrianReader concrete impl. Bulk-loads projax- managed nodes + child_of edges in one pair of queries per call, builds a graphContext in memory, derives Paths via ancestor walk (depth-capped at 64 like projax's trigger). Implements every ItemReader method. - store/mbrian_parity_test.go: 5 parity tests against the live db — ListAll field equality (skipping the renamed squatter slugs), spot-check resolves, caldav-list link round-trip, gitea-repo link round-trip, AllTags union, NotFound consistency. All 5 GREEN. - cmd/projax-remap-views/main.go: one-shot tool to rewrite projax.views.filter_json.project_id from old projax uuids to new mBrian uuids using the audit map mBrian dropped (head will relay the path). Dry-run default; --apply commits. Idempotent. - docs/plans/slice-b-views-projectid-gap.md: surfaces the gap + the remediation path. Must run remap BEFORE slice E drops projax.items. CHANGES - store/adapter.go: kept the ItemReader interface + *Store assertion; removed the prep stub (replaced by mbrian.go). - web/server.go: Server.Items store.ItemReader field. web.New defaults Items to the concrete *Store (legacy path). main.go overrides to MBrianReader when PROJAX_BACKEND=mbrian. - All read-path call sites in web/ swapped from s.Store.<readMethod>( to s.Items.<readMethod>( for the 15 ItemReader methods. MCP tools unchanged (separate scope; can pivot in a follow-up). Writes still flow through s.Store. - cmd/projax/main.go: PROJAX_BACKEND env switch with "store" (default) and "mbrian" values. Logs the choice at startup. Unknown value refuses to start. SMOKE - go build ./... green; go vet green. - go test ./store/ -count=1 — all parity tests pass against live data. - Local server boot with PROJAX_BACKEND=mbrian — backs binding logs "backend=mbrian (read path via store.MBrianReader)" and serves /views/tree (auth wall protects deeper smoke; parity tests cover that surface). PRE-EXISTING failure NOT addressed in this commit: 3 timeline_filter tests in web/ already failed on main (legacy /timeline URL hits the Phase 5j 301 redirect to /views/timeline). No diff vs main in those test files; out of scope for slice B. OUT OF SCOPE FOR SLICE B (deferred): - MCP read tools migration to ItemReader (separate diff, low risk). - Aggregator's LinkLister wired to ItemReader (currently consumes *Store directly through Server.Aggregator()). - views.filter_json.project_id remap RUN — tool ships here, run waits on the head's relay of the audit-map path. - Slice C write-path. Slice D mai-bridge worker. Slice E drop.
174 lines
5.2 KiB
Go
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.Items.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.Items.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.
|