Commit 6a03ea8472
Verified · cmc
internal/store/labels.go +4 −3
| @@ -1,11 +1,12 @@ | ||
| 1 | 1 | package store |
| 2 | 2 | |
| 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. | |
| 6 | 6 | type Label struct { |
| 7 | 7 | Name string `json:"name"` |
| 8 | 8 | Color string `json:"color,omitempty"` |
| 9 | Org bool `json:"org,omitempty"` | |
| 9 | 10 | Issues int64 `json:"issues"` |
| 10 | 11 | } |
| 11 | 12 | |
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. | |
| 3 | PRAGMA foreign_keys = OFF; | |
| 4 | PRAGMA legacy_alter_table = ON; | |
| 5 | ||
| 6 | ALTER TABLE labels RENAME TO labels_old; | |
| 7 | CREATE 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 | ); | |
| 14 | INSERT INTO labels (id, repo_id, name, color) | |
| 15 | SELECT id, repo_id, name, color FROM labels_old; | |
| 16 | DROP TABLE labels_old; | |
| 17 | ||
| 18 | ALTER TABLE milestones RENAME TO milestones_old; | |
| 19 | CREATE 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 | ); | |
| 29 | INSERT 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; | |
| 31 | DROP TABLE milestones_old; | |
| 32 | ||
| 33 | PRAGMA legacy_alter_table = OFF; | |
| 34 | PRAGMA 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. | |
| 11 | PRAGMA foreign_keys = OFF; | |
| 12 | PRAGMA legacy_alter_table = ON; | |
| 13 | ||
| 14 | ALTER TABLE labels RENAME TO labels_old; | |
| 15 | CREATE 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 | ); | |
| 23 | INSERT INTO labels (id, repo_id, name, color) | |
| 24 | SELECT id, repo_id, name, color FROM labels_old; | |
| 25 | DROP TABLE labels_old; | |
| 26 | CREATE UNIQUE INDEX labels_repo_name ON labels(repo_id, name) WHERE repo_id IS NOT NULL; | |
| 27 | CREATE UNIQUE INDEX labels_org_name ON labels(org_id, name) WHERE org_id IS NOT NULL; | |
| 28 | ||
| 29 | ALTER TABLE milestones RENAME TO milestones_old; | |
| 30 | CREATE 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 | ); | |
| 41 | INSERT 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; | |
| 43 | DROP TABLE milestones_old; | |
| 44 | CREATE UNIQUE INDEX milestones_repo_title ON milestones(repo_id, title) WHERE repo_id IS NOT NULL; | |
| 45 | CREATE UNIQUE INDEX milestones_org_title ON milestones(org_id, title) WHERE org_id IS NOT NULL; | |
| 46 | ||
| 47 | PRAGMA legacy_alter_table = OFF; | |
| 48 | PRAGMA foreign_keys = ON; | |
internal/store/milestones.go +1
| @@ -9,6 +9,7 @@ import ( | ||
| 9 | 9 | type Milestone struct { |
| 10 | 10 | ID int64 |
| 11 | 11 | RepoID int64 |
| 12 | OrgID int64 // set instead of RepoID for an org milestone | |
| 12 | 13 | Title string |
| 13 | 14 | Description string |
| 14 | 15 | DueDate string |
internal/store/store.go +7
| @@ -161,6 +161,13 @@ func (s *Store) migrateTo(target int) error { | ||
| 161 | 161 | return err |
| 162 | 162 | } |
| 163 | 163 | 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 | } | |
| 164 | 171 | tx, err := s.DB.Begin() |
| 165 | 172 | if err != nil { |
| 166 | 173 | return err |
internal/store/store_test.go +84
| @@ -144,3 +144,87 @@ func TestSSHKeyLabel(t *testing.T) { | ||
| 144 | 144 | t.Fatalf("relabel by another user: %v, want ErrNotFound", err) |
| 145 | 145 | } |
| 146 | 146 | } |
| 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. | |
| 155 | func 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 | } | |