~bigbes/sr-ht-spec

ref: 90eb06ec6f16e09cc7d1ab7558c9464c3764aca8 sr-ht-spec/db/comment.go -rw-r--r-- 10.5 KiB
90eb06ec — Eugene Blikh feat(cmd): agent tokens and host-side proposals get admin commands 13 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
package db

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"

	"github.com/lib/pq"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
)

// AuthorKind distinguishes the owner from an agent. It is stored rather than
// inferred from the author string, because "who may resolve a thread" turns on
// it: an agent marking its own critique resolved would defeat the auto-merge
// gate, and a rule that depends on parsing an identity string is a rule that
// stops holding the first time an agent is named after a person.
type AuthorKind string

const (
	AuthorHuman AuthorKind = "human"
	AuthorAgent AuthorKind = "agent"
)

// ParseAuthorKind validates an author kind read back from Postgres.
func ParseAuthorKind(s string) (AuthorKind, error) {
	switch AuthorKind(s) {
	case AuthorHuman, AuthorAgent:
		return AuthorKind(s), nil
	}
	return "", fmt.Errorf("comment: %q is not one of human|agent", s)
}

// Comment is one message in a review thread: either a thread root carrying an
// anchor, or a reply to one.
//
// Anchor is set on a root and nil on a reply. A reply inherits its root's anchor
// rather than copying it — two copies of one anchor is two things that can
// disagree about where a thread is attached.
//
// Nothing here records whether the anchor still fits. That is a property of the
// revision being looked at, not of the comment, and the branch moves under it as
// the agent revises; callers derive it with core.ResolveAnchor.
type Comment struct {
	ID         int
	ProposalID int
	ParentID   int // 0 for a thread root

	Anchor   *core.CommentAnchor
	DocPath  string // path as at comment time; empty on a reply
	Body     string
	Author   string
	Kind     AuthorKind
	Session  string // agent session; empty for a human
	Created  time.Time
	Resolved *time.Time
}

// Root reports whether the comment starts a thread rather than replying to one.
func (c *Comment) Root() bool { return c.ParentID == 0 }

const commentSelect = `
SELECT id, proposal_id, COALESCE(parent_id, 0), COALESCE(doc_id, ''),
       COALESCE(doc_path, ''), heading_path, COALESCE(block_index, 0),
       COALESCE(block_hash, ''), COALESCE(side, ''), body, author, author_kind,
       COALESCE(agent_session, ''), created, resolved
FROM comment`

func scanComment(sc rowScanner) (*Comment, error) {
	var (
		c           Comment
		docID       string
		headingPath pq.StringArray
		blockIndex  int
		blockHash   string
		side        string
		kind        string
		resolved    sql.NullTime
	)
	if err := sc.Scan(&c.ID, &c.ProposalID, &c.ParentID, &docID, &c.DocPath,
		&headingPath, &blockIndex, &blockHash, &side, &c.Body, &c.Author, &kind,
		&c.Session, &c.Created, &resolved); err != nil {
		return nil, err
	}
	parsedKind, err := ParseAuthorKind(kind)
	if err != nil {
		return nil, fmt.Errorf("comment %d: %w", c.ID, err)
	}
	c.Kind = parsedKind
	if docID != "" {
		parsedSide, err := core.ParseCommentSide(side)
		if err != nil {
			return nil, fmt.Errorf("comment %d: %w", c.ID, err)
		}
		c.Anchor = &core.CommentAnchor{
			DocID:       docID,
			HeadingPath: []string(headingPath),
			Index:       blockIndex,
			BlockHash:   blockHash,
			Side:        parsedSide,
		}
	}
	if resolved.Valid {
		t := resolved.Time
		c.Resolved = &t
	}
	return &c, nil
}

// AddComment inserts a thread root: a comment anchored to one block of one
// document in a proposal. c.Anchor, c.DocPath, c.Body, c.Author and c.Kind must
// be set. ID, Created, ParentID and Resolved are ignored on input — a root is
// always born unresolved.
func (s *Store) AddComment(ctx context.Context, c *Comment) (*Comment, error) {
	if c.Anchor == nil {
		return nil, fmt.Errorf("add comment: a thread root needs an anchor (use ReplyComment for a reply)")
	}
	if err := validateAuthor(c); err != nil {
		return nil, fmt.Errorf("add comment: %w", err)
	}
	if _, err := core.ParseCommentSide(string(c.Anchor.Side)); err != nil {
		return nil, fmt.Errorf("add comment: %w", err)
	}
	if c.Anchor.DocID == "" || c.DocPath == "" {
		return nil, fmt.Errorf("add comment: anchor needs both a document id and a path")
	}
	if c.Anchor.Index < 0 {
		return nil, fmt.Errorf("add comment: block index %d is negative", c.Anchor.Index)
	}

	// A block before the first heading has no enclosing headings, which is an
	// ordinary anchor and not a missing one. pq.Array sends a nil slice as SQL
	// NULL, and a NULL heading_path beside a non-NULL doc_id is exactly the
	// half-written anchor ck_comment_anchor refuses — so every comment on a
	// document preamble would be unwritable. An empty non-nil slice sends '{}'.
	headings := c.Anchor.HeadingPath
	if headings == nil {
		headings = []string{}
	}

	const q = `
INSERT INTO comment (proposal_id, doc_id, doc_path, heading_path, block_index,
                     block_hash, side, body, author, author_kind, agent_session, created)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, created`
	out := *c
	out.ParentID = 0
	out.Resolved = nil
	err := s.q.QueryRowContext(ctx, q,
		c.ProposalID, c.Anchor.DocID, c.DocPath, pq.Array(headings),
		c.Anchor.Index, c.Anchor.BlockHash, string(c.Anchor.Side), c.Body,
		c.Author, string(c.Kind), nullable(c.Session), time.Now().UTC(),
	).Scan(&out.ID, &out.Created)
	if err != nil {
		return nil, fmt.Errorf("add comment: %w", err)
	}
	return &out, nil
}

