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 }