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
}