// ReplyComment appends a reply to an existing thread. parentID must name a
// thread root; replying to a reply is refused rather than flattened, so that a
// thread is always one root plus its replies and no caller has to walk a chain
// to render one.
//
// The parent lookup and the insert run in one transaction: without it, a thread
// deleted between the two would leave a reply pointing at nothing, or the FK
// would fail with an error that says nothing about which rule was broken.
func (s *Store) ReplyComment(ctx context.Context, parentID int, c *Comment) (*Comment, error) {
	if err := validateAuthor(c); err != nil {
		return nil, fmt.Errorf("reply to comment %d: %w", parentID, err)
	}
	if c.Anchor != nil {
		return nil, fmt.Errorf("reply to comment %d: a reply inherits its thread's anchor and must not carry one", parentID)
	}

	var out *Comment
	err := s.InTx(ctx, func(tx *Store) error {
		var (
			parentOf sql.NullInt64
			proposal int
		)
		err := tx.q.QueryRowContext(ctx,
			`SELECT parent_id, proposal_id FROM comment WHERE id = $1`, parentID).
			Scan(&parentOf, &proposal)
		if errors.Is(err, sql.ErrNoRows) {
			return ErrNotFound
		}
		if err != nil {
			return err
		}
		if parentOf.Valid {
			return fmt.Errorf("comment %d is itself a reply; threads are one level deep", parentID)
		}

		const q = `
INSERT INTO comment (proposal_id, parent_id, body, author, author_kind, agent_session, created)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created`
		reply := *c
		reply.ProposalID = proposal
		reply.ParentID = parentID
		reply.Resolved = nil
		if err := tx.q.QueryRowContext(ctx, q, proposal, parentID, c.Body,
			c.Author, string(c.Kind), nullable(c.Session), time.Now().UTC(),
		).Scan(&reply.ID, &reply.Created); err != nil {
			return err
		}
		out = &reply
		return nil
	})
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return nil, ErrNotFound
		}
		return nil, fmt.Errorf("reply to comment %d: %w", parentID, err)
	}
	return out, nil
}

// validateAuthor enforces the provenance rule the ck_comment_provenance
// constraint also encodes, so a caller gets the rule by name rather than a
// constraint violation.
func validateAuthor(c *Comment) error {
	if c.Body == "" {
		return errors.New("body is required")
	}
	if c.Author == "" {
		return errors.New("author is required")
	}
	switch c.Kind {
	case AuthorAgent:
		if c.Session == "" {
			return errors.New("an agent comment needs a session: provenance is what makes one shared token auditable")
		}
	case AuthorHuman:
		if c.Session != "" {
			return errors.New("a human comment must not carry an agent session")
		}
	default:
		return fmt.Errorf("author kind %q is not one of human|agent", c.Kind)
	}
	return nil
}

// ListComments returns every comment on a proposal — roots and replies together
// — oldest first, which is both thread order and the order the review page
// renders. Served by ix_comment_proposal.
func (s *Store) ListComments(ctx context.Context, proposalID int) ([]*Comment, error) {
	q := commentSelect + ` WHERE proposal_id = $1 ORDER BY created, id`
	rows, err := s.q.QueryContext(ctx, q, proposalID)
	if err != nil {
		return nil, fmt.Errorf("list comments of proposal %d: %w", proposalID, err)
	}
	defer rows.Close()

	var out []*Comment
	for rows.Next() {
		c, err := scanComment(rows)
		if err != nil {
			return nil, fmt.Errorf("list comments of proposal %d: %w", proposalID, err)
		}
		out = append(out, c)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("list comments of proposal %d: %w", proposalID, err)
	}
	return out, nil
}

// ResolveComment marks a thread resolved, or reopens it when resolved is false.
// It returns ErrNotFound when no such thread exists.
//
// Only a root can be resolved — resolution is a property of the conversation,
// not of one message in it — and the `parent_id IS NULL` guard is what makes
// resolving a reply a miss rather than a silent write to the wrong row.
//
// This does not check who is asking. Agents may not resolve, and that rule lives
// in the service layer, which is where the caller's identity is known.
func (s *Store) ResolveComment(ctx context.Context, id int, resolved bool) error {
	var at any
	if resolved {
		at = time.Now().UTC()
	}
	res, err := s.q.ExecContext(ctx,
		`UPDATE comment SET resolved = $1 WHERE id = $2 AND parent_id IS NULL`, at, id)
	if err != nil {
		return fmt.Errorf("resolve comment %d: %w", id, err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return fmt.Errorf("resolve comment %d: %w", id, err)
	}
	if n == 0 {
		return ErrNotFound
	}
	return nil
}

// HasUnresolvedComments reports whether a proposal has any open thread.
//
// This is the auto-merge gate's whole question. An unresolved comment suppresses
// a *policy* merge — the owner engaged with the proposal, so it must not slip
// through unattended — but never a manual approve, because a stale comment must
// not be able to wedge a proposal shut. Served by ix_comment_unresolved.
func (s *Store) HasUnresolvedComments(ctx context.Context, proposalID int) (bool, error) {
	var exists bool
	err := s.q.QueryRowContext(ctx, `
SELECT EXISTS (
  SELECT 1 FROM comment
  WHERE proposal_id = $1 AND parent_id IS NULL AND resolved IS NULL
)`, proposalID).Scan(&exists)
	if err != nil {
		return false, fmt.Errorf("unresolved comments of proposal %d: %w", proposalID, err)
	}
	return exists, nil
}