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