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

Coverage
83.8% 93/111 statements
Δ
Blob
c397e5c
1 package db
2
3 import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "time"
9
10 "github.com/lib/pq"
11
12 "sourcecraft.dev/bigbes/sr-ht-spec/core"
13 )
14
15 // AuthorKind distinguishes the owner from an agent. It is stored rather than
16 // inferred from the author string, because "who may resolve a thread" turns on
17 // it: an agent marking its own critique resolved would defeat the auto-merge
18 // gate, and a rule that depends on parsing an identity string is a rule that
19 // stops holding the first time an agent is named after a person.
20 type AuthorKind string
21
22 const (
23 AuthorHuman AuthorKind = "human"
24 AuthorAgent AuthorKind = "agent"
25 )
26
27 // ParseAuthorKind validates an author kind read back from Postgres.
28 6 func ParseAuthorKind(s string) (AuthorKind, error) {
29 6 switch AuthorKind(s) {
30 6 case AuthorHuman, AuthorAgent:
31 6 return AuthorKind(s), nil
32 }
33 0 return "", fmt.Errorf("comment: %q is not one of human|agent", s)
34 }
35
36 // Comment is one message in a review thread: either a thread root carrying an
37 // anchor, or a reply to one.
38 //
39 // Anchor is set on a root and nil on a reply. A reply inherits its root's anchor
40 // rather than copying it — two copies of one anchor is two things that can
41 // disagree about where a thread is attached.
42 //
43 // Nothing here records whether the anchor still fits. That is a property of the
44 // revision being looked at, not of the comment, and the branch moves under it as
45 // the agent revises; callers derive it with core.ResolveAnchor.
46 type Comment struct {
47 ID int
48 ProposalID int
49 ParentID int // 0 for a thread root
50
51 Anchor *core.CommentAnchor
52 DocPath string // path as at comment time; empty on a reply
53 Body string
54 Author string
55 Kind AuthorKind
56 Session string // agent session; empty for a human
57 Created time.Time
58 Resolved *time.Time
59 }
60
61 // Root reports whether the comment starts a thread rather than replying to one.
62 5 func (c *Comment) Root() bool { return c.ParentID == 0 }
63
64 const commentSelect = `
65 SELECT id, proposal_id, COALESCE(parent_id, 0), COALESCE(doc_id, ''),
66 COALESCE(doc_path, ''), heading_path, COALESCE(block_index, 0),
67 COALESCE(block_hash, ''), COALESCE(side, ''), body, author, author_kind,
68 COALESCE(agent_session, ''), created, resolved
69 FROM comment`
70
71 6 func scanComment(sc rowScanner) (*Comment, error) {
72 6 var (
73 6 c Comment
74 6 docID string
75 6 headingPath pq.StringArray
76 6 blockIndex int
77 6 blockHash string
78 6 side string
79 6 kind string
80 6 resolved sql.NullTime
81 6 )
82 6 if err := sc.Scan(&c.ID, &c.ProposalID, &c.ParentID, &docID, &c.DocPath,
83 6 &headingPath, &blockIndex, &blockHash, &side, &c.Body, &c.Author, &kind,
84 6 &c.Session, &c.Created, &resolved); err != nil {
85 0 return nil, err
86 0 }
87 6 parsedKind, err := ParseAuthorKind(kind)
88 6 if err != nil {
89 0 return nil, fmt.Errorf("comment %d: %w", c.ID, err)
90 0 }
91 6 c.Kind = parsedKind
92 6 if docID != "" {
93 4 parsedSide, err := core.ParseCommentSide(side)
94 4 if err != nil {
95 0 return nil, fmt.Errorf("comment %d: %w", c.ID, err)
96 0 }
97 4 c.Anchor = &core.CommentAnchor{
98 4 DocID: docID,
99 4 HeadingPath: []string(headingPath),
100 4 Index: blockIndex,
101 4 BlockHash: blockHash,
102 4 Side: parsedSide,
103 4 }
104 }
105 6 if resolved.Valid {
106 1 t := resolved.Time
107 1 c.Resolved = &t
108 1 }
109 6 return &c, nil
110 }
111
112 // AddComment inserts a thread root: a comment anchored to one block of one
113 // document in a proposal. c.Anchor, c.DocPath, c.Body, c.Author and c.Kind must
114 // be set. ID, Created, ParentID and Resolved are ignored on input — a root is
115 // always born unresolved.
116 11 func (s *Store) AddComment(ctx context.Context, c *Comment) (*Comment, error) {
117 11 if c.Anchor == nil {
118 0 return nil, fmt.Errorf("add comment: a thread root needs an anchor (use ReplyComment for a reply)")
119 0 }
120 11 if err := validateAuthor(c); err != nil {
121 5 return nil, fmt.Errorf("add comment: %w", err)
122 5 }
123 6 if _, err := core.ParseCommentSide(string(c.Anchor.Side)); err != nil {
124 0 return nil, fmt.Errorf("add comment: %w", err)
125 0 }
126 6 if c.Anchor.DocID == "" || c.DocPath == "" {
127 0 return nil, fmt.Errorf("add comment: anchor needs both a document id and a path")
128 0 }
129 6 if c.Anchor.Index < 0 {
130 0 return nil, fmt.Errorf("add comment: block index %d is negative", c.Anchor.Index)
131 0 }
132
133 // A block before the first heading has no enclosing headings, which is an
134 // ordinary anchor and not a missing one. pq.Array sends a nil slice as SQL
135 // NULL, and a NULL heading_path beside a non-NULL doc_id is exactly the
136 // half-written anchor ck_comment_anchor refuses — so every comment on a
137 // document preamble would be unwritable. An empty non-nil slice sends '{}'.
138 6 headings := c.Anchor.HeadingPath
139 6 if headings == nil {
140 1 headings = []string{}
141 1 }
142
143 6 const q = `
144 6 INSERT INTO comment (proposal_id, doc_id, doc_path, heading_path, block_index,
145 6 block_hash, side, body, author, author_kind, agent_session, created)
146 6 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
147 6 RETURNING id, created`
148 6 out := *c
149 6 out.ParentID = 0
150 6 out.Resolved = nil
151 6 err := s.q.QueryRowContext(ctx, q,
152 6 c.ProposalID, c.Anchor.DocID, c.DocPath, pq.Array(headings),
153 6 c.Anchor.Index, c.Anchor.BlockHash, string(c.Anchor.Side), c.Body,
154 6 c.Author, string(c.Kind), nullable(c.Session), time.Now().UTC(),
155 6 ).Scan(&out.ID, &out.Created)
156 6 if err != nil {
157 0 return nil, fmt.Errorf("add comment: %w", err)
158 0 }
159 6 return &out, nil
160 }
161
162 // ReplyComment appends a reply to an existing thread. parentID must name a
163 // thread root; replying to a reply is refused rather than flattened, so that a
164 // thread is always one root plus its replies and no caller has to walk a chain
165 // to render one.
166 //
167 // The parent lookup and the insert run in one transaction: without it, a thread
168 // deleted between the two would leave a reply pointing at nothing, or the FK
169 // would fail with an error that says nothing about which rule was broken.
170 7 func (s *Store) ReplyComment(ctx context.Context, parentID int, c *Comment) (*Comment, error) {
171 7 if err := validateAuthor(c); err != nil {
172 0 return nil, fmt.Errorf("reply to comment %d: %w", parentID, err)
173 0 }
174 7 if c.Anchor != nil {
175 1 return nil, fmt.Errorf("reply to comment %d: a reply inherits its thread's anchor and must not carry one", parentID)
176 1 }
177
178 6 var out *Comment
179 6 err := s.InTx(ctx, func(tx *Store) error {
180 6 var (
181 6 parentOf sql.NullInt64
182 6 proposal int
183 6 )
184 6 err := tx.q.QueryRowContext(ctx,
185 6 `SELECT parent_id, proposal_id FROM comment WHERE id = $1`, parentID).
186 6 Scan(&parentOf, &proposal)
187 6 if errors.Is(err, sql.ErrNoRows) {
188 1 return ErrNotFound
189 1 }
190 5 if err != nil {
191 0 return err
192 0 }
193 5 if parentOf.Valid {
194 1 return fmt.Errorf("comment %d is itself a reply; threads are one level deep", parentID)
195 1 }
196
197 4 const q = `
198 4 INSERT INTO comment (proposal_id, parent_id, body, author, author_kind, agent_session, created)
199 4 VALUES ($1, $2, $3, $4, $5, $6, $7)
200 4 RETURNING id, created`
201 4 reply := *c
202 4 reply.ProposalID = proposal
203 4 reply.ParentID = parentID
204 4 reply.Resolved = nil
205 4 if err := tx.q.QueryRowContext(ctx, q, proposal, parentID, c.Body,
206 4 c.Author, string(c.Kind), nullable(c.Session), time.Now().UTC(),
207 4 ).Scan(&reply.ID, &reply.Created); err != nil {
208 0 return err
209 0 }
210 4 out = &reply
211 4 return nil
212 })
213 6 if err != nil {
214 2 if errors.Is(err, ErrNotFound) {
215 1 return nil, ErrNotFound
216 1 }
217 1 return nil, fmt.Errorf("reply to comment %d: %w", parentID, err)
218 }
219 4 return out, nil
220 }
221
222 // validateAuthor enforces the provenance rule the ck_comment_provenance
223 // constraint also encodes, so a caller gets the rule by name rather than a
224 // constraint violation.
225 18 func validateAuthor(c *Comment) error {
226 18 if c.Body == "" {
227 1 return errors.New("body is required")
228 1 }
229 17 if c.Author == "" {
230 1 return errors.New("author is required")
231 1 }
232 16 switch c.Kind {
233 3 case AuthorAgent:
234 3 if c.Session == "" {
235 1 return errors.New("an agent comment needs a session: provenance is what makes one shared token auditable")
236 1 }
237 12 case AuthorHuman:
238 12 if c.Session != "" {
239 1 return errors.New("a human comment must not carry an agent session")
240 1 }
241 1 default:
242 1 return fmt.Errorf("author kind %q is not one of human|agent", c.Kind)
243 }
244 13 return nil
245 }
246
247 // ListComments returns every comment on a proposal — roots and replies together
248 // — oldest first, which is both thread order and the order the review page
249 // renders. Served by ix_comment_proposal.
250 4 func (s *Store) ListComments(ctx context.Context, proposalID int) ([]*Comment, error) {
251 4 q := commentSelect + ` WHERE proposal_id = $1 ORDER BY created, id`
252 4 rows, err := s.q.QueryContext(ctx, q, proposalID)
253 4 if err != nil {
254 0 return nil, fmt.Errorf("list comments of proposal %d: %w", proposalID, err)
255 0 }
256 4 defer rows.Close()
257 4
258 4 var out []*Comment
259 6 for rows.Next() {
260 6 c, err := scanComment(rows)
261 6 if err != nil {
262 0 return nil, fmt.Errorf("list comments of proposal %d: %w", proposalID, err)
263 0 }
264 6 out = append(out, c)
265 }
266 4 if err := rows.Err(); err != nil {
267 0 return nil, fmt.Errorf("list comments of proposal %d: %w", proposalID, err)
268 0 }
269 4 return out, nil
270 }
271
272 // ResolveComment marks a thread resolved, or reopens it when resolved is false.
273 // It returns ErrNotFound when no such thread exists.
274 //
275 // Only a root can be resolved — resolution is a property of the conversation,
276 // not of one message in it — and the `parent_id IS NULL` guard is what makes
277 // resolving a reply a miss rather than a silent write to the wrong row.
278 //
279 // This does not check who is asking. Agents may not resolve, and that rule lives
280 // in the service layer, which is where the caller's identity is known.
281 4 func (s *Store) ResolveComment(ctx context.Context, id int, resolved bool) error {
282 4 var at any
283 4 if resolved {
284 3 at = time.Now().UTC()
285 3 }
286 4 res, err := s.q.ExecContext(ctx,
287 4 `UPDATE comment SET resolved = $1 WHERE id = $2 AND parent_id IS NULL`, at, id)
288 4 if err != nil {
289 0 return fmt.Errorf("resolve comment %d: %w", id, err)
290 0 }
291 4 n, err := res.RowsAffected()
292 4 if err != nil {
293 0 return fmt.Errorf("resolve comment %d: %w", id, err)
294 0 }
295 4 if n == 0 {
296 1 return ErrNotFound
297 1 }
298 3 return nil
299 }
300
301 // HasUnresolvedComments reports whether a proposal has any open thread.
302 //
303 // This is the auto-merge gate's whole question. An unresolved comment suppresses
304 // a *policy* merge — the owner engaged with the proposal, so it must not slip
305 // through unattended — but never a manual approve, because a stale comment must
306 // not be able to wedge a proposal shut. Served by ix_comment_unresolved.
307 4 func (s *Store) HasUnresolvedComments(ctx context.Context, proposalID int) (bool, error) {
308 4 var exists bool
309 4 err := s.q.QueryRowContext(ctx, `
310 4 SELECT EXISTS (
311 4 SELECT 1 FROM comment
312 4 WHERE proposal_id = $1 AND parent_id IS NULL AND resolved IS NULL
313 4 )`, proposalID).Scan(&exists)
314 4 if err != nil {
315 0 return false, fmt.Errorf("unresolved comments of proposal %d: %w", proposalID, err)
316 0 }
317 4 return exists, nil
318 }