package service import ( "context" "errors" "fmt" "time" "sourcecraft.dev/bigbes/sr-ht-spec/authn" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/db" "sourcecraft.dev/bigbes/sr-ht-spec/prosediff" ) // Comment is one message in a review thread, as the surfaces above this layer // need it. Agent reports the authoring agent's identity and is empty for the // owner, which is the only distinction a reader needs — who said it. type Comment struct { ID int ParentID int Body string Author string Agent bool Created time.Time } // Thread is one review conversation: a comment anchored to a block of a // document, plus its replies in order. // // State and Block are filled in by [AnchorThreads] against a particular // revision and are meaningless before it runs — State is the zero AnchorState // and Block is 0, not -1. That is deliberate: anchor fit is a property of a // revision, and a Thread that has not been resolved against one has no honest // answer to give. type Thread struct { Root Comment DocPath string Anchor core.CommentAnchor Replies []Comment // Resolved is when the owner closed the thread, or nil while it is open. // An open thread suppresses policy auto-merge; see [Service.autoMerges]. Resolved *time.Time // State is how well the anchor still fits the revision it was resolved // against. State core.AnchorState // Block is the index into that revision's blocks, or -1 when the anchor did // not resolve. Block int } // Open reports whether the thread still awaits the owner. func (t Thread) Open() bool { return t.Resolved == nil } // CommentRequest opens a review thread on one block of one document. type CommentRequest struct { Principal authn.Principal Space core.SpaceRef ProposalID int // DocPath is the document's path on the proposal branch. DocPath string // Anchor names the block. Its DocID is the archive's addressing key for the // document, which is what makes the thread survive a later rename. Anchor core.CommentAnchor Body string } // CommentOn opens a review thread anchored to a block of a proposed document. // // Owner-only. An agent may reply to a thread but may not start one: the review // conversation exists so a human can direct an agent, and an agent opening // threads on its own proposal would put unresolved threads — which suppress // policy auto-merge — under the control of the thing the gate exists to hold // back. func (s *Service) CommentOn(ctx context.Context, req CommentRequest) (Thread, error) { if !req.Principal.IsOwner() { return Thread{}, fmt.Errorf("%w: %s may not open a review thread; that is the owner's", ErrForbidden, req.Principal) } if req.DocPath == "" { return Thread{}, fmt.Errorf("%w: a comment must name the document it is on", ErrInvalid) } if req.Anchor.DocID == "" { return Thread{}, fmt.Errorf("%w: a comment must carry the document id it anchors to", ErrInvalid) } if req.Body == "" { return Thread{}, fmt.Errorf("%w: a comment needs a body", ErrInvalid) } if req.Anchor.Side == "" { req.Anchor.Side = core.SideNew } // The proposal is read through the normal path so a comment on a missing or // foreign proposal fails here rather than as a foreign-key violation. if _, err := s.GetProposal(ctx, req.ProposalID); err != nil { return Thread{}, err } row, err := s.store.AddComment(ctx, &db.Comment{ ProposalID: req.ProposalID, Anchor: &req.Anchor, DocPath: req.DocPath, Body: req.Body, Author: req.Principal.Owner, Kind: db.AuthorHuman, }) if err != nil { return Thread{}, fmt.Errorf("service: comment on proposal %d: %w", req.ProposalID, err) } return threadView(row, nil), nil } // ReplyTo appends a reply to an existing thread, returning the stored reply. // // Both principals may reply: this is the loop's turn-taking — the owner // critiques, the agent answers and revises. A reply never resolves the thread, // so an agent answering a critique does not clear the auto-merge gate; only the // owner accepting the answer does. func (s *Service) ReplyTo(ctx context.Context, p authn.Principal, threadID int, body string) (Comment, error) { if !p.CanRead() { return Comment{}, fmt.Errorf("%w: %s may not comment", ErrForbidden, p) } if body == "" { return Comment{}, fmt.Errorf("%w: a reply needs a body", ErrInvalid) } reply := &db.Comment{Body: body} if p.IsAgent() { if p.Agent == "" || p.Session == "" { return Comment{}, fmt.Errorf("%w: an agent reply must carry its identity and session", ErrInvalid) } reply.Author, reply.Kind, reply.Session = p.Agent, db.AuthorAgent, p.Session } else { reply.Author, reply.Kind = p.Owner, db.AuthorHuman } row, err := s.store.ReplyComment(ctx, threadID, reply) if errors.Is(err, db.ErrNotFound) { return Comment{}, fmt.Errorf("%w: no review thread %d", ErrNotFound, threadID) } if err != nil { return Comment{}, fmt.Errorf("service: reply to thread %d: %w", threadID, err) } return commentView(row), nil } // ResolveThread closes a review thread, or reopens it when resolved is false. // // Owner-only, and this is the rule the auto-merge gate rests on: an agent that // could resolve the thread opened against its own proposal could clear the gate // holding that proposal back, which is the one thing the gate is for. func (s *Service) ResolveThread(ctx context.Context, p authn.Principal, threadID int, resolved bool) error { if !p.IsOwner() { return fmt.Errorf("%w: %s may not resolve a review thread; only the owner may", ErrForbidden, p) } err := s.store.ResolveComment(ctx, threadID, resolved) if errors.Is(err, db.ErrNotFound) { return fmt.Errorf("%w: no review thread %d", ErrNotFound, threadID) } if err != nil { return fmt.Errorf("service: resolve thread %d: %w", threadID, err) } return nil } // Threads returns a proposal's review conversations, each with its replies in // order, oldest thread first. // // The anchors are not resolved here: fit depends on which revision the caller // is looking at, so it is [AnchorThreads] that answers it, against the documents // the caller already read. func (s *Service) Threads(ctx context.Context, p authn.Principal, proposalID int) ([]Thread, error) { if !p.CanRead() { return nil, fmt.Errorf("%w: %s may not read review threads", ErrForbidden, p) } rows, err := s.store.ListComments(ctx, proposalID) if err != nil { return nil, fmt.Errorf("service: threads of proposal %d: %w", proposalID, err) } replies := make(map[int][]Comment) for _, r := range rows { if !r.Root() { replies[r.ParentID] = append(replies[r.ParentID], commentView(r)) } } var out []Thread for _, r := range rows { if r.Root() { out = append(out, threadView(r, replies[r.ID])) } } return out, nil } // AnchorThreads resolves every thread's anchor against a revision's documents, // filling in State and Block. // // It lives here rather than in each surface for the reason [Service.Archive] // does: the review page and the MCP tool must agree about whether a comment // still fits, and two surfaces each segmenting and matching would agree only // until one of them was changed. Each document is segmented at most once per // side however many threads hang off it. // // A thread whose document is not in docs is outdated, not dropped. That happens // when the agent's revision reverted the document to its base — it is no longer // a changed document, so the review page never renders it — and a comment that // silently vanished would look like one that was never made. func AnchorThreads(threads []Thread, docs []ProposalDoc) []Thread { byPath := make(map[string]ProposalDoc, len(docs)) for _, d := range docs { byPath[d.Path] = d } type key struct { path string side core.CommentSide } segmented := make(map[key][]core.AnchorBlock) out := make([]Thread, len(threads)) for i, t := range threads { out[i] = t out[i].Block, out[i].State = -1, core.AnchorOutdated doc, ok := byPath[t.DocPath] if !ok { continue } k := key{t.DocPath, t.Anchor.Side} blocks, done := segmented[k] if !done { src := doc.Proposed if t.Anchor.Side == core.SideOld { src = doc.Base } blocks = anchorBlocksOf(src) segmented[k] = blocks } out[i].Block, out[i].State = core.ResolveAnchor(t.Anchor, blocks) } return out } // anchorBlocksOf segments a document and reduces it to what anchoring reads. // A nil source — the base of a document the proposal adds — has no blocks, so // every anchor against it is outdated, which is the honest answer. func anchorBlocksOf(src []byte) []core.AnchorBlock { if len(src) == 0 { return nil } segs := prosediff.Segment(src) hashes := make([]string, len(segs)) paths := make([][]string, len(segs)) for i, b := range segs { hashes[i], paths[i] = b.Hash, b.HeadingPath } return core.AnchorBlocks(hashes, paths) } // AnchorOf builds the anchor for a block of a document. // // A surface offering a "comment on this block" control has a position in // prosediff's document-global block order; the anchor needs the position within // the block's own heading path instead. Converting here means the web form and // the MCP tool cannot each get the numbering subtly different, which would put // their comments on different blocks of the same document. // // It reads that numbering out of [core.AnchorBlocks] — the same call // [AnchorThreads] resolves against — rather than recomputing it. A second // implementation of "which block is this within its section" would agree with // the first only for as long as nobody edited either, and the failure it would // eventually produce is a comment stored against one block and displayed // against another. func AnchorOf(docID string, src []byte, ordinal int, side core.CommentSide) (core.CommentAnchor, error) { blocks := anchorBlocksOf(src) if ordinal < 0 || ordinal >= len(blocks) { return core.CommentAnchor{}, fmt.Errorf("%w: block %d is outside the document's %d blocks", ErrInvalid, ordinal, len(blocks)) } b := blocks[ordinal] return core.CommentAnchor{ DocID: docID, HeadingPath: b.HeadingPath, Index: b.Index, BlockHash: b.Hash, Side: side, }, nil } // commentView maps a stored comment onto the surface shape. func commentView(c *db.Comment) Comment { return Comment{ ID: c.ID, ParentID: c.ParentID, Body: c.Body, Author: c.Author, Agent: c.Kind == db.AuthorAgent, Created: c.Created, } } // threadView maps a stored root plus its replies onto the surface shape. Block // is -1 until AnchorThreads runs: an unresolved anchor points at no block, and // zero would point at the first one. func threadView(root *db.Comment, replies []Comment) Thread { t := Thread{ Root: commentView(root), DocPath: root.DocPath, Replies: replies, Resolved: root.Resolved, Block: -1, } if root.Anchor != nil { t.Anchor = *root.Anchor } return t }