krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

main: internal/store/issues.go · raw

  1package store
  2
  3import (
  4	"database/sql"
  5	"errors"
  6	"fmt"
  7)
  8
  9type Issue struct {
 10	ID        int64
 11	RepoID    int64
 12	Number    int64
 13	Author    string
 14	Title     string
 15	Body      string
 16	State     string // open | closed
 17	CreatedAt string
 18	UpdatedAt string
 19	Labels    []string
 20	Assignees []string
 21}
 22
 23type IssueComment struct {
 24	Author    string
 25	Body      string
 26	CreatedAt string
 27}
 28
 29// CreateIssue allocates the per-repo number from the repo counter inside the
 30// same transaction as the insert — MAX(number)+1 races.
 31func (s *Store) CreateIssue(repoID, authorID int64, title, body string) (int64, error) {
 32	tx, err := s.DB.Begin()
 33	if err != nil {
 34		return 0, err
 35	}
 36	defer tx.Rollback()
 37	if _, err := tx.Exec("UPDATE repos SET issue_counter = issue_counter + 1 WHERE id = ?", repoID); err != nil {
 38		return 0, err
 39	}
 40	var n int64
 41	if err := tx.QueryRow("SELECT issue_counter FROM repos WHERE id = ?", repoID).Scan(&n); err != nil {
 42		return 0, err
 43	}
 44	if _, err := tx.Exec(
 45		"INSERT INTO issues (repo_id, number, author_id, title, body) VALUES (?, ?, ?, ?, ?)",
 46		repoID, n, authorID, title, body); err != nil {
 47		return 0, err
 48	}
 49	return n, tx.Commit()
 50}
 51
 52func (s *Store) IssueByNumber(repoID, number int64) (Issue, error) {
 53	var i Issue
 54	err := s.DB.QueryRow(`
 55		SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state, i.created_at, i.updated_at
 56		FROM issues i JOIN users u ON u.id = i.author_id
 57		WHERE i.repo_id = ? AND i.number = ?`, repoID, number).
 58		Scan(&i.ID, &i.RepoID, &i.Number, &i.Author, &i.Title, &i.Body, &i.State, &i.CreatedAt, &i.UpdatedAt)
 59	if errors.Is(err, sql.ErrNoRows) {
 60		return i, ErrNotFound
 61	}
 62	if err != nil {
 63		return i, err
 64	}
 65	if i.Labels, err = s.issueStrings(i.ID, `
 66		SELECT l.name FROM issue_labels il JOIN labels l ON l.id = il.label_id
 67		WHERE il.issue_id = ? ORDER BY l.name`); err != nil {
 68		return i, err
 69	}
 70	i.Assignees, err = s.issueStrings(i.ID, `
 71		SELECT u.username FROM issue_assignees ia JOIN users u ON u.id = ia.user_id
 72		WHERE ia.issue_id = ? ORDER BY u.username`)
 73	return i, err
 74}
 75
 76func (s *Store) issueStrings(issueID int64, query string) ([]string, error) {
 77	rows, err := s.DB.Query(query, issueID)
 78	if err != nil {
 79		return nil, err
 80	}
 81	defer rows.Close()
 82	var out []string
 83	for rows.Next() {
 84		var v string
 85		if err := rows.Scan(&v); err != nil {
 86			return nil, err
 87		}
 88		out = append(out, v)
 89	}
 90	return out, rows.Err()
 91}
 92
 93// ListIssues returns issues for a repo; state is "open", "closed", or "all".
 94func (s *Store) ListIssues(repoID int64, state string) ([]Issue, error) {
 95	q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state, i.created_at, i.updated_at
 96	      FROM issues i JOIN users u ON u.id = i.author_id WHERE i.repo_id = ?`
 97	args := []any{repoID}
 98	if state != "all" {
 99		q += " AND i.state = ?"
100		args = append(args, state)
101	}
102	q += " ORDER BY i.number DESC"
103	rows, err := s.DB.Query(q, args...)
104	if err != nil {
105		return nil, err
106	}
107	defer rows.Close()
108	var out []Issue
109	for rows.Next() {
110		var i Issue
111		if err := rows.Scan(&i.ID, &i.RepoID, &i.Number, &i.Author, &i.Title, &i.Body, &i.State, &i.CreatedAt, &i.UpdatedAt); err != nil {
112			return nil, err
113		}
114		out = append(out, i)
115	}
116	return out, rows.Err()
117}
118
119func (s *Store) SetIssueState(issueID int64, state string) error {
120	res, err := s.DB.Exec(
121		"UPDATE issues SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
122		state, issueID)
123	if err != nil {
124		return err
125	}
126	if n, _ := res.RowsAffected(); n == 0 {
127		return ErrNotFound
128	}
129	return nil
130}
131
132func (s *Store) AddIssueComment(issueID, authorID int64, body string) error {
133	tx, err := s.DB.Begin()
134	if err != nil {
135		return err
136	}
137	defer tx.Rollback()
138	if _, err := tx.Exec(
139		"INSERT INTO issue_comments (issue_id, author_id, body) VALUES (?, ?, ?)",
140		issueID, authorID, body); err != nil {
141		return err
142	}
143	if _, err := tx.Exec(
144		"UPDATE issues SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", issueID); err != nil {
145		return err
146	}
147	return tx.Commit()
148}
149
150func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) {
151	rows, err := s.DB.Query(`
152		SELECT u.username, c.body, c.created_at
153		FROM issue_comments c JOIN users u ON u.id = c.author_id
154		WHERE c.issue_id = ? ORDER BY c.id`, issueID)
155	if err != nil {
156		return nil, err
157	}
158	defer rows.Close()
159	var out []IssueComment
160	for rows.Next() {
161		var c IssueComment
162		if err := rows.Scan(&c.Author, &c.Body, &c.CreatedAt); err != nil {
163			return nil, err
164		}
165		out = append(out, c)
166	}
167	return out, rows.Err()
168}
169
170// SetIssueLabel attaches (add) or detaches a label, creating the repo label
171// on first use.
172func (s *Store) SetIssueLabel(repoID, issueID int64, name string, add bool) error {
173	tx, err := s.DB.Begin()
174	if err != nil {
175		return err
176	}
177	defer tx.Rollback()
178	if add {
179		if _, err := tx.Exec(
180			"INSERT INTO labels (repo_id, name) VALUES (?, ?) ON CONFLICT (repo_id, name) DO NOTHING",
181			repoID, name); err != nil {
182			return err
183		}
184		if _, err := tx.Exec(`
185			INSERT INTO issue_labels (issue_id, label_id)
186			SELECT ?, id FROM labels WHERE repo_id = ? AND name = ?
187			ON CONFLICT DO NOTHING`, issueID, repoID, name); err != nil {
188			return err
189		}
190	} else {
191		res, err := tx.Exec(`
192			DELETE FROM issue_labels WHERE issue_id = ? AND label_id IN
193			(SELECT id FROM labels WHERE repo_id = ? AND name = ?)`, issueID, repoID, name)
194		if err != nil {
195			return err
196		}
197		if n, _ := res.RowsAffected(); n == 0 {
198			return fmt.Errorf("label %q: %w", name, ErrNotFound)
199		}
200	}
201	return tx.Commit()
202}
203
204// SetIssueAssignee adds or removes an assignee by user id.
205func (s *Store) SetIssueAssignee(issueID, userID int64, add bool) error {
206	if add {
207		_, err := s.DB.Exec(
208			"INSERT INTO issue_assignees (issue_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
209			issueID, userID)
210		return err
211	}
212	res, err := s.DB.Exec(
213		"DELETE FROM issue_assignees WHERE issue_id = ? AND user_id = ?", issueID, userID)
214	if err != nil {
215		return err
216	}
217	if n, _ := res.RowsAffected(); n == 0 {
218		return ErrNotFound
219	}
220	return nil
221}
222
223// RecordEvent appends to the event log and enqueues a delivery for every
224// active webhook on the repo whose event filter matches.
225func (s *Store) RecordEvent(repoID, actorID int64, kind, dataJSON string) error {
226	if dataJSON == "" {
227		dataJSON = "{}"
228	}
229	tx, err := s.DB.Begin()
230	if err != nil {
231		return err
232	}
233	defer tx.Rollback()
234	res, err := tx.Exec(
235		"INSERT INTO events (repo_id, actor_id, kind, data_json) VALUES (?, ?, ?, ?)",
236		repoID, actorID, kind, dataJSON)
237	if err != nil {
238		return err
239	}
240	eventID, err := res.LastInsertId()
241	if err != nil {
242		return err
243	}
244	if _, err := tx.Exec(`
245		INSERT INTO webhook_deliveries (webhook_id, event_id)
246		SELECT id, ? FROM webhooks
247		WHERE repo_id = ? AND active = 1
248		  AND (events = '*' OR ',' || events || ',' LIKE '%,' || ? || ',%')`,
249		eventID, repoID, kind); err != nil {
250		return err
251	}
252	return tx.Commit()
253}