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: 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 cross-site guard used to open this list and is gone from it: it is
// csrf.Require on the router now (Handler), where it also covers the POST
// nobody has written yet.
//
// 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 !mayRead(r) {
s.renderError(w, r, http.StatusForbidden, "you may not read this proposal")
return service.Proposal{}, false
}
// The grant half of the same ACL. The review conversation is content, so a
// tokens.sr.ht token reaches it on spec:read like every other read here.
if err := readGrant(r); err != nil {
s.denyGrant(w, r, formatHTML)
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
}