~bigbes/sr-ht-spec

ref: 394285f4cb998359c0a127454de4d32284a3ef22 sr-ht-spec/web/comments.go -rw-r--r-- 15.3 KiB
394285f4 — Eugene Blikh chore(beads): file spec-rsb and spec-ovo, the two admin commands 13 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
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
package web

import (
	"net/http"
	"sort"
	"strconv"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/doc"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

// commentTimeFormat is how a comment's timestamp reads on the page. Minutes are
// the finest unit a review conversation cares about.
const commentTimeFormat = "2006-01-02 15:04"

// reviewControls is who may do what to the review threads on one page.
//
// The two authorities are kept apart because the service keeps them apart: only
// the owner may open or resolve a thread, while owner and agent alike may
// reply. This struct decides which controls are *drawn*; it is not the check —
// service.CommentOn and service.ResolveThread refuse a non-owner themselves and
// this page surfaces their ErrForbidden. Drawing a control the service would
// refuse is the failure this prevents, not privilege escalation.
type reviewControls struct {
	// Owner draws the compose form and the resolve/reopen control.
	Owner bool
	// Reply draws the reply form.
	Reply bool
	// ActionBase is the proposal's URL, which every form posts under.
	ActionBase string
}

// threadPanel is one review thread as the page renders it: the root comment,
// its replies, and the controls the viewer is allowed.
type threadPanel struct {
	ID       int
	Author   string
	Agent    bool
	Body     string
	When     string
	Replies  []replyLine
	Resolved bool

	// Note is a short badge naming an anchor that no longer fits exactly, and
	// NoteWhy is its tooltip. Both are empty for core.AnchorExact: a comment that
	// still sits on the text it was written about needs no annotation.
	Note    string
	NoteWhy string

	// DocPath is set only for a thread the page could not place on a block, where
	// the document it belonged to is the only remaining locator. A placed thread
	// leaves it empty — the document heading it renders under already says it.
	DocPath string

	// AnchorPath and AnchorIndex name the block this thread is drawn against,
	// display-only. They are set for a thread the diff placed and left empty for
	// one it could not: a lost thread's anchor is precisely what did not resolve,
	// and printing the path it used to name would read as a place it still sits.
	AnchorPath  string
	AnchorIndex int

	CanReply   bool
	CanResolve bool
	ActionBase string
}

// replyLine is one reply under a root comment. Threading is flat by design:
// there is a root and there are answers to it, and nothing nests deeper.
type replyLine struct {
	Author string
	Agent  bool
	Body   string
	When   string
}

// composeForm is the "comment on this block" form's hidden state.
//
// It carries the block's document-global prosediff ordinal rather than a
// pre-built anchor: building the anchor is service.AnchorOf's job, and doing it
// at submit time re-reads the branch, so a form drawn against a revision the
// agent has since replaced is caught by Hash rather than silently anchored to
// whatever now sits at that ordinal.
type composeForm struct {
	ActionBase string
	DocPath    string
	Ordinal    int
	Side       core.CommentSide
	Hash       string

	// AnchorPath and AnchorIndex are shown, not submitted: the reviewer selects
	// lines and the form stores a block, and this is where that indirection is
	// stated instead of being magic. AnchorIndex is the anchor's own index —
	// 0-based, within the heading path — because a number invented for display
	// would not be the number the comment stores.
	AnchorPath  string
	AnchorIndex int
}

// anchorPathLabel names a block's section for a reader: its enclosing headings,
// outermost first, or the phrase for a block that sits above the document's
// first heading. Empty would leave the composer saying "Anchors to" and nothing.
func anchorPathLabel(path []string) string {
	if s := strings.Join(path, " › "); s != "" {
		return s
	}
	return "(document preamble)"
}

// blockComments is what the "blockthreads" template renders for one block: the
// threads already on it and, for the owner, the form that opens a new one.
type blockComments struct {
	Threads []threadPanel
	Compose *composeForm
}

// threadPanelOf maps a resolved service.Thread onto the page's shape.
//
// The anchor state is reported, never hidden. core.AnchorEdited means the block
// is still where the comment pointed but its text has moved on, so the critique
// may no longer fit — the reader has to be told that, because a stale critique
// read as a current one is worse than no critique. core.AnchorOutdated means the
// anchor is lost entirely; such a thread is never drawn against a block at all,
// only in the page's unplaced area.
func threadPanelOf(t service.Thread, c reviewControls) threadPanel {
	p := threadPanel{
		ID:         t.Root.ID,
		Author:     t.Root.Author,
		Agent:      t.Root.Agent,
		Body:       t.Root.Body,
		When:       t.Root.Created.Format(commentTimeFormat),
		Resolved:   !t.Open(),
		CanReply:   c.Reply,
		CanResolve: c.Owner,
		ActionBase: c.ActionBase,
	}
	for _, r := range t.Replies {
		p.Replies = append(p.Replies, replyLine{
			Author: r.Author,
			Agent:  r.Agent,
			Body:   r.Body,
			When:   r.Created.Format(commentTimeFormat),
		})
	}
	switch t.State {
	case core.AnchorEdited:
		p.Note = "block edited since"
		p.NoteWhy = "The block is still here but its text changed after this comment was written."
	case core.AnchorOutdated:
		p.Note = "anchor lost"
		p.NoteWhy = "The block this comment was written about is no longer in the proposed revision."
	}
	return p
}

// lostPanels renders the threads no block claimed, in a stable order.
//
// They carry their document path because that is all that is left of where they
// pointed, and they are sorted so two renders of the same page agree: the
// threads arrive grouped per document from a map walk, which has no order of its
// own.
func lostPanels(threads []service.Thread, c reviewControls) []threadPanel {
	out := make([]threadPanel, 0, len(threads))
	for _, t := range threads {
		p := threadPanelOf(t, c)
		p.DocPath = t.DocPath
		out = append(out, p)
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].DocPath != out[j].DocPath {
			return out[i].DocPath < out[j].DocPath
		}
		return out[i].ID < out[j].ID
	})
	return out
}

