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