~bigbes/sr-ht-dolt

ref: dc918296868d42d3c1824561416aedcb87ab1f89 sr-ht-dolt/web/beads.go -rw-r--r-- 8.1 KiB
dc918296 — Eugene Blikh web: link issue ids to the tracker that owns them 5 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
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}
}