Commit eec3395d09

eec3395d0973fbb50ac28ce48a7bee2d08f018da

parent: 8a7642b427

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-11 15:12 UTC

store: milestones resolve through the repository's org

Ref #203
internal/store/milestones.go +139 −20
@@ -19,52 +19,158 @@ type Milestone struct {
1919 ClosedItems int // closed issues + merged/closed MRs attached
2020}
2121
22func (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.
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(
2450 "INSERT INTO milestones (repo_id, title, description, due_date) VALUES (?, ?, ?, ?)",
25 repoID, title, description, due)
51 repo.ID, title, description, due)
2652 if err != nil {
2753 if isUniqueErr(err) {
2854 return 0, fmt.Errorf("milestone %q already exists", title)
2955 }
3056 return 0, err
3157 }
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.
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()
33113}
34114
35const 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.
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 + `)
41126 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}
42133
43134func scanMilestone(row interface{ Scan(...any) error }) (Milestone, error) {
44135 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,
46137 &m.CreatedAt, &m.OpenItems, &m.ClosedItems)
47138 return m, err
48139}
49140
50func (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.
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...)...))
53146 if errors.Is(err, sql.ErrNoRows) {
54147 return m, ErrNotFound
55148 }
56149 return m, err
57150}
58151
59func (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.
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...)
62168 if state != "all" {
63169 q += " AND m.state = ?"
64 args = append(args, state)
170 qargs = append(qargs, state)
65171 }
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...)
68174 if err != nil {
69175 return nil, err
70176 }
@@ -80,6 +186,19 @@ func (s *Store) ListMilestones(repoID int64, state string) ([]Milestone, error)
80186 return out, rows.Err()
81187}
82188
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
83202func (s *Store) SetMilestoneState(id int64, state string) error {
84203 res, err := s.DB.Exec("UPDATE milestones SET state = ? WHERE id = ?", state, id)
85204 if err != nil {
internal/store/milestones_test.go added +117
@@ -0,0 +1,117 @@
1package store
2
3import (
4 "errors"
5 "testing"
6)
7
8func TestOrgMilestoneSpansRepos(t *testing.T) {
9 f := newAcme(t)
10 id, folded, err := f.s.CreateOrgMilestone(f.org, "v1", "first", "2027-01-01")
11 if err != nil || folded != 0 || id == 0 {
12 t.Fatalf("CreateOrgMilestone: %d, %d, %v", id, folded, err)
13 }
14 // Resolves from either repo, not from alice/app.
15 m, err := f.s.MilestoneByTitle(f.core, "v1")
16 if err != nil || m.OrgID != f.org || m.RepoID != 0 {
17 t.Fatalf("core resolves v1 = %+v, %v", m, err)
18 }
19 if _, err := f.s.MilestoneByTitle(f.app, "v1"); !errors.Is(err, ErrNotFound) {
20 t.Fatalf("app resolves v1: %v", err)
21 }
22 if err := f.s.SetIssueMilestone(f.coreIssue, id); err != nil {
23 t.Fatal(err)
24 }
25 if err := f.s.SetIssueMilestone(f.siteIssue, id); err != nil {
26 t.Fatal(err)
27 }
28 if err := f.s.SetIssueState(f.siteIssue, "closed"); err != nil {
29 t.Fatal(err)
30 }
31 ms, err := f.s.ListOrgMilestones(f.org, "open", f.orgRepos())
32 if err != nil || len(ms) != 1 || ms[0].OpenItems != 1 || ms[0].ClosedItems != 1 {
33 t.Fatalf("org list = %+v, %v", ms, err)
34 }
35 // Counts stop at what the caller can read.
36 ms, _ = f.s.ListOrgMilestones(f.org, "open", []int64{f.core.ID})
37 if ms[0].OpenItems != 1 || ms[0].ClosedItems != 0 {
38 t.Fatalf("org list over core = %+v", ms)
39 }
40 // A repo's list shows the org milestone first, then its own.
41 if _, err := f.s.CreateMilestone(f.core, "core-only", "", ""); err != nil {
42 t.Fatal(err)
43 }
44 ms, _ = f.s.ListMilestones(f.core, "open", f.orgRepos())
45 if len(ms) != 2 || ms[0].Title != "v1" || ms[0].OrgID != f.org || ms[1].Title != "core-only" || ms[1].RepoID != f.core.ID {
46 t.Fatalf("core list = %+v", ms)
47 }
48 if _, err := f.s.OrgMilestoneByTitle(f.org, "core-only"); !errors.Is(err, ErrNotFound) {
49 t.Fatalf("org resolves a repo milestone: %v", err)
50 }
51}
52
53func TestRepoMilestoneRefusedWhenOrgHoldsTitle(t *testing.T) {
54 f := newAcme(t)
55 if _, _, err := f.s.CreateOrgMilestone(f.org, "v1", "", ""); err != nil {
56 t.Fatal(err)
57 }
58 if _, err := f.s.CreateMilestone(f.core, "v1", "", ""); !errors.Is(err, ErrOrgScoped) {
59 t.Fatalf("CreateMilestone over org title: %v", err)
60 }
61 if _, err := f.s.CreateMilestone(f.app, "v1", "", ""); err != nil {
62 t.Fatalf("user repo unaffected: %v", err)
63 }
64 if _, _, err := f.s.CreateOrgMilestone(f.org, "v1", "", ""); err == nil {
65 t.Fatal("duplicate org milestone accepted")
66 }
67}
68
69func TestCreateOrgMilestonePromotes(t *testing.T) {
70 f := newAcme(t)
71 cid, err := f.s.CreateMilestone(f.core, "v1", "", "")
72 if err != nil {
73 t.Fatal(err)
74 }
75 sid, err := f.s.CreateMilestone(f.site, "v1", "", "")
76 if err != nil {
77 t.Fatal(err)
78 }
79 if err := f.s.SetIssueMilestone(f.coreIssue, cid); err != nil {
80 t.Fatal(err)
81 }
82 n, err := f.s.CreateMR(f.site.ID, f.alice, f.site.ID, "feat", "main", "t", "", "abc", "md", false)
83 if err != nil {
84 t.Fatal(err)
85 }
86 // CreateMR returns the per-repo MR number, not the merge_requests.id
87 // row that milestone_id references; resolve it the way SetMRMilestone
88 // callers must.
89 mr, err := f.s.MRByNumber(f.site.ID, n)
90 if err != nil {
91 t.Fatal(err)
92 }
93 if err := f.s.SetMRMilestone(mr.ID, sid); err != nil {
94 t.Fatal(err)
95 }
96 id, folded, err := f.s.CreateOrgMilestone(f.org, "v1", "org wide", "2027-06-01")
97 if err != nil || folded != 2 {
98 t.Fatalf("promote: folded %d, %v", folded, err)
99 }
100 var count int
101 f.s.DB.QueryRow("SELECT COUNT(*) FROM milestones WHERE title = 'v1'").Scan(&count)
102 if count != 1 {
103 t.Fatalf("milestones named v1: %d", count)
104 }
105 f.s.DB.QueryRow("SELECT COUNT(*) FROM issues WHERE milestone_id = ?", id).Scan(&count)
106 if count != 1 {
107 t.Fatalf("issues on org milestone: %d", count)
108 }
109 f.s.DB.QueryRow("SELECT COUNT(*) FROM merge_requests WHERE milestone_id = ?", id).Scan(&count)
110 if count != 1 {
111 t.Fatalf("mrs on org milestone: %d", count)
112 }
113 ms, _ := f.s.ListOrgMilestones(f.org, "open", f.orgRepos())
114 if len(ms) != 1 || ms[0].OpenItems != 2 || ms[0].Description != "org wide" || ms[0].DueDate != "2027-06-01" {
115 t.Fatalf("after promote: %+v", ms)
116 }
117}