internal/store/milestones.go

784b5dfad3f6ed718ada2a43910225c220abb310
gitbay/internal/store/milestones.go history · blame · raw

235 lines · 7643 bytes

  1package store
  2
  3import (
  4	"database/sql"
  5	"errors"
  6	"fmt"
  7)
  8
  9type Milestone struct {
 10	ID          int64
 11	RepoID      int64
 12	OrgID       int64 // set instead of RepoID for an org milestone
 13	Title       string
 14	Description string
 15	DueDate     string
 16	State       string // open | closed
 17	CreatedAt   string
 18	OpenItems   int // open issues + open MRs attached
 19	ClosedItems int // closed issues + merged/closed MRs attached
 20}
 21
 22// orgHoldsMilestone reports whether the repository's org has a milestone
 23// of that title; always false for a user-owned repository.
 24func orgHoldsMilestone(q interface {
 25	QueryRow(string, ...any) *sql.Row
 26}, repo Repo, title string) (bool, error) {
 27	if repo.OwnerKind != "org" {
 28		return false, nil
 29	}
 30	var n int
 31	err := q.QueryRow("SELECT COUNT(*) FROM milestones WHERE org_id = ? AND title = ?", repo.OwnerID, title).Scan(&n)
 32	return n > 0, err
 33}
 34
 35// CreateMilestone creates the repository's milestone. A title the org
 36// holds is refused with ErrOrgScoped.
 37func (s *Store) CreateMilestone(repo Repo, title, description, due string) (int64, error) {
 38	tx, err := s.DB.Begin()
 39	if err != nil {
 40		return 0, err
 41	}
 42	defer tx.Rollback()
 43	if held, err := orgHoldsMilestone(tx, repo, title); err != nil || held {
 44		if err != nil {
 45			return 0, err
 46		}
 47		return 0, ErrOrgScoped
 48	}
 49	res, err := tx.Exec(
 50		"INSERT INTO milestones (repo_id, title, description, due_date) VALUES (?, ?, ?, ?)",
 51		repo.ID, title, description, due)
 52	if err != nil {
 53		if isUniqueErr(err) {
 54			return 0, fmt.Errorf("milestone %q already exists", title)
 55		}
 56		return 0, err
 57	}
 58	id, err := res.LastInsertId()
 59	if err != nil {
 60		return 0, err
 61	}
 62	return id, tx.Commit()
 63}
 64
 65// CreateOrgMilestone creates the org's milestone. Repositories under the
 66// org that hold the title are folded in: their issues and merge requests
 67// move to the org's row and their rows go. folded is how many were.
 68func (s *Store) CreateOrgMilestone(orgID int64, title, description, due string) (int64, int, error) {
 69	tx, err := s.DB.Begin()
 70	if err != nil {
 71		return 0, 0, err
 72	}
 73	defer tx.Rollback()
 74	res, err := tx.Exec(
 75		"INSERT INTO milestones (org_id, title, description, due_date) VALUES (?, ?, ?, ?)",
 76		orgID, title, description, due)
 77	if err != nil {
 78		if isUniqueErr(err) {
 79			return 0, 0, fmt.Errorf("milestone %q already exists", title)
 80		}
 81		return 0, 0, err
 82	}
 83	id, err := res.LastInsertId()
 84	if err != nil {
 85		return 0, 0, err
 86	}
 87	rows, err := tx.Query(`SELECT m.id FROM milestones m JOIN repos r ON r.id = m.repo_id
 88		WHERE r.owner_kind = 'org' AND r.owner_id = ? AND m.title = ?`, orgID, title)
 89	if err != nil {
 90		return 0, 0, err
 91	}
 92	var repoRows []int64
 93	for rows.Next() {
 94		var rid int64
 95		if err := rows.Scan(&rid); err != nil {
 96			rows.Close()
 97			return 0, 0, err
 98		}
 99		repoRows = append(repoRows, rid)
100	}
101	rows.Close()
102	for _, rid := range repoRows {
103		for _, table := range []string{"issues", "merge_requests"} {
104			if _, err := tx.Exec("UPDATE "+table+" SET milestone_id = ? WHERE milestone_id = ?", id, rid); err != nil {
105				return 0, 0, err
106			}
107		}
108		if _, err := tx.Exec("DELETE FROM milestones WHERE id = ?", rid); err != nil {
109			return 0, 0, err
110		}
111	}
112	return id, len(repoRows), tx.Commit()
113}
114
115// milestoneQuery selects milestones with their progress, counting only
116// items in the readable repositories. Its args come first in any query
117// built on it.
118func milestoneQuery(readable []int64) (string, []any) {
119	in, args := inClause(readable)
120	q := `
121	SELECT m.id, COALESCE(m.repo_id, 0), COALESCE(m.org_id, 0), m.title, m.description, m.due_date, m.state, m.created_at,
122	       (SELECT COUNT(*) FROM issues i WHERE i.milestone_id = m.id AND i.state = 'open' AND i.repo_id IN ` + in + `)
123	     + (SELECT COUNT(*) FROM merge_requests r WHERE r.milestone_id = m.id AND r.state IN ('open','source_gone') AND r.repo_id IN ` + in + `),
124	       (SELECT COUNT(*) FROM issues i WHERE i.milestone_id = m.id AND i.state = 'closed' AND i.repo_id IN ` + in + `)
125	     + (SELECT COUNT(*) FROM merge_requests r WHERE r.milestone_id = m.id AND r.state IN ('merged','closed') AND r.repo_id IN ` + in + `)
126	FROM milestones m`
127	all := make([]any, 0, 4*len(args))
128	for i := 0; i < 4; i++ {
129		all = append(all, args...)
130	}
131	return q, all
132}
133
134func scanMilestone(row interface{ Scan(...any) error }) (Milestone, error) {
135	var m Milestone
136	err := row.Scan(&m.ID, &m.RepoID, &m.OrgID, &m.Title, &m.Description, &m.DueDate, &m.State,
137		&m.CreatedAt, &m.OpenItems, &m.ClosedItems)
138	return m, err
139}
140
141// milestoneByTitle resolves a title under where. The org's row comes
142// first when both scopes are in play; creation keeps that from happening.
143func (s *Store) milestoneByTitle(where string, args []any) (Milestone, error) {
144	q, qargs := milestoneQuery(nil)
145	m, err := scanMilestone(s.DB.QueryRow(q+" WHERE "+where+" ORDER BY m.org_id IS NULL LIMIT 1", append(qargs, args...)...))
146	if errors.Is(err, sql.ErrNoRows) {
147		return m, ErrNotFound
148	}
149	return m, err
150}
151
152// MilestoneByTitle resolves a title the way attaching does: the org's
153// milestone when the org has it, else the repository's. Progress counts
154// are not populated here; list for those.
155func (s *Store) MilestoneByTitle(repo Repo, title string) (Milestone, error) {
156	where, args := scopeClause("m", repo)
157	return s.milestoneByTitle(where+" AND m.title = ?", append(args, title))
158}
159
160func (s *Store) OrgMilestoneByTitle(orgID int64, title string) (Milestone, error) {
161	return s.milestoneByTitle("m.org_id = ? AND m.title = ?", []any{orgID, title})
162}
163
164func (s *Store) listMilestones(where string, args []any, state string, readable []int64) ([]Milestone, error) {
165	q, qargs := milestoneQuery(readable)
166	q += " WHERE " + where
167	qargs = append(qargs, args...)
168	if state != "all" {
169		q += " AND m.state = ?"
170		qargs = append(qargs, state)
171	}
172	q += " ORDER BY m.org_id IS NULL, m.due_date = '', m.due_date, m.title"
173	rows, err := s.DB.Query(q, qargs...)
174	if err != nil {
175		return nil, err
176	}
177	defer rows.Close()
178	var out []Milestone
179	for rows.Next() {
180		m, err := scanMilestone(rows)
181		if err != nil {
182			return nil, err
183		}
184		out = append(out, m)
185	}
186	return out, rows.Err()
187}
188
189// ListMilestones lists the milestones a repository sees, the org's first,
190// with progress counted over the readable repositories.
191func (s *Store) ListMilestones(repo Repo, state string, readable []int64) ([]Milestone, error) {
192	where, args := scopeClause("m", repo)
193	return s.listMilestones(where, args, state, readable)
194}
195
196// ListOrgMilestones lists an org's milestones with progress across the
197// readable repositories under it.
198func (s *Store) ListOrgMilestones(orgID int64, state string, readable []int64) ([]Milestone, error) {
199	return s.listMilestones("m.org_id = ?", []any{orgID}, state, readable)
200}
201
202func (s *Store) SetMilestoneState(id int64, state string) error {
203	res, err := s.DB.Exec("UPDATE milestones SET state = ? WHERE id = ?", state, id)
204	if err != nil {
205		return err
206	}
207	if n, _ := res.RowsAffected(); n == 0 {
208		return ErrNotFound
209	}
210	return nil
211}
212
213// SetIssueMilestone attaches (or with milestoneID 0 clears) a milestone.
214func (s *Store) SetIssueMilestone(issueID, milestoneID int64) error {
215	return s.setItemMilestone("issues", issueID, milestoneID)
216}
217
218func (s *Store) SetMRMilestone(mrID, milestoneID int64) error {
219	return s.setItemMilestone("merge_requests", mrID, milestoneID)
220}
221
222func (s *Store) setItemMilestone(table string, id, milestoneID int64) error {
223	var v any
224	if milestoneID != 0 {
225		v = milestoneID
226	}
227	res, err := s.DB.Exec("UPDATE "+table+" SET milestone_id = ? WHERE id = ?", v, id)
228	if err != nil {
229		return err
230	}
231	if n, _ := res.RowsAffected(); n == 0 {
232		return ErrNotFound
233	}
234	return nil
235}