coverage~bigbes/sr-ht-dolt3523280cweb/beads.go

Coverage
93.3% 56/60 statements
Δ
+0.0
Blob
1432b19
1 package web
2
3 import (
4 "context"
5 "fmt"
6 "html/template"
7 "log/slog"
8 "net/http"
9 "net/url"
10 "strings"
11
12 "go.bigb.es/auxilia/scribe"
13
14 "sourcecraft.dev/bigbes/sr-ht-dolt/beads"
15 "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
16 "sourcecraft.dev/bigbes/sr-ht-dolt/core"
17 )
18
19 // beadsView renders a "beads" (bd) issue database as a Mardi Gras parade board:
20 // four lanes of cards (Rolling / Lined Up / Stalled / Past Stand) plus a
21 // per-issue detail pane reachable via ?issue=<id>. ?layout=stream draws the same
22 // filtered set as one column of sections instead — a layout of this view and not
23 // a second one, which is why it is a query parameter and not another tab. All
24 // data is read through the BrowseSession surface (Rows/Tables) — there is no SQL
25 // engine behind it.
26 //
27 // The reading itself is not here: the fingerprint, the lane bucketing, the ready
28 // rule and the whole view model live in the beads package, which the MCP surface
29 // shares. This type is only the View adapter — slug, label, template, and the
30 // hand-off of the request's ref and query.
31 type beadsView struct {
32 // prefixes is the cross-database link index's cache: one issue prefix per
33 // database, gated on that database's head hash and expiring on
34 // beads.ReadyCacheTTL — /ready's bounds, shared rather than restated (see
35 // beads/cache.go).
36 //
37 // It lives on the view because the view is the one piece of per-process state
38 // this rendering has, and because a zero value is a working cache: views are
39 // registered as a bare &beadsView{}, here and in every test.
40 //
41 // The cache is keyed on the database and never on the caller, which is safe
42 // precisely because what it holds is a database's own prefix — a fact about
43 // the store, not about who may see it. Who may see it is decided per request,
44 // before a store is opened, and the index built from it belongs to that
45 // request alone.
46 prefixes beads.PrefixCache
47 }
48
49 // The two Data.Mode values that render one issue rather than a board. They are
50 // what decides whether this request pays for the cross-database index at all.
51 const (
52 beadsModeDetail = "detail"
53 beadsModeEpic = "epic"
54 )
55
56 177 func (*beadsView) Name() string { return "beads" }
57 101 func (*beadsView) Label() string { return "Beads" }
58 43 func (*beadsView) Template() string { return "beads.html" }
59
60 // Applies fingerprints a beads DB; see beads.Applies for the rule.
61 106 func (*beadsView) Applies(tables []browse.TableInfo) bool { return beads.Applies(tables) }
62
63 // Build reads the issue graph and produces either the board or, when ?issue=
64 // names an issue, that issue's detail pane. The result is a *beads.Data,
65 // handed to beads.html as its .Data.
66 43 func (*beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo, ref string, query url.Values) (any, error) {
67 43 data, err := beads.Build(ctx, sess, ref, query)
68 43 if err != nil {
69 0 return nil, err
70 0 }
71 43 return data, nil
72 }
73
74 // --- cross-database issue links -----------------------------------------------
75
76 // beadLinks renders one stored text with the issue ids in it linked to the
77 // databases that own them. It is the envelope's .Links on the issue detail pane
78 // and nil everywhere else, and a nil one renders the text with no links at all —
79 // which is a whole answer, not a degraded one: an id nobody here owns is text.
80 type beadLinks struct {
81 index *beads.PrefixIndex
82 }
83
84 // Text renders a stored text — a description, a comment body, an event summary —
85 // as HTML with every recognised issue id wrapped in a link.
86 //
87 // It escapes first and wraps second. The result is built as a sequence of
88 // escaped segments and anchors this function generated itself, and only the
89 // finished whole is marked template.HTML: marking user-stored text as HTML and
90 // then running a regexp over it is how a stored payload becomes a rendered one.
91 // Nothing that came out of the database is ever handed to the browser unescaped,
92 // including the id inside the anchor and the href built from it.
93 111 func (l *beadLinks) Text(s string) template.HTML {
94 111 var refs []beads.Reference
95 111 if l != nil {
96 103 refs = l.index.Scan(s)
97 103 }
98 111 var b strings.Builder
99 111 b.Grow(len(s))
100 111 last := 0
101 111 for _, ref := range refs {
102 57 b.WriteString(template.HTMLEscapeString(s[last:ref.Start]))
103 57 b.WriteString(`<a href="`)
104 57 b.WriteString(template.HTMLEscapeString(
105 57 beadIssueHref(ref.Database.OwnerName, ref.Database.Name, ref.ID)))
106 57 b.WriteString(`">`)
107 57 b.WriteString(template.HTMLEscapeString(ref.ID))
108 57 b.WriteString(`</a>`)
109 57 last = ref.End
110 57 }
111 111 b.WriteString(template.HTMLEscapeString(s[last:]))
112 111 return template.HTML(b.String())
113 }
114
115 // beadIssueHref is the detail-pane URL of one issue in one database: the same
116 // address beads.html writes for a dependency edge, built here because this one
117 // is assembled in Go rather than by the template.
118 65 func beadIssueHref(owner, name, id string) string {
119 65 return "/~" + url.PathEscape(owner) + "/" + url.PathEscape(name) +
120 65 "/view/beads?issue=" + url.QueryEscape(id)
121 65 }
122
123 // beadCrossLinks builds the link index for one render, and returns nil for every
124 // page that is not an issue detail pane: the board carries ids in card headers
125 // that are already links, and nothing else in this service renders stored prose.
126 // Building the index opens a store per database, so it is asked for by the one
127 // rendering that needs it and never as part of the envelope.
128 //
129 // Which databases may enter the index is linkableDatabases' answer and not this
130 // function's. What that leaves here: a database this caller may not browse never
131 // enters it, so an id belonging to it renders as plain text — indistinguishable
132 // from one whose prefix matches nothing. That indistinguishability is the point:
133 // a tooltip, a class or an "unknown tracker" marker would each publish the
134 // existence of a database this caller is not allowed to know about.
135 65 func (a *app) beadCrossLinks(r *http.Request, view View, repo *core.Repo, data any) *beadLinks {
136 65 bv, ok := view.(*beadsView)
137 65 if !ok {
138 22 return nil
139 22 }
140 43 d, ok := data.(*beads.Data)
141 43 if !ok || (d.Mode != beadsModeDetail && d.Mode != beadsModeEpic) {
142 17 return nil
143 17 }
144
145 26 dbs, open, ok := a.linkableDatabases(r, repo)
146 26 if !ok {
147 1 return nil
148 1 }
149
150 25 index := beads.PrefixesAcross(r.Context(), dbs, open, &bv.prefixes, timeNow())
151 25
152 25 // A store that cannot be read is a fact about this deployment and belongs in
153 25 // the log with its error. The page says nothing at all: the ids that database
154 25 // owns simply stay text.
155 25 for _, f := range index.Failed {
156 0 slog.Warn("reading a database for the issue link index failed",
157 0 "component", "web", "database", f.Database.Slug(), scribe.Err(f.Err))
158 0 }
159 25 return &beadLinks{index: index}
160 }
161
162 // linkableDatabases is the set of databases a cross-database link index may be
163 // built over for this request, with the opener that reaches them. Both the issue
164 // links and the memory links ask for it, and they must ask the same way: the
165 // index decides what a reader is shown, so the rule for what may enter it lives
166 // in one place rather than in each caller.
167 //
168 // The rule is /ready's. Enumerate with ListReposForViewer (the listing rule),
169 // then ask core.Allowed/OpBrowse per database (the access rule), and do both
170 // before a single store is opened.
171 //
172 // The false return is a listing that failed, and it costs the page its links and
173 // not the page: this is decoration on top of an answer, exactly as the freshness
174 // line is, and it may never be the reason a reader gets a 500.
175 func (a *app) linkableDatabases(
176 r *http.Request,
177 repo *core.Repo,
178 41 ) ([]beads.ReadyDatabase, beads.ReadyOpener, bool) {
179 41 _, caller := callerOf(r.Context())
180 41 repos, err := a.cfg.Repos.ListReposForViewer(r.Context(), caller)
181 41 if err != nil {
182 2 slog.Error("listing databases for the cross-database link index failed",
183 2 "component", "web", scribe.Err(err))
184 2 return nil, nil, false
185 2 }
186
187 // The disk path never reaches beads: it is this service's arrangement of its
188 // own storage. The opener closes over the map, so a database that was filtered
189 // out here has no path to be opened by.
190 39 paths := make(map[int]string, len(repos))
191 39 dbs := make([]beads.ReadyDatabase, 0, len(repos))
192 52 for _, cand := range repos {
193 52 if !core.Allowed(caller, cand, a.effectiveACL(r, caller, cand), core.OpBrowse) {
194 0 continue
195 }
196 52 paths[cand.ID] = cand.Path
197 52 entry := beads.ReadyDatabase{ID: cand.ID, OwnerName: cand.OwnerName, Name: cand.Name}
198 52 if cand.ID == repo.ID {
199 39 // The database whose page this is goes first. It is the one the ceiling
200 39 // may never drop — its own ids have always linked and must keep
201 39 // linking — and it is what makes a [[slug]] this tracker holds resolve
202 39 // here rather than in whichever other tracker also carries a copy.
203 39 dbs = append([]beads.ReadyDatabase{entry}, dbs...)
204 39 continue
205 }
206 13 dbs = append(dbs, entry)
207 }
208
209 52 open := func(ctx context.Context, d beads.ReadyDatabase) (beads.ReadySession, error) {
210 52 path, ok := paths[d.ID]
211 52 if !ok {
212 0 return nil, fmt.Errorf("web: no store path for database %s", d.Slug())
213 0 }
214 52 return a.cfg.Browse.Open(ctx, path)
215 }
216 39 return dbs, open, true
217 }