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 }