Commit 6a03ea8472

6a03ea8472741268d3ed72a5b48a6be38b185de1

parent: c08edd0c19

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-11 14:42 UTC

store: migration 0052 scopes labels and milestones to a repo or an org

Ref #203
internal/store/labels.go +4 −3
@@ -1,11 +1,12 @@
11package store
22
3// Label is one of a repository's issue labels with its colour, "" when
4// none was set (the web then derives one from the name), and how many
5// issues carry it.
3// Label is an issue label with its colour, "" when none was set (the web
4// then derives one from the name), and how many issues carry it. Org is
5// true for a label the repository sees through its org.
66type Label struct {
77 Name string `json:"name"`
88 Color string `json:"color,omitempty"`
9 Org bool `json:"org,omitempty"`
910 Issues int64 `json:"issues"`
1011}
1112
internal/store/migrations/0052_org_scope.down.sql added +34
@@ -0,0 +1,34 @@
1-- Back to per-repository rows. An org-scoped row has no repository to go
2-- to; the NOT NULL on repo_id refuses the copy, which fails the migration.
3PRAGMA foreign_keys = OFF;
4PRAGMA legacy_alter_table = ON;
5
6ALTER TABLE labels RENAME TO labels_old;
7CREATE TABLE labels (
8 id INTEGER PRIMARY KEY,
9 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
10 name TEXT NOT NULL,
11 color TEXT NOT NULL DEFAULT '',
12 UNIQUE (repo_id, name)
13);
14INSERT INTO labels (id, repo_id, name, color)
15 SELECT id, repo_id, name, color FROM labels_old;
16DROP TABLE labels_old;
17
18ALTER TABLE milestones RENAME TO milestones_old;
19CREATE TABLE milestones (
20 id INTEGER PRIMARY KEY,
21 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
22 title TEXT NOT NULL,
23 description TEXT NOT NULL DEFAULT '',
24 due_date TEXT NOT NULL DEFAULT '',
25 state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open','closed')),
26 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
27 UNIQUE (repo_id, title)
28);
29INSERT INTO milestones (id, repo_id, title, description, due_date, state, created_at)
30 SELECT id, repo_id, title, description, due_date, state, created_at FROM milestones_old;
31DROP TABLE milestones_old;
32
33PRAGMA legacy_alter_table = OFF;
34PRAGMA foreign_keys = ON;
internal/store/migrations/0052_org_scope.up.sql added +48
@@ -0,0 +1,48 @@
1-- Labels and milestones scoped to a repository or to an org (#203).
2-- Exactly one of repo_id and org_id is set. Uniqueness is per scope, as
3-- two partial indexes; the app refuses a repo name the org already holds.
4--
5-- Both tables have children (issue_labels, issues.milestone_id,
6-- merge_requests.milestone_id). Since SQLite 3.26 renaming a parent
7-- rewrites the children's foreign keys to follow it, which would bind them
8-- to the *_old tables. legacy_alter_table keeps the children naming labels
9-- and milestones, which the new tables then are. foreign_keys stays on:
10-- nothing references the *_old tables, so dropping them cascades nothing.
11PRAGMA foreign_keys = OFF;
12PRAGMA legacy_alter_table = ON;
13
14ALTER TABLE labels RENAME TO labels_old;
15CREATE TABLE labels (
16 id INTEGER PRIMARY KEY,
17 repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE,
18 org_id INTEGER REFERENCES orgs(id) ON DELETE CASCADE,
19 name TEXT NOT NULL,
20 color TEXT NOT NULL DEFAULT '',
21 CHECK ((repo_id IS NULL) <> (org_id IS NULL))
22);
23INSERT INTO labels (id, repo_id, name, color)
24 SELECT id, repo_id, name, color FROM labels_old;
25DROP TABLE labels_old;
26CREATE UNIQUE INDEX labels_repo_name ON labels(repo_id, name) WHERE repo_id IS NOT NULL;
27CREATE UNIQUE INDEX labels_org_name ON labels(org_id, name) WHERE org_id IS NOT NULL;
28
29ALTER TABLE milestones RENAME TO milestones_old;
30CREATE TABLE milestones (
31 id INTEGER PRIMARY KEY,
32 repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE,
33 org_id INTEGER REFERENCES orgs(id) ON DELETE CASCADE,
34 title TEXT NOT NULL,
35 description TEXT NOT NULL DEFAULT '',
36 due_date TEXT NOT NULL DEFAULT '',
37 state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open','closed')),
38 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
39 CHECK ((repo_id IS NULL) <> (org_id IS NULL))
40);
41INSERT INTO milestones (id, repo_id, title, description, due_date, state, created_at)
42 SELECT id, repo_id, title, description, due_date, state, created_at FROM milestones_old;
43DROP TABLE milestones_old;
44CREATE UNIQUE INDEX milestones_repo_title ON milestones(repo_id, title) WHERE repo_id IS NOT NULL;
45CREATE UNIQUE INDEX milestones_org_title ON milestones(org_id, title) WHERE org_id IS NOT NULL;
46
47PRAGMA legacy_alter_table = OFF;
48PRAGMA foreign_keys = ON;
internal/store/milestones.go +1
@@ -9,6 +9,7 @@ import (
99type Milestone struct {
1010 ID int64
1111 RepoID int64
12 OrgID int64 // set instead of RepoID for an org milestone
1213 Title string
1314 Description string
1415 DueDate string
internal/store/store.go +7
@@ -161,6 +161,13 @@ func (s *Store) migrateTo(target int) error {
161161 return err
162162 }
163163 step := func(sqlText string, newVersion int) error {
164 needsFKOff := strings.Contains(sqlText, "PRAGMA foreign_keys = OFF")
165 if needsFKOff {
166 if _, err := s.DB.Exec("PRAGMA foreign_keys = OFF"); err != nil {
167 return err
168 }
169 defer s.DB.Exec("PRAGMA foreign_keys = ON")
170 }
164171 tx, err := s.DB.Begin()
165172 if err != nil {
166173 return err
internal/store/store_test.go +84
@@ -144,3 +144,87 @@ func TestSSHKeyLabel(t *testing.T) {
144144 t.Fatalf("relabel by another user: %v, want ErrNotFound", err)
145145 }
146146}
147
148// Migration 0052 rebuilds labels and milestones with an org scope. The
149// rebuild renames the old tables; since SQLite 3.26 a rename rewrites the
150// children's foreign keys to follow it, which would bind them to the *_old
151// tables. legacy_alter_table keeps the children naming labels and milestones,
152// which the new tables then are. foreign_keys stays on: nothing references the
153// *_old tables, so dropping them cascades nothing.
154// This checks the ids, the memberships and the foreign keys all survive.
155func TestMigration0052KeepsMembershipsAndForeignKeys(t *testing.T) {
156 s := open(t)
157 if err := s.MigrateTo(51); err != nil {
158 t.Fatal(err)
159 }
160 uid, err := s.CreateUser("alice", false)
161 if err != nil {
162 t.Fatal(err)
163 }
164 rid, err := s.CreateRepo("user", uid, "app", "public")
165 if err != nil {
166 t.Fatal(err)
167 }
168 iid, err := s.CreateIssue(rid, uid, "one", "", "md")
169 if err != nil {
170 t.Fatal(err)
171 }
172 if _, err := s.DB.Exec("INSERT INTO labels (repo_id, name, color) VALUES (?, 'bug', '#ff0000')", rid); err != nil {
173 t.Fatal(err)
174 }
175 if _, err := s.DB.Exec("INSERT INTO issue_labels (issue_id, label_id) SELECT ?, id FROM labels WHERE name = 'bug'", iid); err != nil {
176 t.Fatal(err)
177 }
178 if _, err := s.DB.Exec("INSERT INTO milestones (repo_id, title) VALUES (?, 'v1')", rid); err != nil {
179 t.Fatal(err)
180 }
181 if _, err := s.DB.Exec("UPDATE issues SET milestone_id = (SELECT id FROM milestones WHERE title = 'v1') WHERE id = ?", iid); err != nil {
182 t.Fatal(err)
183 }
184 if err := s.MigrateTo(52); err != nil {
185 t.Fatal(err)
186 }
187 var n int
188 if err := s.DB.QueryRow(`SELECT COUNT(*) FROM issue_labels il JOIN labels l ON l.id = il.label_id
189 WHERE il.issue_id = ? AND l.name = 'bug' AND l.repo_id = ? AND l.org_id IS NULL`, iid, rid).Scan(&n); err != nil || n != 1 {
190 t.Fatalf("label membership after 0052: %d, %v", n, err)
191 }
192 if err := s.DB.QueryRow(`SELECT COUNT(*) FROM issues i JOIN milestones m ON m.id = i.milestone_id
193 WHERE i.id = ? AND m.title = 'v1' AND m.repo_id = ?`, iid, rid).Scan(&n); err != nil || n != 1 {
194 t.Fatalf("milestone attachment after 0052: %d, %v", n, err)
195 }
196 rows, err := s.DB.Query("PRAGMA foreign_key_check")
197 if err != nil {
198 t.Fatal(err)
199 }
200 defer rows.Close()
201 if rows.Next() {
202 t.Fatal("foreign_key_check reported a violation after 0052")
203 }
204 // The scope CHECK holds: a row with neither or both scopes is refused.
205 if _, err := s.DB.Exec("INSERT INTO labels (name) VALUES ('neither')"); err == nil {
206 t.Fatal("label with no scope was accepted")
207 }
208 if _, err := s.DB.Exec("INSERT INTO labels (repo_id, org_id, name) VALUES (?, 1, 'both')", rid); err == nil {
209 t.Fatal("label with both scopes was accepted")
210 }
211 // Down refuses while an org-scoped row exists, and works once it is gone.
212 if _, err := s.DB.Exec("INSERT INTO orgs (name) VALUES ('acme')"); err != nil {
213 t.Fatal(err)
214 }
215 if _, err := s.DB.Exec("INSERT INTO labels (org_id, name) VALUES ((SELECT id FROM orgs WHERE name = 'acme'), 'org-only')"); err != nil {
216 t.Fatal(err)
217 }
218 if err := s.MigrateTo(51); err == nil {
219 t.Fatal("down migration accepted an org-scoped label")
220 }
221 if _, err := s.DB.Exec("DELETE FROM labels WHERE org_id IS NOT NULL"); err != nil {
222 t.Fatal(err)
223 }
224 if err := s.MigrateTo(51); err != nil {
225 t.Fatalf("down migration: %v", err)
226 }
227 if err := s.DB.QueryRow(`SELECT COUNT(*) FROM issue_labels il JOIN labels l ON l.id = il.label_id WHERE il.issue_id = ?`, iid).Scan(&n); err != nil || n != 1 {
228 t.Fatalf("label membership after down: %d, %v", n, err)
229 }
230}