internal/store/milestones.go

v1.19.0
gitbay/internal/store/milestones.go history · blame · raw

237 lines · 7830 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	repoMilestones, err := scanIDs(rows)
 93	if err != nil {
 94		return 0, 0, err
 95	}
 96	for _, mid := range repoMilestones {
 97		if err := foldMilestoneRow(tx, id, mid); err != nil {
 98			return 0, 0, err
 99		}
100	}
101	return id, len(repoMilestones), tx.Commit()
102}
103
104// foldMilestoneRow moves a repository's milestone onto the org's row:
105// every issue and merge request attached to it gets the org row, then the
106// repository row goes.
107func foldMilestoneRow(tx *sql.Tx, orgRow, repoRow int64) error {
108	for _, table := range []string{"issues", "merge_requests"} {
109		if _, err := tx.Exec("UPDATE "+table+" SET milestone_id = ? WHERE milestone_id = ?", orgRow, repoRow); err != nil {
110			return err
111		}
112	}
113	_, err := tx.Exec("DELETE FROM milestones WHERE id = ?", repoRow)
114	return err
115}
116
117// milestoneQuery selects milestones with their progress, counting only
118// items in the readable repositories. Its args come first in any query
119// built on it.
120func milestoneQuery(readable []int64) (string, []any) {
121	in, args := inClause(readable)
122	q := `
123	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,
124	       (SELECT COUNT(*) FROM issues i WHERE i.milestone_id = m.id AND i.state = 'open' AND i.repo_id IN ` + in + `)
125	     + (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 + `),
126	       (SELECT COUNT(*) FROM issues i WHERE i.milestone_id = m.id AND i.state = 'closed' AND i.repo_id IN ` + in + `)
127	     + (SELECT COUNT(*) FROM merge_requests r WHERE r.milestone_id = m.id AND r.state IN ('merged','closed') AND r.repo_id IN ` + in + `)
128	FROM milestones m`
129	all := make([]any, 0, 4*len(args))
130	for i := 0; i < 4; i++ {
131		all = append(all, args...)
132	}
133	return q, all
134}
135
136func scanMilestone(row interface{ Scan(...any) error }) (Milestone, error) {
137	var m Milestone
138	err := row.Scan(&m.ID, &m.RepoID, &m.OrgID, &m.Title, &m.Description, &m.DueDate, &m.State,
139		&m.CreatedAt, &m.OpenItems, &m.ClosedItems)
140	return m, err
141}
142
143// milestoneByTitle resolves a title under where. The org's row comes
144// first when both scopes are in play; creation keeps that from happening.
145func (s *Store) milestoneByTitle(where string, args []any) (Milestone, error) {
146	q, qargs := milestoneQuery(nil)
147	m, err := scanMilestone(s.DB.QueryRow(q+" WHERE "+where+" ORDER BY m.org_id IS NULL LIMIT 1", append(qargs, args...)...))
148	if errors.Is(err, sql.ErrNoRows) {
149		return m, ErrNotFound
150	}
151	return m, err
152}
153
154// MilestoneByTitle resolves a title the way attaching does: the org's
155// milestone when the org has it, else the repository's. Progress counts
156// are not populated here; list for those.
157func (s *Store) MilestoneByTitle(repo Repo, title string) (Milestone, error) {
158	where, args := scopeClause("m", repo)
159	return s.milestoneByTitle(where+" AND m.title = ?", append(args, title))
160}
161
162func (s *Store) OrgMilestoneByTitle(orgID int64, title string) (Milestone, error) {
163	return s.milestoneByTitle("m.org_id = ? AND m.title = ?", []any{orgID, title})
164}
165
166func (s *Store) listMilestones(where string, args []any, state string, readable []int64) ([]Milestone, error) {
167	q, qargs := milestoneQuery(readable)
168	q += " WHERE " + where
169	qargs = append(qargs, args...)
170	if state != "all" {
171		q += " AND m.state = ?"
172		qargs = append(qargs, state)
173	}
174	q += " ORDER BY m.org_id IS NULL, m.due_date = '', m.due_date, m.title"
175	rows, err := s.DB.Query(q, qargs...)
176	if err != nil {
177		return nil, err
178	}
179	defer rows.Close()
180	var out []Milestone
181	for rows.Next() {
182		m, err := scanMilestone(rows)
183		if err != nil {
184			return nil, err
185		}
186		out = append(out, m)
187	}
188	return out, rows.Err()
189}
190
191// ListMilestones lists the milestones a repository sees, the org's first,
192// with progress counted over the readable repositories.
193func (s *Store) ListMilestones(repo Repo, state string, readable []int64) ([]Milestone, error) {
194	where, args := scopeClause("m", repo)
195	return s.listMilestones(where, args, state, readable)
196}
197
198// ListOrgMilestones lists an org's milestones with progress across the
199// readable repositories under it.
200func (s *Store) ListOrgMilestones(orgID int64, state string, readable []int64) ([]Milestone, error) {
201	return s.listMilestones("m.org_id = ?", []any{orgID}, state, readable)
202}
203
204func (s *Store) SetMilestoneState(id int64, state string) error {
205	res, err := s.DB.Exec("UPDATE milestones SET state = ? WHERE id = ?", state, id)
206	if err != nil {
207		return err
208	}
209	if n, _ := res.RowsAffected(); n == 0 {
210		return ErrNotFound
211	}
212	return nil
213}
214
215// SetIssueMilestone attaches (or with milestoneID 0 clears) a milestone.
216func (s *Store) SetIssueMilestone(issueID, milestoneID int64) error {
217	return s.setItemMilestone("issues", issueID, milestoneID)
218}
219
220func (s *Store) SetMRMilestone(mrID, milestoneID int64) error {
221	return s.setItemMilestone("merge_requests", mrID, milestoneID)
222}
223
224func (s *Store) setItemMilestone(table string, id, milestoneID int64) error {
225	var v any
226	if milestoneID != 0 {
227		v = milestoneID
228	}
229	res, err := s.DB.Exec("UPDATE "+table+" SET milestone_id = ? WHERE id = ?", v, id)
230	if err != nil {
231		return err
232	}
233	if n, _ := res.RowsAffected(); n == 0 {
234		return ErrNotFound
235	}
236	return nil
237}