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.
226 lines
6.0 KiB
Go
226 lines
6.0 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/m/projax/gitea"
|
|
"github.com/m/projax/store"
|
|
)
|
|
|
|
const refTypeGiteaRepo = "gitea-repo"
|
|
|
|
// GiteaDeps is the optional Gitea integration. nil → the Issues section on
|
|
// the detail page renders nothing and main.go logs "gitea: disabled".
|
|
type GiteaDeps struct {
|
|
Client *gitea.Client
|
|
// Cache is a small in-memory TTL cache so repeatedly rendering the same
|
|
// detail page does not hammer Gitea. Nil → no caching (used in tests).
|
|
Cache *issueCache
|
|
}
|
|
|
|
// NewGiteaDeps wires a client + a default 3-minute TTL cache.
|
|
func NewGiteaDeps(c *gitea.Client) *GiteaDeps {
|
|
return &GiteaDeps{Client: c, Cache: newIssueCache(3 * time.Minute)}
|
|
}
|
|
|
|
// issueCache is a tiny synchronised TTL cache keyed by "owner/repo|state".
|
|
// Concurrency-safe; not size-bounded (m's project count is small, so we
|
|
// don't bother with LRU at v1).
|
|
type issueCache struct {
|
|
ttl time.Duration
|
|
mu sync.Mutex
|
|
rows map[string]cachedIssues
|
|
}
|
|
|
|
type cachedIssues struct {
|
|
at time.Time
|
|
issues []gitea.Issue
|
|
}
|
|
|
|
func newIssueCache(ttl time.Duration) *issueCache {
|
|
return &issueCache{ttl: ttl, rows: map[string]cachedIssues{}}
|
|
}
|
|
|
|
func (c *issueCache) Get(key string) ([]gitea.Issue, bool) {
|
|
if c == nil {
|
|
return nil, false
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
v, ok := c.rows[key]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
if time.Since(v.at) > c.ttl {
|
|
delete(c.rows, key)
|
|
return nil, false
|
|
}
|
|
return v.issues, true
|
|
}
|
|
|
|
func (c *issueCache) Invalidate(key string) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
delete(c.rows, key)
|
|
}
|
|
|
|
func (c *issueCache) Set(key string, issues []gitea.Issue) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.rows[key] = cachedIssues{at: time.Now(), issues: issues}
|
|
}
|
|
|
|
// repoIssues groups one repo's open + recently-closed issues for the template.
|
|
type repoIssues struct {
|
|
Repo string // owner/repo as supplied in the item link
|
|
RepoURL string // browser URL for the repo
|
|
IssuesURL string // browser URL for the open-issues list page
|
|
Open []giteaIssueView
|
|
ClosedRecent []giteaIssueView
|
|
OpenCount int
|
|
Error string
|
|
}
|
|
|
|
// giteaIssueView is the template-facing view of an issue — pre-rendered fields
|
|
// so the template doesn't have to do formatting work.
|
|
type giteaIssueView struct {
|
|
Number int
|
|
Title string
|
|
State string
|
|
Labels []string
|
|
Assignees []string
|
|
Milestone string
|
|
UpdatedAt time.Time
|
|
HTMLURL string
|
|
UpdatedRel string // "2h ago", "yesterday", "3d ago" — pre-formatted for the row
|
|
}
|
|
|
|
func toView(in []gitea.Issue, now time.Time) []giteaIssueView {
|
|
out := make([]giteaIssueView, 0, len(in))
|
|
for _, i := range in {
|
|
out = append(out, giteaIssueView{
|
|
Number: i.Number,
|
|
Title: i.Title,
|
|
State: i.State,
|
|
Labels: i.Labels,
|
|
Assignees: i.Assignees,
|
|
Milestone: i.Milestone,
|
|
UpdatedAt: i.UpdatedAt,
|
|
HTMLURL: i.HTMLURL,
|
|
UpdatedRel: relativeTime(now, i.UpdatedAt),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// relativeTime renders a short "Nh ago" / "yesterday" / "Nd ago" string. Days
|
|
// past 30 fall back to a plain YYYY-MM-DD so old issues stay readable.
|
|
func relativeTime(now, t time.Time) string {
|
|
if t.IsZero() {
|
|
return ""
|
|
}
|
|
d := now.Sub(t)
|
|
switch {
|
|
case d < time.Minute:
|
|
return "just now"
|
|
case d < time.Hour:
|
|
return formatDuration(d, time.Minute, "m") + " ago"
|
|
case d < 24*time.Hour:
|
|
return formatDuration(d, time.Hour, "h") + " ago"
|
|
case d < 48*time.Hour:
|
|
return "yesterday"
|
|
case d < 30*24*time.Hour:
|
|
return formatDuration(d, 24*time.Hour, "d") + " ago"
|
|
default:
|
|
return t.UTC().Format("2006-01-02")
|
|
}
|
|
}
|
|
|
|
func formatDuration(d, unit time.Duration, suffix string) string {
|
|
n := int(d / unit)
|
|
if n < 1 {
|
|
n = 1
|
|
}
|
|
return strconv.Itoa(n) + suffix
|
|
}
|
|
|
|
// detailIssues walks every gitea-repo link on the item, fetches open issues
|
|
// (and the last 30d of closed ones into a small disclosure), and pre-renders
|
|
// each row for the template. Per-repo failures surface as repoIssues.Error so
|
|
// one missing/renamed repo doesn't blank the whole section.
|
|
func (s *Server) detailIssues(ctx context.Context, item *store.Item) ([]repoIssues, error) {
|
|
if s.Gitea == nil {
|
|
return nil, nil
|
|
}
|
|
links, err := s.Items.LinksByType(ctx, item.ID, refTypeGiteaRepo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(links) == 0 {
|
|
return nil, nil
|
|
}
|
|
now := time.Now()
|
|
closedCutoff := now.AddDate(0, 0, -30)
|
|
var out []repoIssues
|
|
for _, l := range links {
|
|
owner, repo := gitea.ParseRepoRef(l.RefID)
|
|
ri := repoIssues{
|
|
Repo: l.RefID,
|
|
RepoURL: s.Gitea.Client.RepoHTMLURL(owner, repo),
|
|
IssuesURL: s.Gitea.Client.RepoHTMLURL(owner, repo) + "/issues",
|
|
}
|
|
if owner == "" || repo == "" {
|
|
ri.Error = "Malformed repo ref — expected owner/repo, got " + l.RefID
|
|
out = append(out, ri)
|
|
continue
|
|
}
|
|
openKey := l.RefID + "|open"
|
|
open, ok := s.Gitea.Cache.Get(openKey)
|
|
if !ok {
|
|
open, err = s.Gitea.Client.ListIssues(ctx, owner, repo, gitea.ListOpts{State: "open"})
|
|
if err != nil {
|
|
ri.Error = giteaErrMessage(l.RefID, err)
|
|
s.Logger.Warn("gitea list issues", "repo", l.RefID, "err", err)
|
|
out = append(out, ri)
|
|
continue
|
|
}
|
|
s.Gitea.Cache.Set(openKey, open)
|
|
}
|
|
ri.Open = toView(open, now)
|
|
ri.OpenCount = len(ri.Open)
|
|
// Closed (last 30d, up to 20).
|
|
closedKey := l.RefID + "|closed-recent"
|
|
closed, ok := s.Gitea.Cache.Get(closedKey)
|
|
if !ok {
|
|
closed, err = s.Gitea.Client.ListIssues(ctx, owner, repo, gitea.ListOpts{State: "closed", Since: closedCutoff, Limit: 20})
|
|
if err != nil {
|
|
// Closed-list failure is non-fatal — the open list already rendered.
|
|
s.Logger.Warn("gitea list closed", "repo", l.RefID, "err", err)
|
|
closed = nil
|
|
} else {
|
|
s.Gitea.Cache.Set(closedKey, closed)
|
|
}
|
|
}
|
|
ri.ClosedRecent = toView(closed, now)
|
|
out = append(out, ri)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func giteaErrMessage(repo string, err error) string {
|
|
if errors.Is(err, gitea.ErrNotFound) {
|
|
return "Repo " + repo + " not found on Gitea (renamed, deleted, or token lacks access)."
|
|
}
|
|
return "Could not fetch issues from " + repo + ": " + err.Error()
|
|
}
|