package web
import (
"context"
"fmt"
"html/template"
"log/slog"
"net/http"
"net/url"
"strings"
"go.bigb.es/auxilia/scribe"
"sourcecraft.dev/bigbes/sr-ht-dolt/beads"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
// beadsView renders a "beads" (bd) issue database as a Mardi Gras parade board:
// four lanes of cards (Rolling / Lined Up / Stalled / Past Stand) plus a
// per-issue detail pane reachable via ?issue=<id>. ?layout=stream draws the same
// filtered set as one column of sections instead — a layout of this view and not
// a second one, which is why it is a query parameter and not another tab. All
// data is read through the BrowseSession surface (Rows/Tables) — there is no SQL
// engine behind it.
//
// The reading itself is not here: the fingerprint, the lane bucketing, the ready
// rule and the whole view model live in the beads package, which the MCP surface
// shares. This type is only the View adapter — slug, label, template, and the
// hand-off of the request's ref and query.
type beadsView struct {
// prefixes is the cross-database link index's cache: one issue prefix per
// database, gated on that database's head hash and expiring on
// beads.ReadyCacheTTL — /ready's bounds, shared rather than restated (see
// beads/cache.go).
//
// It lives on the view because the view is the one piece of per-process state
// this rendering has, and because a zero value is a working cache: views are
// registered as a bare &beadsView{}, here and in every test.
//
// The cache is keyed on the database and never on the caller, which is safe
// precisely because what it holds is a database's own prefix — a fact about
// the store, not about who may see it. Who may see it is decided per request,
// before a store is opened, and the index built from it belongs to that
// request alone.
prefixes beads.PrefixCache
}
// The two Data.Mode values that render one issue rather than a board. They are
// what decides whether this request pays for the cross-database index at all.
const (
beadsModeDetail = "detail"
beadsModeEpic = "epic"
)
func (*beadsView) Name() string { return "beads" }
func (*beadsView) Label() string { return "Beads" }
func (*beadsView) Template() string { return "beads.html" }
// Applies fingerprints a beads DB; see beads.Applies for the rule.
func (*beadsView) Applies(tables []browse.TableInfo) bool { return beads.Applies(tables) }
// Build reads the issue graph and produces either the board or, when ?issue=
// names an issue, that issue's detail pane. The result is a *beads.Data,
// handed to beads.html as its .Data.
func (*beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo, ref string, query url.Values) (any, error) {
data, err := beads.Build(ctx, sess, ref, query)
if err != nil {
return nil, err
}
return data, nil
}
// --- cross-database issue links -----------------------------------------------
// beadLinks renders one stored text with the issue ids in it linked to the
// databases that own them. It is the envelope's .Links on the issue detail pane
// and nil everywhere else, and a nil one renders the text with no links at all —
// which is a whole answer, not a degraded one: an id nobody here owns is text.
type beadLinks struct {
index *beads.PrefixIndex
}
// Text renders a stored text — a description, a comment body, an event summary —
// as HTML with every recognised issue id wrapped in a link.
//
// It escapes first and wraps second. The result is built as a sequence of
// escaped segments and anchors this function generated itself, and only the
// finished whole is marked template.HTML: marking user-stored text as HTML and
// then running a regexp over it is how a stored payload becomes a rendered one.
// Nothing that came out of the database is ever handed to the browser unescaped,
// including the id inside the anchor and the href built from it.
func (l *beadLinks) Text(s string) template.HTML {
var refs []beads.Reference
if l != nil {
refs = l.index.Scan(s)
}
var b strings.Builder
b.Grow(len(s))
last := 0
for _, ref := range refs {
b.WriteString(template.HTMLEscapeString(s[last:ref.Start]))
b.WriteString(`<a href="`)
b.WriteString(template.HTMLEscapeString(
beadIssueHref(ref.Database.OwnerName, ref.Database.Name, ref.ID)))
b.WriteString(`">`)
b.WriteString(template.HTMLEscapeString(ref.ID))
b.WriteString(`</a>`)
last = ref.End
}
b.WriteString(template.HTMLEscapeString(s[last:]))
return template.HTML(b.String())
}
// beadIssueHref is the detail-pane URL of one issue in one database: the same
// address beads.html writes for a dependency edge, built here because this one
// is assembled in Go rather than by the template.
func beadIssueHref(owner, name, id string) string {
return "/~" + url.PathEscape(owner) + "/" + url.PathEscape(name) +
"/view/beads?issue=" + url.QueryEscape(id)
}
// beadCrossLinks builds the link index for one render, and returns nil for every
// page that is not an issue detail pane: the board carries ids in card headers
// that are already links, and nothing else in this service renders stored prose.
// Building the index opens a store per database, so it is asked for by the one
// rendering that needs it and never as part of the envelope.
//
// The visibility rule is the /ready page's, for the same reason: enumerate with
// ListReposForViewer (the listing rule), then ask core.Allowed/OpBrowse per
// database (the access rule), and do both before a single store is opened. A
// database this caller may not browse never enters the index, so an id belonging
// to it renders as plain text — indistinguishable from one whose prefix matches
// nothing. That indistinguishability is the point: a tooltip, a class or an
// "unknown tracker" marker would each publish the existence of a database this
// caller is not allowed to know about.
//
// A listing that fails costs the page its links and not the page. This is
// decoration on top of an answer, exactly as the freshness line is, and it may
// never be the reason a reader gets a 500 instead of an issue.
func (a *app) beadCrossLinks(r *http.Request, view View, repo *core.Repo, data any) *beadLinks {
bv, ok := view.(*beadsView)
if !ok {
return nil
}
d, ok := data.(*beads.Data)
if !ok || (d.Mode != beadsModeDetail && d.Mode != beadsModeEpic) {
return nil
}
_, caller := callerOf(r.Context())
repos, err := a.cfg.Repos.ListReposForViewer(r.Context(), caller)
if err != nil {
slog.Error("listing databases for the issue link index failed",
"component", "web", scribe.Err(err))
return nil
}
// The disk path never reaches beads: it is this service's arrangement of its
// own storage. The opener closes over the map, so a database that was filtered
// out here has no path to be opened by.
paths := make(map[int]string, len(repos))
dbs := make([]beads.ReadyDatabase, 0, len(repos))
for _, cand := range repos {
if !core.Allowed(caller, cand, a.effectiveACL(r, caller, cand), core.OpBrowse) {
continue
}
paths[cand.ID] = cand.Path
entry := beads.ReadyDatabase{ID: cand.ID, OwnerName: cand.OwnerName, Name: cand.Name}
if cand.ID == repo.ID {
// The database whose page this is goes first, so the one prefix the
// page cannot do without — its own, the ids that used to link and must
// keep linking — can never be the one the ceiling drops.
dbs = append([]beads.ReadyDatabase{entry}, dbs...)
continue
}
dbs = append(dbs, entry)
}
open := func(ctx context.Context, d beads.ReadyDatabase) (beads.ReadySession, error) {
path, ok := paths[d.ID]
if !ok {
return nil, fmt.Errorf("web: no store path for database %s", d.Slug())
}
return a.cfg.Browse.Open(ctx, path)
}
index := beads.PrefixesAcross(r.Context(), dbs, open, &bv.prefixes, timeNow())
// A store that cannot be read is a fact about this deployment and belongs in
// the log with its error. The page says nothing at all: the ids that database
// owns simply stay text.
for _, f := range index.Failed {
slog.Warn("reading a database for the issue link index failed",
"component", "web", "database", f.Database.Slug(), scribe.Err(f.Err))
}
return &beadLinks{index: index}
}