// docIDFor derives a document's anchoring key: its frontmatter id when it has a
// well-formed one, otherwise its path without the extension. That is the
// archive's addressing rule (doc/scan.go), and anchoring on the id rather than
// the path is what lets a comment survive a rename.
//
// The archive also refuses an id that two documents claim; that contest cannot
// be judged here, because a review holds only the documents the proposal changes
// and not the whole revision they sit in. The consequence is bounded: the anchor
// carries the document path as well, and it is the path that service.AnchorThreads
// resolves a thread by.
func docIDFor(path string, src []byte) string {
	front, _ := doc.ParseFront(src)
	if core.ValidateDocID(front.ID) == nil {
		return front.ID
	}
	return strings.TrimSuffix(path, core.DocExt)
}

// ---- handlers -------------------------------------------------------------

// handleProposalComment opens a review thread on one block of a proposed
// document.
//
// The anchor is built here, at submit time, from the branch as it now reads —
// not from anything the form carries — because a form drawn ten minutes ago
// describes a revision the agent may have replaced since. The form's block hash
// is the guard on that: if the block at the submitted ordinal no longer hashes
// to what the reviewer was looking at, the comment is refused rather than
// attached to whatever moved into that position.
//
// service.AnchorOf does the ordinal conversion. Hand-rolling it here would put
// the browser's comments on different blocks than the MCP tool's, which is the
// one way two surfaces of the same conversation can disagree without either
// looking broken.
func (s *Server) handleProposalComment(w http.ResponseWriter, r *http.Request) {
	p, ok := s.commentPost(w, r)
	if !ok {
		return
	}

	docPath := r.PostFormValue("doc")
	body := strings.TrimSpace(r.PostFormValue("body"))
	ordinal, err := strconv.Atoi(r.PostFormValue("block"))
	if err != nil || ordinal < 0 {
		s.renderError(w, r, http.StatusBadRequest, "that comment names no block")
		return
	}
	side, err := core.ParseCommentSide(r.PostFormValue("side"))
	if err != nil {
		s.renderError(w, r, http.StatusBadRequest, err.Error())
		return
	}
	if body == "" {
		s.renderError(w, r, http.StatusBadRequest, "a comment needs a body")
		return
	}

	docs, err := s.reader.ProposalDiff(r.Context(), p)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	src, found := sourceOfSide(docs, docPath, side)
	if !found {
		s.renderError(w, r, http.StatusNotFound, "this proposal does not change that document")
		return
	}

	anchor, err := service.AnchorOf(docIDFor(docPath, src), src, ordinal, side)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	// The hash is required, not merely checked when present. Skipping the guard
	// for a submission that omits it would mean a later template refactor that
	// dropped the hidden field disabled the staleness check silently, with every
	// test still passing — the comment would still store a coherent anchor, just
	// not the block the reviewer was reading.
	switch want := r.PostFormValue("hash"); {
	case want == "":
		s.renderError(w, r, http.StatusBadRequest, "that comment names no block revision")
		return
	case want != anchor.BlockHash:
		s.renderError(w, r, http.StatusConflict,
			"that block changed since this page was loaded; reload the proposal and comment again")
		return
	}

	thread, err := s.reader.CommentOn(r.Context(), service.CommentRequest{
		Principal:  authn.PrincipalFromContext(r.Context()),
		Space:      p.Space,
		ProposalID: p.ID,
		DocPath:    docPath,
		Anchor:     anchor,
		Body:       body,
	})
	if err != nil {
		s.fail(w, r, err)
		return
	}
	s.backToThread(w, r, p, thread.Root.ID)
}

