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, numbering it the way
// [core.AnchorBlocks] does.
//
// A surface offering a "comment on this block" control has a document and 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.
func AnchorOf(docID string, src []byte, ordinal int, side core.CommentSide) (core.CommentAnchor, error) {
segs := prosediff.Segment(src)
if ordinal < 0 || ordinal >= len(segs) {
return core.CommentAnchor{}, fmt.Errorf("%w: block %d is outside the document's %d blocks",
ErrInvalid, ordinal, len(segs))
}
blk := segs[ordinal]
// The within-section index is a count of preceding blocks sharing the
// heading path, which is exactly what AnchorBlocks assigns.
index := 0
for _, prior := range segs[:ordinal] {
if sameHeadingPath(prior.HeadingPath, blk.HeadingPath) {
index++
}
}
return core.CommentAnchor{
DocID: docID,
HeadingPath: blk.HeadingPath,
Index: index,
BlockHash: blk.Hash,
Side: side,
}, nil
}
func sameHeadingPath(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// 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
}