internal/store/milestones.go
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}