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) { // Both modes of this tool begin by reading the review conversation — the // reply path lists the threads before it can answer one — so it is a read // surface and carries the read grant. The grant vocabulary has no separate // action for commenting, and inventing one here would put a word in the // instance's dictionary that no token was ever minted with. if err := requireRead(ctx); err != nil { return commentOutput{}, err } 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), } }