// handleProposalReply appends a reply to an existing thread. Both principals may
// reply — that is the loop's turn-taking, the owner critiques and the agent
// answers — and service.ReplyTo is what says so.
func (s *Server) handleProposalReply(w http.ResponseWriter, r *http.Request) {
	p, ok := s.commentPost(w, r)
	if !ok {
		return
	}
	threadID, ok := s.threadOfProposal(w, r, p.ID)
	if !ok {
		return
	}
	body := strings.TrimSpace(r.PostFormValue("body"))
	if body == "" {
		s.renderError(w, r, http.StatusBadRequest, "a reply needs a body")
		return
	}
	if _, err := s.reader.ReplyTo(r.Context(), authn.PrincipalFromContext(r.Context()), threadID, body); err != nil {
		s.fail(w, r, err)
		return
	}
	s.backToThread(w, r, p, threadID)
}

// handleProposalResolve closes a thread, or reopens it when the form says so.
//
// Owner-only, enforced by service.ResolveThread rather than re-stated here: an
// agent that could resolve the thread opened against its own proposal could
// clear the auto-merge gate that thread exists to hold shut.
func (s *Server) handleProposalResolve(w http.ResponseWriter, r *http.Request) {
	p, ok := s.commentPost(w, r)
	if !ok {
		return
	}
	threadID, ok := s.threadOfProposal(w, r, p.ID)
	if !ok {
		return
	}
	resolved := r.PostFormValue("resolved") == "1"
	who := authn.PrincipalFromContext(r.Context())
	if err := s.reader.ResolveThread(r.Context(), who, threadID, resolved); err != nil {
		s.fail(w, r, err)
		return
	}
	s.backToThread(w, r, p, threadID)
}

