internal/store/diffcomments.go
200 lines · 6570 bytes
12 symbols in this file
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}