internal/store/diffcomments.go

v1.42.0
gitbay/internal/store/diffcomments.go history · blame · raw

200 lines · 6570 bytes

  1package store
  2
  3import (
  4	"database/sql"
  5	"errors"
  6	"fmt"
  7)
  8
  9type DiffComment struct {
 10	ID         int64
 11	Author     string
 12	HeadSHA    string
 13	Path       string
 14	Side       string
 15	Line       int64
 16	StartLine  int64 // first line of a range ending at Line; 0 for Line alone
 17	Body       string
 18	ReplyTo    int64 // 0 for thread roots
 19	ResolvedBy string
 20	// Pending marks a comment in a review its author has not submitted.
 21	// Only they can see it, and `mr review` publishes it.
 22	Pending   bool
 23	CreatedAt string
 24}
 25
 26// AddDiffComment creates a thread root (replyTo 0) or a reply. Replies
 27// inherit the root's anchor and must belong to the same MR. startLine is
 28// the first line of a range ending at line, or 0.
 29func (s *Store) AddDiffComment(mrID, authorID int64, headSHA, path, side string, line, startLine int64, body string, replyTo int64, pending bool) (int64, error) {
 30	if replyTo != 0 {
 31		var rootMR int64
 32		var rootReply sql.NullInt64
 33		err := s.DB.QueryRow(
 34			"SELECT mr_id, reply_to FROM mr_diff_comments WHERE id = ?", replyTo).Scan(&rootMR, &rootReply)
 35		if errors.Is(err, sql.ErrNoRows) {
 36			return 0, fmt.Errorf("no thread %d: %w", replyTo, ErrNotFound)
 37		}
 38		if err != nil {
 39			return 0, err
 40		}
 41		if rootMR != mrID {
 42			return 0, fmt.Errorf("thread %d belongs to a different merge request", replyTo)
 43		}
 44		if rootReply.Valid {
 45			return 0, fmt.Errorf("reply to the thread root %d, not to a reply", rootReply.Int64)
 46		}
 47		err = s.DB.QueryRow(
 48			"SELECT head_sha, path, side, line, start_line FROM mr_diff_comments WHERE id = ?", replyTo).
 49			Scan(&headSHA, &path, &side, &line, &startLine)
 50		if err != nil {
 51			return 0, err
 52		}
 53	}
 54	var reply any
 55	if replyTo != 0 {
 56		reply = replyTo
 57	}
 58	res, err := s.DB.Exec(`
 59		INSERT INTO mr_diff_comments (mr_id, author_id, head_sha, path, side, line, start_line, body, reply_to, pending)
 60		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
 61		mrID, authorID, headSHA, path, side, line, startLine, body, reply, pending)
 62	if err != nil {
 63		return 0, err
 64	}
 65	return res.LastInsertId()
 66}
 67
 68// ListDiffComments returns the diff comments on an MR that viewer may
 69// see: everything published, plus their own pending ones. viewer 0 is an
 70// anonymous reader, who sees only what is published.
 71func (s *Store) ListDiffComments(mrID, viewer int64) ([]DiffComment, error) {
 72	rows, err := s.DB.Query(`
 73		SELECT `+diffCommentCols+`
 74		FROM mr_diff_comments c
 75		JOIN users u ON u.id = c.author_id
 76		LEFT JOIN users r ON r.id = c.resolved_by
 77		WHERE c.mr_id = ?1 AND (c.pending = 0 OR c.author_id = ?2)
 78		ORDER BY c.id`, mrID, viewer)
 79	if err != nil {
 80		return nil, err
 81	}
 82	defer rows.Close()
 83	var out []DiffComment
 84	for rows.Next() {
 85		c, err := scanDiffComment(rows)
 86		if err != nil {
 87			return nil, err
 88		}
 89		out = append(out, c)
 90	}
 91	return out, rows.Err()
 92}
 93
 94const diffCommentCols = `c.id, u.username, c.head_sha, c.path, c.side, c.line, c.start_line, c.body,
 95		       COALESCE(c.reply_to, 0), COALESCE(r.username, ''), c.pending, c.created_at`
 96
 97func scanDiffComment(row interface{ Scan(...any) error }) (DiffComment, error) {
 98	var c DiffComment
 99	err := row.Scan(&c.ID, &c.Author, &c.HeadSHA, &c.Path, &c.Side, &c.Line, &c.StartLine, &c.Body,
100		&c.ReplyTo, &c.ResolvedBy, &c.Pending, &c.CreatedAt)
101	return c, err
102}
103
104// DiffCommentByID returns one comment on an MR, published or pending.
105func (s *Store) DiffCommentByID(mrID, id int64) (DiffComment, error) {
106	c, err := scanDiffComment(s.DB.QueryRow(`
107		SELECT `+diffCommentCols+`
108		FROM mr_diff_comments c
109		JOIN users u ON u.id = c.author_id
110		LEFT JOIN users r ON r.id = c.resolved_by
111		WHERE c.mr_id = ? AND c.id = ?`, mrID, id))
112	if errors.Is(err, sql.ErrNoRows) {
113		return c, ErrNotFound
114	}
115	return c, err
116}
117
118// SetThreadResolved resolves or unresolves a thread root.
119func (s *Store) SetThreadResolved(mrID, rootID, byUser int64, resolved bool) error {
120	var q string
121	var args []any
122	if resolved {
123		q = `UPDATE mr_diff_comments SET resolved_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), resolved_by = ?
124		     WHERE id = ? AND mr_id = ? AND reply_to IS NULL`
125		args = []any{byUser, rootID, mrID}
126	} else {
127		q = `UPDATE mr_diff_comments SET resolved_at = NULL, resolved_by = NULL
128		     WHERE id = ? AND mr_id = ? AND reply_to IS NULL`
129		args = []any{rootID, mrID}
130	}
131	res, err := s.DB.Exec(q, args...)
132	if err != nil {
133		return err
134	}
135	if n, _ := res.RowsAffected(); n == 0 {
136		return ErrNotFound
137	}
138	return nil
139}
140
141// DiffCommentAuthor returns the author id of one comment.
142func (s *Store) DiffCommentAuthor(mrID, id int64) (int64, error) {
143	var author int64
144	err := s.DB.QueryRow(
145		"SELECT author_id FROM mr_diff_comments WHERE id = ? AND mr_id = ?", id, mrID).Scan(&author)
146	if errors.Is(err, sql.ErrNoRows) {
147		return 0, ErrNotFound
148	}
149	return author, err
150}
151
152// UnresolvedThreadCount counts unresolved thread roots on an MR.
153//
154// Pending roots are excluded: an unsubmitted comment is one reviewer's
155// note to themselves, and blocking a merge on it would let anyone stall
156// a merge request with a thread nobody else can see or resolve.
157func (s *Store) UnresolvedThreadCount(mrID int64) (int, error) {
158	var n int
159	err := s.DB.QueryRow(`
160		SELECT COUNT(*) FROM mr_diff_comments
161		WHERE mr_id = ? AND reply_to IS NULL AND resolved_at IS NULL AND pending = 0`,
162		mrID).Scan(&n)
163	return n, err
164}
165
166// PublishPendingComments makes an author's pending comments on an MR
167// visible, and reports how many. This is what `mr review` does with the
168// batch the reviewer composed.
169func (s *Store) PublishPendingComments(mrID, authorID int64) (int64, error) {
170	res, err := s.DB.Exec(
171		"UPDATE mr_diff_comments SET pending = 0 WHERE mr_id = ? AND author_id = ? AND pending = 1",
172		mrID, authorID)
173	if err != nil {
174		return 0, err
175	}
176	return res.RowsAffected()
177}
178
179// DiscardPendingComments deletes an author's unsubmitted comments. Only
180// pending rows: a published comment is part of the conversation and is
181// not something its author can quietly take back.
182func (s *Store) DiscardPendingComments(mrID, authorID int64) (int64, error) {
183	res, err := s.DB.Exec(
184		"DELETE FROM mr_diff_comments WHERE mr_id = ? AND author_id = ? AND pending = 1",
185		mrID, authorID)
186	if err != nil {
187		return 0, err
188	}
189	return res.RowsAffected()
190}
191
192// CountPendingComments is how many unsubmitted comments an author holds
193// on an MR, for the reminder that they have a review in progress.
194func (s *Store) CountPendingComments(mrID, authorID int64) int {
195	var n int
196	s.DB.QueryRow(
197		"SELECT COUNT(*) FROM mr_diff_comments WHERE mr_id = ? AND author_id = ? AND pending = 1",
198		mrID, authorID).Scan(&n)
199	return n
200}