// commentPost is the prologue every comment POST shares: the cross-site guard,
// read authority, a parsed form, and the proposal the URL names — refusing one
// that belongs to another space for the same reason the review page does, that
// the id is global but the link names its space.
//
// The one authority it checks is the read ACL — these handlers go on to read the
// proposal branch to place an anchor and to list a proposal's threads, and that
// is a read like any other. Which principal may *write* what is deliberately not
// restated: opening and resolving are the owner's alone and replying is not, the
// service knows both rules, and a second copy here would be a second place for
// them to be wrong.
func (s *Server) commentPost(w http.ResponseWriter, r *http.Request) (service.Proposal, bool) {
	if !s.sameOrigin(r) {
		s.renderError(w, r, http.StatusForbidden, "this request did not originate from this site")
		return service.Proposal{}, false
	}
	if !mayRead(r) {
		s.renderError(w, r, http.StatusForbidden, "you may not read this proposal")
		return service.Proposal{}, false
	}
	if err := r.ParseForm(); err != nil {
		s.renderError(w, r, http.StatusBadRequest, "malformed form submission")
		return service.Proposal{}, false
	}
	ref, err := spaceRefFrom(r)
	if err != nil {
		s.fail(w, r, err)
		return service.Proposal{}, false
	}
	id, ok := proposalIDFrom(r)
	if !ok {
		s.renderError(w, r, http.StatusNotFound, "no such proposal")
		return service.Proposal{}, false
	}
	p, err := s.reader.GetProposal(r.Context(), id)
	if err != nil {
		s.fail(w, r, err)
		return service.Proposal{}, false
	}
	if p.Space != ref {
		s.renderError(w, r, http.StatusNotFound, "no such proposal in this space")
		return service.Proposal{}, false
	}
	return p, true
}

// threadOfProposal reads the "thread" form field and checks that it names a root
// thread of *this* proposal.
//
// Thread ids are global while the URL names one proposal, so without this a form
// could carry another proposal's thread id and have the reply land somewhere the
// reviewer was never looking. Matching against the roots also keeps threading
// flat: a reply's id is not a root, so it cannot be replied to.
func (s *Server) threadOfProposal(w http.ResponseWriter, r *http.Request, proposalID int) (int, bool) {
	threadID, err := strconv.Atoi(r.PostFormValue("thread"))
	if err != nil || threadID <= 0 {
		s.renderError(w, r, http.StatusBadRequest, "that action names no review thread")
		return 0, false
	}
	threads, err := s.reader.Threads(r.Context(), authn.PrincipalFromContext(r.Context()), proposalID)
	if err != nil {
		s.fail(w, r, err)
		return 0, false
	}
	for _, t := range threads {
		if t.Root.ID == threadID {
			return threadID, true
		}
	}
	s.renderError(w, r, http.StatusNotFound, "no such review thread on this proposal")
	return 0, false
}

// backToThread redirects to the proposal page, scrolled to the thread that was
// just written, so a reload does not re-submit and the reviewer lands on what
// they said rather than at the top of a long diff.
func (s *Server) backToThread(w http.ResponseWriter, r *http.Request, p service.Proposal, threadID int) {
	http.Redirect(w, r, proposalHref(p)+"#thread-"+strconv.Itoa(threadID), http.StatusSeeOther)
}

// sourceOfSide returns the revision of a document a comment on one side anchors
// against: the proposed text for a comment on the new side, the base for one on
// a block the proposal deletes.
func sourceOfSide(docs []service.ProposalDoc, path string, side core.CommentSide) ([]byte, bool) {
	for _, d := range docs {
		if d.Path != path {
			continue
		}
		if side == core.SideOld {
			return d.Base, d.Base != nil
		}
		return d.Proposed, true
	}
	return nil, false
}

// unresolvedThreads counts the threads still awaiting the owner. It is what the
// page states next to the approve control: an open thread suppresses policy
// auto-merge, so the count explains why a proposal is sitting here.
func unresolvedThreads(threads []service.Thread) int {
	n := 0
	for _, t := range threads {
		if t.Open() {
			n++
		}
	}
	return n
}