coverage~bigbes/sr-ht-spec64cae3afservice/comment.go

Coverage
82.1% 78/95 statements
Δ
+0.0
Blob
5a79fdf
1 package service
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "time"
8
9 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
10 "sourcecraft.dev/bigbes/sr-ht-spec/core"
11 "sourcecraft.dev/bigbes/sr-ht-spec/db"
12 "sourcecraft.dev/bigbes/sr-ht-spec/prosediff"
13 )
14
15 // Comment is one message in a review thread, as the surfaces above this layer
16 // need it. Agent reports the authoring agent's identity and is empty for the
17 // owner, which is the only distinction a reader needs — who said it.
18 type Comment struct {
19 ID int
20 ParentID int
21 Body string
22 Author string
23 Agent bool
24 Created time.Time
25 }
26
27 // Thread is one review conversation: a comment anchored to a block of a
28 // document, plus its replies in order.
29 //
30 // State and Block are filled in by [AnchorThreads] against a particular
31 // revision and are meaningless before it runs — State is the zero AnchorState
32 // and Block is 0, not -1. That is deliberate: anchor fit is a property of a
33 // revision, and a Thread that has not been resolved against one has no honest
34 // answer to give.
35 type Thread struct {
36 Root Comment
37 DocPath string
38 Anchor core.CommentAnchor
39 Replies []Comment
40 // Resolved is when the owner closed the thread, or nil while it is open.
41 // An open thread suppresses policy auto-merge; see [Service.autoMerges].
42 Resolved *time.Time
43
44 // State is how well the anchor still fits the revision it was resolved
45 // against.
46 State core.AnchorState
47 // Block is the index into that revision's blocks, or -1 when the anchor did
48 // not resolve.
49 Block int
50 }
51
52 // Open reports whether the thread still awaits the owner.
53 1 func (t Thread) Open() bool { return t.Resolved == nil }
54
55 // CommentRequest opens a review thread on one block of one document.
56 type CommentRequest struct {
57 Principal authn.Principal
58 Space core.SpaceRef
59 ProposalID int
60 // DocPath is the document's path on the proposal branch.
61 DocPath string
62 // Anchor names the block. Its DocID is the archive's addressing key for the
63 // document, which is what makes the thread survive a later rename.
64 Anchor core.CommentAnchor
65 Body string
66 }
67
68 // CommentOn opens a review thread anchored to a block of a proposed document.
69 //
70 // Owner-only. An agent may reply to a thread but may not start one: the review
71 // conversation exists so a human can direct an agent, and an agent opening
72 // threads on its own proposal would put unresolved threads — which suppress
73 // policy auto-merge — under the control of the thing the gate exists to hold
74 // back.
75 6 func (s *Service) CommentOn(ctx context.Context, req CommentRequest) (Thread, error) {
76 6 if !req.Principal.IsOwner() {
77 1 return Thread{}, fmt.Errorf("%w: %s may not open a review thread; that is the owner's", ErrForbidden, req.Principal)
78 1 }
79 5 if req.DocPath == "" {
80 0 return Thread{}, fmt.Errorf("%w: a comment must name the document it is on", ErrInvalid)
81 0 }
82 5 if req.Anchor.DocID == "" {
83 0 return Thread{}, fmt.Errorf("%w: a comment must carry the document id it anchors to", ErrInvalid)
84 0 }
85 5 if req.Body == "" {
86 0 return Thread{}, fmt.Errorf("%w: a comment needs a body", ErrInvalid)
87 0 }
88 5 if req.Anchor.Side == "" {
89 0 req.Anchor.Side = core.SideNew
90 0 }
91
92 // The proposal is read through the normal path so a comment on a missing or
93 // foreign proposal fails here rather than as a foreign-key violation.
94 5 if _, err := s.GetProposal(ctx, req.ProposalID); err != nil {
95 0 return Thread{}, err
96 0 }
97
98 5 row, err := s.store.AddComment(ctx, &db.Comment{
99 5 ProposalID: req.ProposalID,
100 5 Anchor: &req.Anchor,
101 5 DocPath: req.DocPath,
102 5 Body: req.Body,
103 5 Author: req.Principal.Owner,
104 5 Kind: db.AuthorHuman,
105 5 })
106 5 if err != nil {
107 0 return Thread{}, fmt.Errorf("service: comment on proposal %d: %w", req.ProposalID, err)
108 0 }
109 5 return threadView(row, nil), nil
110 }
111
112 // ReplyTo appends a reply to an existing thread, returning the stored reply.
113 //
114 // Both principals may reply: this is the loop's turn-taking — the owner
115 // critiques, the agent answers and revises. A reply never resolves the thread,
116 // so an agent answering a critique does not clear the auto-merge gate; only the
117 // owner accepting the answer does.
118 2 func (s *Service) ReplyTo(ctx context.Context, p authn.Principal, threadID int, body string) (Comment, error) {
119 2 if !p.CanRead() {
120 0 return Comment{}, fmt.Errorf("%w: %s may not comment", ErrForbidden, p)
121 0 }
122 2 if body == "" {
123 0 return Comment{}, fmt.Errorf("%w: a reply needs a body", ErrInvalid)
124 0 }
125
126 2 reply := &db.Comment{Body: body}
127 2 if p.IsAgent() {
128 2 if p.Agent == "" || p.Session == "" {
129 0 return Comment{}, fmt.Errorf("%w: an agent reply must carry its identity and session", ErrInvalid)
130 0 }
131 2 reply.Author, reply.Kind, reply.Session = p.Agent, db.AuthorAgent, p.Session
132 0 } else {
133 0 reply.Author, reply.Kind = p.Owner, db.AuthorHuman
134 0 }
135
136 2 row, err := s.store.ReplyComment(ctx, threadID, reply)
137 2 if errors.Is(err, db.ErrNotFound) {
138 0 return Comment{}, fmt.Errorf("%w: no review thread %d", ErrNotFound, threadID)
139 0 }
140 2 if err != nil {
141 0 return Comment{}, fmt.Errorf("service: reply to thread %d: %w", threadID, err)
142 0 }
143 2 return commentView(row), nil
144 }
145
146 // ResolveThread closes a review thread, or reopens it when resolved is false.
147 //
148 // Owner-only, and this is the rule the auto-merge gate rests on: an agent that
149 // could resolve the thread opened against its own proposal could clear the gate
150 // holding that proposal back, which is the one thing the gate is for.
151 2 func (s *Service) ResolveThread(ctx context.Context, p authn.Principal, threadID int, resolved bool) error {
152 2 if !p.IsOwner() {
153 1 return fmt.Errorf("%w: %s may not resolve a review thread; only the owner may", ErrForbidden, p)
154 1 }
155 1 err := s.store.ResolveComment(ctx, threadID, resolved)
156 1 if errors.Is(err, db.ErrNotFound) {
157 0 return fmt.Errorf("%w: no review thread %d", ErrNotFound, threadID)
158 0 }
159 1 if err != nil {
160 0 return fmt.Errorf("service: resolve thread %d: %w", threadID, err)
161 0 }
162 1 return nil
163 }
164
165 // Threads returns a proposal's review conversations, each with its replies in
166 // order, oldest thread first.
167 //
168 // The anchors are not resolved here: fit depends on which revision the caller
169 // is looking at, so it is [AnchorThreads] that answers it, against the documents
170 // the caller already read.
171 1 func (s *Service) Threads(ctx context.Context, p authn.Principal, proposalID int) ([]Thread, error) {
172 1 if !p.CanRead() {
173 0 return nil, fmt.Errorf("%w: %s may not read review threads", ErrForbidden, p)
174 0 }
175 1 rows, err := s.store.ListComments(ctx, proposalID)
176 1 if err != nil {
177 0 return nil, fmt.Errorf("service: threads of proposal %d: %w", proposalID, err)
178 0 }
179
180 1 replies := make(map[int][]Comment)
181 2 for _, r := range rows {
182 2 if !r.Root() {
183 1 replies[r.ParentID] = append(replies[r.ParentID], commentView(r))
184 1 }
185 }
186 1 var out []Thread
187 2 for _, r := range rows {
188 2 if r.Root() {
189 1 out = append(out, threadView(r, replies[r.ID]))
190 1 }
191 }
192 1 return out, nil
193 }
194
195 // AnchorThreads resolves every thread's anchor against a revision's documents,
196 // filling in State and Block.
197 //
198 // It lives here rather than in each surface for the reason [Service.Archive]
199 // does: the review page and the MCP tool must agree about whether a comment
200 // still fits, and two surfaces each segmenting and matching would agree only
201 // until one of them was changed. Each document is segmented at most once per
202 // side however many threads hang off it.
203 //
204 // A thread whose document is not in docs is outdated, not dropped. That happens
205 // when the agent's revision reverted the document to its base — it is no longer
206 // a changed document, so the review page never renders it — and a comment that
207 // silently vanished would look like one that was never made.
208 3 func AnchorThreads(threads []Thread, docs []ProposalDoc) []Thread {
209 3 byPath := make(map[string]ProposalDoc, len(docs))
210 3 for _, d := range docs {
211 2 byPath[d.Path] = d
212 2 }
213 3 type key struct {
214 3 path string
215 3 side core.CommentSide
216 3 }
217 3 segmented := make(map[key][]core.AnchorBlock)
218 3
219 3 out := make([]Thread, len(threads))
220 6 for i, t := range threads {
221 6 out[i] = t
222 6 out[i].Block, out[i].State = -1, core.AnchorOutdated
223 6
224 6 doc, ok := byPath[t.DocPath]
225 6 if !ok {
226 2 continue
227 }
228 4 k := key{t.DocPath, t.Anchor.Side}
229 4 blocks, done := segmented[k]
230 4 if !done {
231 2 src := doc.Proposed
232 2 if t.Anchor.Side == core.SideOld {
233 1 src = doc.Base
234 1 }
235 2 blocks = anchorBlocksOf(src)
236 2 segmented[k] = blocks
237 }
238 4 out[i].Block, out[i].State = core.ResolveAnchor(t.Anchor, blocks)
239 }
240 3 return out
241 }
242
243 // anchorBlocksOf segments a document and reduces it to what anchoring reads.
244 // A nil source — the base of a document the proposal adds — has no blocks, so
245 // every anchor against it is outdated, which is the honest answer.
246 14 func anchorBlocksOf(src []byte) []core.AnchorBlock {
247 14 if len(src) == 0 {
248 0 return nil
249 0 }
250 14 segs := prosediff.Segment(src)
251 14 hashes := make([]string, len(segs))
252 14 paths := make([][]string, len(segs))
253 71 for i, b := range segs {
254 71 hashes[i], paths[i] = b.Hash, b.HeadingPath
255 71 }
256 14 return core.AnchorBlocks(hashes, paths)
257 }
258
259 // AnchorOf builds the anchor for a block of a document.
260 //
261 // A surface offering a "comment on this block" control has a position in
262 // prosediff's document-global block order; the anchor needs the position within
263 // the block's own heading path instead. Converting here means the web form and
264 // the MCP tool cannot each get the numbering subtly different, which would put
265 // their comments on different blocks of the same document.
266 //
267 // It reads that numbering out of [core.AnchorBlocks] — the same call
268 // [AnchorThreads] resolves against — rather than recomputing it. A second
269 // implementation of "which block is this within its section" would agree with
270 // the first only for as long as nobody edited either, and the failure it would
271 // eventually produce is a comment stored against one block and displayed
272 // against another.
273 11 func AnchorOf(docID string, src []byte, ordinal int, side core.CommentSide) (core.CommentAnchor, error) {
274 11 blocks := anchorBlocksOf(src)
275 11 if ordinal < 0 || ordinal >= len(blocks) {
276 1 return core.CommentAnchor{}, fmt.Errorf("%w: block %d is outside the document's %d blocks",
277 1 ErrInvalid, ordinal, len(blocks))
278 1 }
279 10 b := blocks[ordinal]
280 10 return core.CommentAnchor{
281 10 DocID: docID,
282 10 HeadingPath: b.HeadingPath,
283 10 Index: b.Index,
284 10 BlockHash: b.Hash,
285 10 Side: side,
286 10 }, nil
287 }
288
289 // commentView maps a stored comment onto the surface shape.
290 9 func commentView(c *db.Comment) Comment {
291 9 return Comment{
292 9 ID: c.ID,
293 9 ParentID: c.ParentID,
294 9 Body: c.Body,
295 9 Author: c.Author,
296 9 Agent: c.Kind == db.AuthorAgent,
297 9 Created: c.Created,
298 9 }
299 9 }
300
301 // threadView maps a stored root plus its replies onto the surface shape. Block
302 // is -1 until AnchorThreads runs: an unresolved anchor points at no block, and
303 // zero would point at the first one.
304 6 func threadView(root *db.Comment, replies []Comment) Thread {
305 6 t := Thread{
306 6 Root: commentView(root),
307 6 DocPath: root.DocPath,
308 6 Replies: replies,
309 6 Resolved: root.Resolved,
310 6 Block: -1,
311 6 }
312 6 if root.Anchor != nil {
313 6 t.Anchor = *root.Anchor
314 6 }
315 6 return t
316 }