package mcpsrv
import (
"context"
"fmt"
"strings"
"time"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// Commenter is everything spec_comment may reach, and the list is short on
// purpose: read the threads, read the revision they are anchored against, and
// append a reply.
//
// What it leaves out is the load-bearing part. *service.Service also has
// CommentOn and ResolveThread, and both are owner-only there; naming them here
// would be harmless today and a regression the first time somebody relaxed the
// service-side check. An unresolved thread suppresses policy auto-merge, so an
// agent that could open or resolve one would hold the gate that exists to hold
// its own output back. The handler is written against this interface rather
// than against Writer so that "the comment tool cannot resolve a thread" is a
// property of the types, not of the handler remembering not to.
type Commenter interface {
Threads(ctx context.Context, p authn.Principal, proposalID int) ([]service.Thread, error)
ReplyTo(ctx context.Context, p authn.Principal, threadID int, body string) (service.Comment, error)
GetProposal(ctx context.Context, id int) (service.Proposal, error)
ProposalDiff(ctx context.Context, p service.Proposal) ([]service.ProposalDoc, error)
}
type commentInput struct {
Proposal int `json:"proposal" jsonschema:"the proposal whose review threads to read, or whose thread to reply to — the id spec_propose returned"`
// Thread and Body together are the reply; omitting both lists.
Thread int `json:"thread,omitempty" jsonschema:"reply to this thread, given as the thread id from a listing. Omit both this and body to list the proposal's threads instead."`
Body string `json:"body,omitempty" jsonschema:"the reply text, required when thread is given"`
}
// commentAuthor is one message: a thread's root or a reply. Times are formatted
// strings rather than time.Time because the tool's schema is derived from this
// struct by reflection, and a time.Time would reach the agent as an object with
// no fields.
type commentAuthor struct {
ID int `json:"id"`
// Thread is the id of the thread this message belongs to. On a root it is
// its own id, which is the value to send back as the thread argument.
Thread int `json:"thread"`
Author string `json:"author"`
// Agent reports whether an agent wrote this, so a reply of one's own is
// distinguishable from the owner's critique without parsing the name.
Agent bool `json:"agent"`
Body string `json:"body"`
Created string `json:"created"`
}
type commentThread struct {
commentAuthor
// Document is the path of the document the thread is on, and DocID the
// archive key its anchor was written against — the anchor survives a rename,
// so the two can disagree.
Document string `json:"document"`
DocID string `json:"doc_id,omitempty"`
// Heading is the enclosing headings of the commented block, outermost
// first. With Document it is how to find the block being talked about.
Heading []string `json:"heading,omitempty"`
// Side is which revision of the block was commented on: "new" for the
// proposed text, "old" for a block the proposal deletes.
Side string `json:"side,omitempty"`
// State is how well the anchor still fits the CURRENT proposal branch:
// "anchored" (the block is there verbatim), "edited" (the block is at that
// position but its text has changed since the comment) or "outdated" (the
// anchor lost its block). Acting on an outdated critique is the failure this
// field exists to prevent.
State string `json:"state"`
// Block is the block's index in the document, or -1 when the anchor is
// outdated and points at nothing.
Block int `json:"block"`
// Open reports whether the thread still awaits the owner. Only the owner can
// close one; a reply never does.
Open bool `json:"open"`
Replies []commentAuthor `json:"replies,omitempty"`
}
type commentOutput struct {
Proposal int `json:"proposal"`
// Threads is set when listing, and empty when the proposal has no review
// threads at all — which is a proposal nobody has commented on, not an
// error.
Threads []commentThread `json:"threads,omitempty"`
// Reply is set when replying, and carries the stored reply as it was
// attributed: an agent's reply comes back under its own agent identity.
Reply *commentAuthor `json:"reply,omitempty"`
}
func commentHandler(ctx context.Context, c Commenter, in commentInput) (commentOutput, error) {
if in.Proposal <= 0 {
return commentOutput{}, fmt.Errorf("proposal must name a proposal id")
}
// The mode is chosen on whether body was sent at all, not on whether it has
// anything in it: a whitespace body is a caller that meant to reply and
// botched it, and quietly listing instead would look like the reply landed.
body := strings.TrimSpace(in.Body)
switch {
case in.Thread > 0 && body == "":
return commentOutput{}, fmt.Errorf("a reply to thread %d needs a body", in.Thread)
case in.Thread <= 0 && in.Body != "":
return commentOutput{}, fmt.Errorf("body needs the thread it answers; pass thread, " +
"or omit body to list this proposal's threads")
}
// The principal is the one the resolver middleware put on this request.
// service.Threads and service.ReplyTo apply the ACL — this layer forwards
// the caller rather than deciding anything, so the read plane has one policy.
principal := authn.PrincipalFromContext(ctx)
// Threads runs before anything touches git, in both modes. It is one query,
// it is where the ACL is enforced, and a proposal with no threads needs no
// revision read at all.
threads, err := c.Threads(ctx, principal, in.Proposal)
if err != nil {
return commentOutput{}, err
}
if in.Thread > 0 {
return replyToThread(ctx, c, principal, threads, in.Proposal, in.Thread, body)
}
return listThreads(ctx, c, threads, in.Proposal)
}
// listThreads resolves every anchor against the proposal branch as it stands
// now and reports the fit.
//
// The anchoring is not optional decoration. A thread's stored anchor says where
// the comment was written, and the branch has moved since — often because this
// very agent revised it. An agent asking "what should I fix" that is not told
// the critique no longer describes any block will go and fix the wrong
// paragraph, so the state travels with every thread.
func listThreads(ctx context.Context, c Commenter, threads []service.Thread, proposalID int) (commentOutput, error) {
out := commentOutput{Proposal: proposalID}
if len(threads) == 0 {
return out, nil
}
p, err := c.GetProposal(ctx, proposalID)
if err != nil {
return commentOutput{}, err
}
docs, err := c.ProposalDiff(ctx, p)
if err != nil {
return commentOutput{}, err
}
out.Threads = make([]commentThread, 0, len(threads))
for _, t := range service.AnchorThreads(threads, docs) {
out.Threads = append(out.Threads, threadEntry(t))
}
return out, nil
}
// replyToThread appends the reply, after checking the thread is one of this
// proposal's.
//
// A thread id is a global integer, so a mistyped one names a real thread on
// somebody else's proposal, and service.ReplyTo would accept it: the id is all
// it needs. Requiring the proposal and checking membership here turns that
// typo into an error instead of a reply that lands out of sight of the agent
// that wrote it. The threads were already read for the ACL check, so it costs
// nothing.
func replyToThread(ctx context.Context, c Commenter, p authn.Principal, threads []service.Thread, proposalID, threadID int, body string) (commentOutput, error) {
found := false
for _, t := range threads {
if t.Root.ID == threadID {
found = true
break
}
}
if !found {
return commentOutput{}, fmt.Errorf("proposal %d has no review thread %d; "+
"call spec_comment with only proposal to list its threads", proposalID, threadID)
}
reply, err := c.ReplyTo(ctx, p, threadID, body)
if err != nil {
return commentOutput{}, err
}
entry := authorEntry(reply)
return commentOutput{Proposal: proposalID, Reply: &entry}, nil
}
func threadEntry(t service.Thread) commentThread {
e := commentThread{
commentAuthor: authorEntry(t.Root),
Document: t.DocPath,
DocID: t.Anchor.DocID,
Heading: t.Anchor.HeadingPath,
Side: string(t.Anchor.Side),
State: string(t.State),
Block: t.Block,
Open: t.Open(),
}
// A root's thread id is its own id: that is the value spec_comment takes
// back as the thread argument.
e.Thread = t.Root.ID
for _, r := range t.Replies {
e.Replies = append(e.Replies, authorEntry(r))
}
return e
}
func authorEntry(c service.Comment) commentAuthor {
return commentAuthor{
ID: c.ID,
Thread: c.ParentID,
Author: c.Author,
Agent: c.Agent,
Body: c.Body,
Created: c.Created.UTC().Format(time.RFC3339),
}
}