| @@ -19,52 +19,158 @@ type Milestone struct { |
| 19 | 19 | ClosedItems int // closed issues + merged/closed MRs attached |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | | func (s *Store) CreateMilestone(repoID int64, title, description, due string) (int64, error) { |
| 23 | | res, err := s.DB.Exec( |
| 22 | // orgHoldsMilestone reports whether the repository's org has a milestone |
| 23 | // of that title; always false for a user-owned repository. |
| 24 | func 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. |
| 37 | func (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( |
| 24 | 50 | "INSERT INTO milestones (repo_id, title, description, due_date) VALUES (?, ?, ?, ?)", |
| 25 | | repoID, title, description, due) |
| 51 | repo.ID, title, description, due) |
| 26 | 52 | if err != nil { |
| 27 | 53 | if isUniqueErr(err) { |
| 28 | 54 | return 0, fmt.Errorf("milestone %q already exists", title) |
| 29 | 55 | } |
| 30 | 56 | return 0, err |
| 31 | 57 | } |
| 32 | | return res.LastInsertId() |
| 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. |
| 68 | func (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() |
| 33 | 113 | } |
| 34 | 114 | |
| 35 | | const milestoneSelect = ` |
| 36 | | SELECT m.id, m.repo_id, m.title, m.description, m.due_date, m.state, m.created_at, |
| 37 | | (SELECT COUNT(*) FROM issues i WHERE i.milestone_id = m.id AND i.state = 'open') |
| 38 | | + (SELECT COUNT(*) FROM merge_requests r WHERE r.milestone_id = m.id AND r.state IN ('open','source_gone')), |
| 39 | | (SELECT COUNT(*) FROM issues i WHERE i.milestone_id = m.id AND i.state = 'closed') |
| 40 | | + (SELECT COUNT(*) FROM merge_requests r WHERE r.milestone_id = m.id AND r.state IN ('merged','closed')) |
| 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. |
| 118 | func 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 + `) |
| 41 | 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 | } |
| 42 | 133 | |
| 43 | 134 | func scanMilestone(row interface{ Scan(...any) error }) (Milestone, error) { |
| 44 | 135 | var m Milestone |
| 45 | | err := row.Scan(&m.ID, &m.RepoID, &m.Title, &m.Description, &m.DueDate, &m.State, |
| 136 | err := row.Scan(&m.ID, &m.RepoID, &m.OrgID, &m.Title, &m.Description, &m.DueDate, &m.State, |
| 46 | 137 | &m.CreatedAt, &m.OpenItems, &m.ClosedItems) |
| 47 | 138 | return m, err |
| 48 | 139 | } |
| 49 | 140 | |
| 50 | | func (s *Store) MilestoneByTitle(repoID int64, title string) (Milestone, error) { |
| 51 | | m, err := scanMilestone(s.DB.QueryRow( |
| 52 | | milestoneSelect+" WHERE m.repo_id = ? AND m.title = ?", repoID, title)) |
| 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. |
| 143 | func (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...)...)) |
| 53 | 146 | if errors.Is(err, sql.ErrNoRows) { |
| 54 | 147 | return m, ErrNotFound |
| 55 | 148 | } |
| 56 | 149 | return m, err |
| 57 | 150 | } |
| 58 | 151 | |
| 59 | | func (s *Store) ListMilestones(repoID int64, state string) ([]Milestone, error) { |
| 60 | | q := milestoneSelect + " WHERE m.repo_id = ?" |
| 61 | | args := []any{repoID} |
| 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. |
| 155 | func (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 | |
| 160 | func (s *Store) OrgMilestoneByTitle(orgID int64, title string) (Milestone, error) { |
| 161 | return s.milestoneByTitle("m.org_id = ? AND m.title = ?", []any{orgID, title}) |
| 162 | } |
| 163 | |
| 164 | func (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...) |
| 62 | 168 | if state != "all" { |
| 63 | 169 | q += " AND m.state = ?" |
| 64 | | args = append(args, state) |
| 170 | qargs = append(qargs, state) |
| 65 | 171 | } |
| 66 | | q += " ORDER BY m.due_date = '', m.due_date, m.title" |
| 67 | | rows, err := s.DB.Query(q, args...) |
| 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...) |
| 68 | 174 | if err != nil { |
| 69 | 175 | return nil, err |
| 70 | 176 | } |
| @@ -80,6 +186,19 @@ func (s *Store) ListMilestones(repoID int64, state string) ([]Milestone, error) |
| 80 | 186 | return out, rows.Err() |
| 81 | 187 | } |
| 82 | 188 | |
| 189 | // ListMilestones lists the milestones a repository sees, the org's first, |
| 190 | // with progress counted over the readable repositories. |
| 191 | func (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. |
| 198 | func (s *Store) ListOrgMilestones(orgID int64, state string, readable []int64) ([]Milestone, error) { |
| 199 | return s.listMilestones("m.org_id = ?", []any{orgID}, state, readable) |
| 200 | } |
| 201 | |
| 83 | 202 | func (s *Store) SetMilestoneState(id int64, state string) error { |
| 84 | 203 | res, err := s.DB.Exec("UPDATE milestones SET state = ? WHERE id = ?", state, id) |
| 85 | 204 | if err != nil { |