internal/store/mrs.go

da232bfde4952959944a074ccec455f38eab3b66
gitbay/internal/store/mrs.go history · blame · raw

633 lines · 20690 bytes

  1package store
  2
  3import (
  4	"database/sql"
  5	"errors"
  6	"strings"
  7)
  8
  9type MR struct {
 10	ID           int64
 11	RepoID       int64
 12	Number       int64
 13	Author       string
 14	SourceRepoID int64  // 0 when the source repo is gone
 15	SourcePath   string // owner/name of source repo, "" when gone
 16	SourceRef    string
 17	TargetRef    string
 18	Title        string
 19	Body         string
 20	BodyFormat   string // md | org
 21	State        string // open | merged | closed | source_gone
 22	// Draft marks an open merge request that is not asking to be merged
 23	// yet. Not a state: see migration 0037.
 24	Draft      bool
 25	Milestone  string
 26	HeadSHA    string
 27	MergedBase string // target tip at merge time; base for historical diffs
 28	MergedAt   string // "" unless merged
 29	MergedBy   string // "" when unknown (imports) or the account is gone
 30	ClosedAt   string // "" unless closed without merging
 31	ClosedBy   string
 32	// SupersededBy is the number, within this repository, of the merge
 33	// request this one was closed in favour of. 0 means none.
 34	SupersededBy int64
 35	CreatedAt    string
 36	UpdatedAt    string
 37	Labels       []string
 38	// ReviewRequests is who has been asked, directly, for a review — the
 39	// mr review request counterpart of Issue.Assignees.
 40	ReviewRequests []string
 41}
 42
 43type MRReview struct {
 44	Reviewer  string
 45	Verdict   string
 46	HeadSHA   string
 47	Stale     bool
 48	CreatedAt string
 49}
 50
 51func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, targetRef, title, body, headSHA, format string, draft bool) (int64, error) {
 52	tx, err := s.DB.Begin()
 53	if err != nil {
 54		return 0, err
 55	}
 56	defer tx.Rollback()
 57	if _, err := tx.Exec("UPDATE repos SET mr_counter = mr_counter + 1 WHERE id = ?", repoID); err != nil {
 58		return 0, err
 59	}
 60	var n int64
 61	if err := tx.QueryRow("SELECT mr_counter FROM repos WHERE id = ?", repoID).Scan(&n); err != nil {
 62		return 0, err
 63	}
 64	if _, err := tx.Exec(`
 65		INSERT INTO merge_requests (repo_id, number, author_id, source_repo_id, source_ref, target_ref, title, body, head_sha, body_format, draft)
 66		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
 67		repoID, n, authorID, sourceRepoID, sourceRef, targetRef, title, body, headSHA, format, draft); err != nil {
 68		return 0, err
 69	}
 70	if headSHA != "" {
 71		var mrID int64
 72		if err := tx.QueryRow("SELECT id FROM merge_requests WHERE repo_id = ? AND number = ?",
 73			repoID, n).Scan(&mrID); err != nil {
 74			return 0, err
 75		}
 76		if _, err := tx.Exec("INSERT INTO mr_heads (mr_id, sha) VALUES (?, ?)", mrID, headSHA); err != nil {
 77			return 0, err
 78		}
 79	}
 80	return n, tx.Commit()
 81}
 82
 83// SetMRDraft marks an open merge request as a draft, or takes the mark
 84// off. Merging is refused while it is set.
 85func (s *Store) SetMRDraft(mrID int64, draft bool) error {
 86	_, err := s.DB.Exec(
 87		"UPDATE merge_requests SET draft = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
 88		draft, mrID)
 89	return err
 90}
 91
 92const mrSelect = `
 93	SELECT m.id, m.repo_id, m.number, u.username,
 94	       COALESCE(m.source_repo_id, 0),
 95	       COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''),
 96	       m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft,
 97	       COALESCE(ms.title, ''), m.head_sha,
 98	       m.merged_base, m.merged_at, COALESCE(mu.username, ''),
 99	       m.closed_at, COALESCE(cu.username, ''), COALESCE(m.superseded_by, 0), m.created_at, m.updated_at
100	FROM merge_requests m
101	JOIN users u ON u.id = m.author_id
102	LEFT JOIN users mu ON mu.id = m.merged_by
103	LEFT JOIN users cu ON cu.id = m.closed_by
104	LEFT JOIN repos sr ON sr.id = m.source_repo_id
105	LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id
106	LEFT JOIN orgs so  ON sr.owner_kind = 'org'  AND so.id = sr.owner_id
107	LEFT JOIN milestones ms ON ms.id = m.milestone_id`
108
109func scanMR(row interface{ Scan(...any) error }) (MR, error) {
110	var m MR
111	err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
112		&m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Draft, &m.Milestone, &m.HeadSHA, &m.MergedBase,
113		&m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.SupersededBy, &m.CreatedAt, &m.UpdatedAt)
114	return m, err
115}
116
117func (s *Store) MRByNumber(repoID, number int64) (MR, error) {
118	m, err := scanMR(s.DB.QueryRow(mrSelect+" WHERE m.repo_id = ? AND m.number = ?", repoID, number))
119	if errors.Is(err, sql.ErrNoRows) {
120		return m, ErrNotFound
121	}
122	if err != nil {
123		return m, err
124	}
125	if m.Labels, err = s.issueStrings(m.ID, `
126		SELECT l.name FROM mr_labels ml JOIN labels l ON l.id = ml.label_id
127		WHERE ml.mr_id = ? ORDER BY l.name`); err != nil {
128		return m, err
129	}
130	m.ReviewRequests, err = s.issueStrings(m.ID, `
131		SELECT u.username FROM mr_review_requests rr JOIN users u ON u.id = rr.user_id
132		WHERE rr.mr_id = ? ORDER BY u.username`)
133	return m, err
134}
135
136// ListMRLabels returns the label names attached to each merge request of
137// a repo, keyed by merge request id, its org's labels included. Used by
138// the web merge request listing; ListMRs itself stays label-free for the
139// CLI's lean list output.
140func (s *Store) ListMRLabels(repo Repo) (map[int64][]string, error) {
141	return s.listItemLabels(mrLabelJoin, repo)
142}
143
144// SetMRLabel attaches (add) or detaches a label by name, the issue rules
145// exactly: the org's row when the org has the name, else the
146// repository's, created on first use.
147func (s *Store) SetMRLabel(repo Repo, mrID int64, name string, add bool) error {
148	return s.setItemLabel(mrLabelJoin, repo, mrID, name, add)
149}
150
151// SetMRReviewRequest adds or removes a review request by user id — the
152// mr review request counterpart of SetIssueAssignee.
153func (s *Store) SetMRReviewRequest(mrID, userID int64, add bool) error {
154	if add {
155		_, err := s.DB.Exec(
156			"INSERT INTO mr_review_requests (mr_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
157			mrID, userID)
158		return err
159	}
160	res, err := s.DB.Exec(
161		"DELETE FROM mr_review_requests WHERE mr_id = ? AND user_id = ?", mrID, userID)
162	if err != nil {
163		return err
164	}
165	if n, _ := res.RowsAffected(); n == 0 {
166		return ErrNotFound
167	}
168	return nil
169}
170
171// MRReviewRequestIDs returns who has been asked for a review, by id — for
172// notifying them without a username round trip.
173func (s *Store) MRReviewRequestIDs(mrID int64) ([]int64, error) {
174	return s.idQuery("SELECT user_id FROM mr_review_requests WHERE mr_id = ?", mrID)
175}
176
177// ListMRs returns merge requests for a repo. limit 0 means everything;
178// before (an MR number) starts the page strictly below it, matching the
179// number-descending order.
180// MRFilter narrows a listing. Empty strings match anything; State "all"
181// too. Milestone "none" selects merge requests with no milestone.
182type MRFilter struct {
183	State     string
184	Label     string
185	Author    string
186	Milestone string
187	Search    string // full-text over title and body
188	Limit     int
189	Before    int64
190}
191
192func (s *Store) ListMRs(repoID int64, state string, limit int, before int64) ([]MR, error) {
193	return s.QueryMRs(repoID, MRFilter{State: state, Limit: limit, Before: before})
194}
195
196// QueryMRs lists a repository's merge requests, newest first, narrowed
197// by f.
198func (s *Store) QueryMRs(repoID int64, f MRFilter) ([]MR, error) {
199	q := mrSelect + " WHERE m.repo_id = ?"
200	args := []any{repoID}
201	if f.State != "" && f.State != "all" {
202		q += " AND m.state = ?"
203		args = append(args, f.State)
204	}
205	if f.Label != "" {
206		q += ` AND EXISTS (SELECT 1 FROM mr_labels ml JOIN labels l ON l.id = ml.label_id
207			WHERE ml.mr_id = m.id AND l.name = ?)`
208		args = append(args, f.Label)
209	}
210	if f.Author != "" {
211		q += " AND u.username = ?"
212		args = append(args, f.Author)
213	}
214	switch f.Milestone {
215	case "":
216	case "none":
217		q += " AND m.milestone_id IS NULL"
218	default:
219		q += " AND ms.title = ?"
220		args = append(args, f.Milestone)
221	}
222	if f.Search != "" {
223		q += " AND m.id IN (SELECT rowid FROM mr_fts WHERE mr_fts MATCH ?)"
224		args = append(args, FTSQuery(f.Search))
225	}
226	if f.Before > 0 {
227		q += " AND m.number < ?"
228		args = append(args, f.Before)
229	}
230	q += " ORDER BY m.number DESC"
231	if f.Limit > 0 {
232		q += " LIMIT ?"
233		args = append(args, f.Limit)
234	}
235	rows, err := s.DB.Query(q, args...)
236	if err != nil {
237		return nil, err
238	}
239	defer rows.Close()
240	var out []MR
241	for rows.Next() {
242		m, err := scanMR(rows)
243		if err != nil {
244			return nil, err
245		}
246		out = append(out, m)
247	}
248	return out, rows.Err()
249}
250
251// OpenMRsBySource returns open (and source_gone) MRs fed by the given source
252// repo branch — the cross-repo hook effect consults this.
253func (s *Store) OpenMRsBySource(sourceRepoID int64, sourceRef string) ([]MR, error) {
254	rows, err := s.DB.Query(
255		mrSelect+" WHERE m.source_repo_id = ? AND m.source_ref = ? AND m.state IN ('open','source_gone')",
256		sourceRepoID, sourceRef)
257	if err != nil {
258		return nil, err
259	}
260	defer rows.Close()
261	var out []MR
262	for rows.Next() {
263		m, err := scanMR(rows)
264		if err != nil {
265			return nil, err
266		}
267		out = append(out, m)
268	}
269	return out, rows.Err()
270}
271
272// MarkMerged records the merge along with the target tip it landed on, so
273// the MR's diff stays reconstructable after fast-forwards. actorID 0 and an
274// empty at leave the merger unknown and stamp the current time, which is
275// what an import that carries neither can say.
276func (s *Store) MarkMerged(mrID int64, baseSHA string, actorID int64, at string) error {
277	_, err := s.DB.Exec(
278		`UPDATE merge_requests SET state = 'merged', merged_base = ?,
279			merged_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')),
280			merged_by = NULLIF(?, 0),
281			updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
282		baseSHA, at, actorID, mrID)
283	return err
284}
285
286// MarkClosed is MarkMerged's counterpart for a merge request closed without
287// merging.
288func (s *Store) MarkClosed(mrID, actorID int64, at string) error {
289	_, err := s.DB.Exec(
290		`UPDATE merge_requests SET state = 'closed',
291			closed_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')),
292			closed_by = NULLIF(?, 0),
293			updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
294		at, actorID, mrID)
295	return err
296}
297
298// SetSupersededBy records which merge request, by number within the same
299// repository, this one was closed in favour of. n of 0 clears it.
300func (s *Store) SetSupersededBy(mrID, n int64) error {
301	var v any
302	if n != 0 {
303		v = n
304	}
305	_, err := s.DB.Exec("UPDATE merge_requests SET superseded_by = ? WHERE id = ?", v, mrID)
306	return err
307}
308
309// MRsSuperseding returns the merge requests in a repository whose
310// superseded_by names number, oldest first — the reverse of
311// MR.SupersededBy.
312func (s *Store) MRsSuperseding(repoID, number int64) ([]MR, error) {
313	rows, err := s.DB.Query(mrSelect+" WHERE m.repo_id = ? AND m.superseded_by = ? ORDER BY m.number ASC", repoID, number)
314	if err != nil {
315		return nil, err
316	}
317	defer rows.Close()
318	var out []MR
319	for rows.Next() {
320		m, err := scanMR(rows)
321		if err != nil {
322			return nil, err
323		}
324		out = append(out, m)
325	}
326	return out, rows.Err()
327}
328
329// SetMRState moves an MR between states that carry no resolution stamp.
330// Returning to open (a source branch that came back) clears one.
331func (s *Store) SetMRState(mrID int64, state string) error {
332	stamp := ""
333	if state == "open" || state == "source_gone" {
334		stamp = ", merged_at = '', merged_by = NULL, closed_at = '', closed_by = NULL"
335	}
336	res, err := s.DB.Exec(
337		"UPDATE merge_requests SET state = ?"+stamp+", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
338		state, mrID)
339	if err != nil {
340		return err
341	}
342	if n, _ := res.RowsAffected(); n == 0 {
343		return ErrNotFound
344	}
345	return nil
346}
347
348// UpdateMRHead moves a merge request onto a new head, stales the reviews
349// of the old one, and records the head in the history a range-diff reads.
350// baseSHA is the merge base at this moment; "" when the caller could not
351// work it out, which only costs the range-diff its precision.
352//
353// sameDiff says the new head proposes the change the old one did (a
354// rebase onto a moved target, or the same commits pushed again). Then the
355// fresh reviews of the old head are reviews of this diff and move to the
356// new head rather than going stale (#198). Reviews already stale stay so.
357func (s *Store) UpdateMRHead(mrID int64, headSHA, baseSHA string, sameDiff bool) error {
358	tx, err := s.DB.Begin()
359	if err != nil {
360		return err
361	}
362	defer tx.Rollback()
363	if sameDiff {
364		if _, err := tx.Exec(`
365			UPDATE mr_reviews SET head_sha = ? WHERE mr_id = ? AND stale = 0
366			AND head_sha = (SELECT head_sha FROM merge_requests WHERE id = ?)`,
367			headSHA, mrID, mrID); err != nil {
368			return err
369		}
370	}
371	if _, err := tx.Exec(
372		"UPDATE merge_requests SET head_sha = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
373		headSHA, mrID); err != nil {
374		return err
375	}
376	if _, err := tx.Exec(
377		"UPDATE mr_reviews SET stale = 1 WHERE mr_id = ? AND head_sha <> ?", mrID, headSHA); err != nil {
378		return err
379	}
380	// Same head twice is a push that changed nothing about this merge
381	// request; it should not add a revision to compare against.
382	var last string
383	tx.QueryRow("SELECT sha FROM mr_heads WHERE mr_id = ? ORDER BY id DESC LIMIT 1", mrID).Scan(&last)
384	if last != headSHA {
385		if _, err := tx.Exec(
386			"INSERT INTO mr_heads (mr_id, sha, base_sha) VALUES (?, ?, ?)", mrID, headSHA, baseSHA); err != nil {
387			return err
388		}
389	}
390	return tx.Commit()
391}
392
393// MRHead is one revision a merge request has had.
394type MRHead struct {
395	SHA       string
396	BaseSHA   string
397	CreatedAt string
398}
399
400// MRHeads returns a merge request's revisions, oldest first.
401func (s *Store) MRHeads(mrID int64) ([]MRHead, error) {
402	rows, err := s.DB.Query(
403		"SELECT sha, base_sha, created_at FROM mr_heads WHERE mr_id = ? ORDER BY id", mrID)
404	if err != nil {
405		return nil, err
406	}
407	defer rows.Close()
408	var out []MRHead
409	for rows.Next() {
410		var h MRHead
411		if err := rows.Scan(&h.SHA, &h.BaseSHA, &h.CreatedAt); err != nil {
412			return nil, err
413		}
414		out = append(out, h)
415	}
416	return out, rows.Err()
417}
418
419// SetMRTarget retargets a merge request and marks every existing review
420// stale, in one transaction. The base of the diff is derived from the
421// target on every read, so nothing else has to move; an approval,
422// though, was of the diff against the old branch.
423func (s *Store) SetMRTarget(mrID int64, targetRef string) error {
424	tx, err := s.DB.Begin()
425	if err != nil {
426		return err
427	}
428	defer tx.Rollback()
429	res, err := tx.Exec(
430		"UPDATE merge_requests SET target_ref = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
431		targetRef, mrID)
432	if err != nil {
433		return err
434	}
435	if n, _ := res.RowsAffected(); n == 0 {
436		return ErrNotFound
437	}
438	if _, err := tx.Exec("UPDATE mr_reviews SET stale = 1 WHERE mr_id = ?", mrID); err != nil {
439		return err
440	}
441	return tx.Commit()
442}
443
444// MarkSourceGoneForRepo flags every open MR sourced from the repo; called
445// when a fork is deleted. Head refs in the target repos are retained.
446func (s *Store) MarkSourceGoneForRepo(sourceRepoID int64) error {
447	_, err := s.DB.Exec(
448		"UPDATE merge_requests SET state = 'source_gone', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE source_repo_id = ? AND state = 'open'",
449		sourceRepoID)
450	return err
451}
452
453func (s *Store) AddMRComment(mrID, authorID int64, body, format string) error {
454	_, err := s.DB.Exec(
455		"INSERT INTO mr_comments (mr_id, author_id, body, body_format) VALUES (?, ?, ?, ?)",
456		mrID, authorID, body, format)
457	return err
458}
459
460// UpdateMRText edits title, body, and/or markup format; nil leaves a field
461// unchanged.
462func (s *Store) UpdateMRText(mrID int64, title, body, format *string) error {
463	set, args := []string{}, []any{}
464	if title != nil {
465		set, args = append(set, "title = ?"), append(args, *title)
466	}
467	if body != nil {
468		set, args = append(set, "body = ?"), append(args, *body)
469	}
470	if format != nil {
471		set, args = append(set, "body_format = ?"), append(args, *format)
472	}
473	if len(set) == 0 {
474		return nil
475	}
476	set = append(set, "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')")
477	args = append(args, mrID)
478	res, err := s.DB.Exec("UPDATE merge_requests SET "+strings.Join(set, ", ")+" WHERE id = ?", args...)
479	if err != nil {
480		return err
481	}
482	if n, _ := res.RowsAffected(); n == 0 {
483		return ErrNotFound
484	}
485	return nil
486}
487
488// AddMRSystemComment is the informational counterpart of AddMRComment.
489func (s *Store) AddMRSystemComment(mrID, actorID int64, body string) error {
490	_, err := s.DB.Exec(
491		"INSERT INTO mr_comments (mr_id, author_id, body, kind) VALUES (?, ?, ?, 'system')",
492		mrID, actorID, body)
493	return err
494}
495
496func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
497	rows, err := s.DB.Query(`
498		SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END,
499		       c.body, c.body_format, c.created_at, c.kind
500		FROM mr_comments c JOIN users u ON u.id = c.author_id
501		WHERE c.mr_id = ? ORDER BY c.id`, mrID)
502	if err != nil {
503		return nil, err
504	}
505	defer rows.Close()
506	var out []IssueComment
507	for rows.Next() {
508		var c IssueComment
509		if err := rows.Scan(&c.Author, &c.Body, &c.BodyFormat, &c.CreatedAt, &c.Kind); err != nil {
510			return nil, err
511		}
512		out = append(out, c)
513	}
514	return out, rows.Err()
515}
516
517// MRCommentCounts totals, per MR, conversation comments plus diff-thread
518// roots — what the list page shows as one comment count. System comments,
519// diff-thread replies, and pending (unpublished) diff comments do not
520// count. The list handler asks for every row on a page in one call rather
521// than one query per MR.
522func (s *Store) MRCommentCounts(repoID int64, mrIDs []int64) (map[int64]int, error) {
523	out := map[int64]int{}
524	if len(mrIDs) == 0 {
525		return out, nil
526	}
527	ph := "?" + strings.Repeat(",?", len(mrIDs)-1)
528	args := make([]any, 0, len(mrIDs)+1)
529	args = append(args, repoID)
530	for _, id := range mrIDs {
531		args = append(args, id)
532	}
533	add := func(query string) error {
534		rows, err := s.DB.Query(query, args...)
535		if err != nil {
536			return err
537		}
538		defer rows.Close()
539		for rows.Next() {
540			var mrID int64
541			var n int
542			if err := rows.Scan(&mrID, &n); err != nil {
543				return err
544			}
545			out[mrID] += n
546		}
547		return rows.Err()
548	}
549	if err := add(`
550		SELECT c.mr_id, COUNT(*) FROM mr_comments c
551		JOIN merge_requests m ON m.id = c.mr_id
552		WHERE m.repo_id = ? AND c.kind <> 'system' AND c.mr_id IN (` + ph + `)
553		GROUP BY c.mr_id`); err != nil {
554		return nil, err
555	}
556	if err := add(`
557		SELECT c.mr_id, COUNT(*) FROM mr_diff_comments c
558		JOIN merge_requests m ON m.id = c.mr_id
559		WHERE m.repo_id = ? AND c.reply_to IS NULL AND c.pending = 0 AND c.mr_id IN (` + ph + `)
560		GROUP BY c.mr_id`); err != nil {
561		return nil, err
562	}
563	return out, nil
564}
565
566func (s *Store) AddMRReview(mrID, reviewerID int64, verdict, headSHA string) error {
567	_, err := s.DB.Exec(
568		"INSERT INTO mr_reviews (mr_id, reviewer_id, verdict, head_sha) VALUES (?, ?, ?, ?)",
569		mrID, reviewerID, verdict, headSHA)
570	return err
571}
572
573func (s *Store) ListMRReviews(mrID int64) ([]MRReview, error) {
574	rows, err := s.DB.Query(`
575		SELECT u.username, r.verdict, r.head_sha, r.stale, r.created_at
576		FROM mr_reviews r JOIN users u ON u.id = r.reviewer_id
577		WHERE r.mr_id = ? ORDER BY r.id`, mrID)
578	if err != nil {
579		return nil, err
580	}
581	defer rows.Close()
582	var out []MRReview
583	for rows.Next() {
584		var r MRReview
585		var stale int
586		if err := rows.Scan(&r.Reviewer, &r.Verdict, &r.HeadSHA, &stale, &r.CreatedAt); err != nil {
587			return nil, err
588		}
589		r.Stale = stale != 0
590		out = append(out, r)
591	}
592	return out, rows.Err()
593}
594
595// PrimaryVerifiedEmail returns the user's primary email if verified, else "".
596func (s *Store) PrimaryVerifiedEmail(userID int64) (string, error) {
597	var addr string
598	err := s.DB.QueryRow(
599		"SELECT address FROM emails WHERE user_id = ? AND is_primary = 1 AND verified_at IS NOT NULL",
600		userID).Scan(&addr)
601	if errors.Is(err, sql.ErrNoRows) {
602		return "", nil
603	}
604	return addr, err
605}
606
607// ActivityMailAddress returns where activity mail for an account goes:
608// its verified primary address, or "" when there is none or the account
609// turned activity mail off (#194).
610func (s *Store) ActivityMailAddress(userID int64) (string, error) {
611	on, err := s.MailEnabled(userID)
612	if err != nil || !on {
613		return "", err
614	}
615	return s.PrimaryVerifiedEmail(userID)
616}
617
618// PreferredVerifiedEmail returns the primary address if it is verified,
619// otherwise the account's other verified address that sorts first by
620// address; "" if none is verified. Unlike PrimaryVerifiedEmail, a verified
621// secondary counts: an account that verified one address but not its
622// primary still has somewhere to send a login link.
623func (s *Store) PreferredVerifiedEmail(userID int64) (string, error) {
624	var addr string
625	err := s.DB.QueryRow(
626		`SELECT address FROM emails WHERE user_id = ? AND verified_at IS NOT NULL
627		 ORDER BY is_primary DESC, address LIMIT 1`,
628		userID).Scan(&addr)
629	if errors.Is(err, sql.ErrNoRows) {
630		return "", nil
631	}
632	return addr, err
633}