Commit 02fa58993b
Verified · cmc
internal/store/labels.go +16 −15
| @@ -149,29 +149,30 @@ func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) { | ||
| 149 | 149 | if err != nil { |
| 150 | 150 | return 0, err |
| 151 | 151 | } |
| 152 | var repoRows []int64 | |
| 153 | for rows.Next() { | |
| 154 | var id int64 | |
| 155 | if err := rows.Scan(&id); err != nil { | |
| 156 | rows.Close() | |
| 157 | return 0, err | |
| 158 | } | |
| 159 | repoRows = append(repoRows, id) | |
| 152 | repoRows, err := scanIDs(rows) | |
| 153 | if err != nil { | |
| 154 | return 0, err | |
| 160 | 155 | } |
| 161 | rows.Close() | |
| 162 | 156 | for _, id := range repoRows { |
| 163 | // OR IGNORE: an issue cannot carry both today, but the primary key | |
| 164 | // makes the move safe if it ever did. | |
| 165 | if _, err := tx.Exec("UPDATE OR IGNORE issue_labels SET label_id = ? WHERE label_id = ?", orgRow, id); err != nil { | |
| 166 | return 0, err | |
| 167 | } | |
| 168 | if _, err := tx.Exec("DELETE FROM labels WHERE id = ?", id); err != nil { | |
| 157 | if err := foldLabelRow(tx, orgRow, id); err != nil { | |
| 169 | 158 | return 0, err |
| 170 | 159 | } |
| 171 | 160 | } |
| 172 | 161 | return len(repoRows), tx.Commit() |
| 173 | 162 | } |
| 174 | 163 | |
| 164 | // foldLabelRow moves a repository's label onto the org's row: every issue | |
| 165 | // carrying it gets the org row, then the repository row goes. | |
| 166 | func foldLabelRow(tx *sql.Tx, orgRow, repoRow int64) error { | |
| 167 | // OR IGNORE: an issue cannot carry both today, but the primary key | |
| 168 | // makes the move safe if it ever did. | |
| 169 | if _, err := tx.Exec("UPDATE OR IGNORE issue_labels SET label_id = ? WHERE label_id = ?", orgRow, repoRow); err != nil { | |
| 170 | return err | |
| 171 | } | |
| 172 | _, err := tx.Exec("DELETE FROM labels WHERE id = ?", repoRow) | |
| 173 | return err | |
| 174 | } | |
| 175 | ||
| 175 | 176 | // DeleteOrgLabel removes an org's label from the org and from every issue |
| 176 | 177 | // under it. |
| 177 | 178 | func (s *Store) DeleteOrgLabel(orgID int64, name string) error { |
internal/store/labels_test.go +66
| @@ -229,3 +229,69 @@ func TestSetOrgLabelPromotesRepoLabels(t *testing.T) { | ||
| 229 | 229 | t.Fatalf("memberships after org delete: %d", n) |
| 230 | 230 | } |
| 231 | 231 | } |
| 232 | ||
| 233 | // A repository moving into an org brings its own labels and milestones; | |
| 234 | // the names the org already holds fold into the org's rows rather than | |
| 235 | // leaving the repository seeing two of each. | |
| 236 | func TestTransferIntoOrgFoldsDuplicateNames(t *testing.T) { | |
| 237 | f := newAcme(t) | |
| 238 | n, err := f.s.CreateIssue(f.app.ID, f.alice, "a1", "", "md") | |
| 239 | if err != nil { | |
| 240 | t.Fatal(err) | |
| 241 | } | |
| 242 | issue, err := f.s.IssueByNumber(f.app.ID, n) | |
| 243 | if err != nil { | |
| 244 | t.Fatal(err) | |
| 245 | } | |
| 246 | if err := f.s.SetLabel(f.app, "bug", "#123456"); err != nil { | |
| 247 | t.Fatal(err) | |
| 248 | } | |
| 249 | if err := f.s.SetIssueLabel(f.app, issue.ID, "bug", true); err != nil { | |
| 250 | t.Fatal(err) | |
| 251 | } | |
| 252 | repoMS, err := f.s.CreateMilestone(f.app, "v1", "", "") | |
| 253 | if err != nil { | |
| 254 | t.Fatal(err) | |
| 255 | } | |
| 256 | if err := f.s.SetIssueMilestone(issue.ID, repoMS); err != nil { | |
| 257 | t.Fatal(err) | |
| 258 | } | |
| 259 | if _, err := f.s.SetOrgLabel(f.org, "bug", "#ff0000"); err != nil { | |
| 260 | t.Fatal(err) | |
| 261 | } | |
| 262 | orgMS, _, err := f.s.CreateOrgMilestone(f.org, "v1", "", "") | |
| 263 | if err != nil { | |
| 264 | t.Fatal(err) | |
| 265 | } | |
| 266 | if err := f.s.TransferRepo(f.app.ID, "org", f.org); err != nil { | |
| 267 | t.Fatal(err) | |
| 268 | } | |
| 269 | app, err := f.s.RepoByID(f.app.ID) | |
| 270 | if err != nil { | |
| 271 | t.Fatal(err) | |
| 272 | } | |
| 273 | labels, err := f.s.ListLabels(app, []int64{app.ID}) | |
| 274 | if err != nil || len(labels) != 1 || !labels[0].Org || labels[0].Color != "#ff0000" || labels[0].Issues != 1 { | |
| 275 | t.Fatalf("labels after transfer = %+v, %v", labels, err) | |
| 276 | } | |
| 277 | ms, err := f.s.ListMilestones(app, "all", []int64{app.ID}) | |
| 278 | if err != nil || len(ms) != 1 || ms[0].ID != orgMS || ms[0].OrgID != f.org || ms[0].OpenItems != 1 { | |
| 279 | t.Fatalf("milestones after transfer = %+v, %v", ms, err) | |
| 280 | } | |
| 281 | // The issue keeps both, pointing at the org's rows; the repository's | |
| 282 | // rows are gone. | |
| 283 | var count int | |
| 284 | f.s.DB.QueryRow(`SELECT COUNT(*) FROM issue_labels il JOIN labels l ON l.id = il.label_id | |
| 285 | WHERE il.issue_id = ? AND l.org_id = ?`, issue.ID, f.org).Scan(&count) | |
| 286 | if count != 1 { | |
| 287 | t.Fatalf("label membership after transfer: %d", count) | |
| 288 | } | |
| 289 | f.s.DB.QueryRow("SELECT COUNT(*) FROM labels WHERE repo_id = ?", app.ID).Scan(&count) | |
| 290 | if count != 0 { | |
| 291 | t.Fatalf("repo label rows left: %d", count) | |
| 292 | } | |
| 293 | f.s.DB.QueryRow("SELECT COUNT(*) FROM milestones WHERE id = ?", repoMS).Scan(&count) | |
| 294 | if count != 0 { | |
| 295 | t.Fatalf("repo milestone row left: %d", count) | |
| 296 | } | |
| 297 | } | |
internal/store/milestones.go +19 −17
| @@ -89,27 +89,29 @@ func (s *Store) CreateOrgMilestone(orgID int64, title, description, due string) | ||
| 89 | 89 | if err != nil { |
| 90 | 90 | return 0, 0, err |
| 91 | 91 | } |
| 92 | var repoRows []int64 | |
| 93 | for rows.Next() { | |
| 94 | var rid int64 | |
| 95 | if err := rows.Scan(&rid); err != nil { | |
| 96 | rows.Close() | |
| 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 { | |
| 97 | 98 | return 0, 0, err |
| 98 | 99 | } |
| 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 | |
| 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. | |
| 107 | func 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 | |
| 110 | 111 | } |
| 111 | 112 | } |
| 112 | return id, len(repoRows), tx.Commit() | |
| 113 | _, err := tx.Exec("DELETE FROM milestones WHERE id = ?", repoRow) | |
| 114 | return err | |
| 113 | 115 | } |
| 114 | 116 | |
| 115 | 117 | // milestoneQuery selects milestones with their progress, counting only |
internal/store/repos.go +70 −5
| @@ -476,11 +476,76 @@ func (s *Store) RenameRepo(repoID int64, newName string) error { | ||
| 476 | 476 | |
| 477 | 477 | // TransferRepo moves a repository to a new owner. The unique index on |
| 478 | 478 | // (owner_kind, owner_id, name) refuses collisions in the target namespace. |
| 479 | // Moving into an org folds the repository's labels and milestones whose | |
| 480 | // names the org already holds into the org's rows, in the same | |
| 481 | // transaction, so the repository does not come out seeing two of each. | |
| 482 | // Moving out of an org needs no counterpart: the repository keeps what it | |
| 483 | // owns and stops seeing the org's rows. | |
| 479 | 484 | func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error { |
| 480 | _, err := s.DB.Exec("UPDATE repos SET owner_kind = ?, owner_id = ? WHERE id = ?", | |
| 481 | newKind, newOwnerID, repoID) | |
| 482 | if isUniqueErr(err) { | |
| 483 | return fmt.Errorf("the target owner already has a repository by that name") | |
| 485 | tx, err := s.DB.Begin() | |
| 486 | if err != nil { | |
| 487 | return err | |
| 484 | 488 | } |
| 485 | return err | |
| 489 | defer tx.Rollback() | |
| 490 | if _, err := tx.Exec("UPDATE repos SET owner_kind = ?, owner_id = ? WHERE id = ?", | |
| 491 | newKind, newOwnerID, repoID); err != nil { | |
| 492 | if isUniqueErr(err) { | |
| 493 | return fmt.Errorf("the target owner already has a repository by that name") | |
| 494 | } | |
| 495 | return err | |
| 496 | } | |
| 497 | if newKind == "org" { | |
| 498 | if err := foldIntoOrg(tx, repoID, newOwnerID); err != nil { | |
| 499 | return err | |
| 500 | } | |
| 501 | } | |
| 502 | return tx.Commit() | |
| 503 | } | |
| 504 | ||
| 505 | // foldIntoOrg folds a repository's labels and milestones into the org's | |
| 506 | // rows of the same name, the way org label set and org milestone create | |
| 507 | // fold the repositories already under the org. | |
| 508 | func foldIntoOrg(tx *sql.Tx, repoID, orgID int64) error { | |
| 509 | labels, err := sharedNameRows(tx, "labels", "name", repoID, orgID) | |
| 510 | if err != nil { | |
| 511 | return err | |
| 512 | } | |
| 513 | for _, p := range labels { | |
| 514 | if err := foldLabelRow(tx, p.org, p.repo); err != nil { | |
| 515 | return err | |
| 516 | } | |
| 517 | } | |
| 518 | milestones, err := sharedNameRows(tx, "milestones", "title", repoID, orgID) | |
| 519 | if err != nil { | |
| 520 | return err | |
| 521 | } | |
| 522 | for _, p := range milestones { | |
| 523 | if err := foldMilestoneRow(tx, p.org, p.repo); err != nil { | |
| 524 | return err | |
| 525 | } | |
| 526 | } | |
| 527 | return nil | |
| 528 | } | |
| 529 | ||
| 530 | // rowPair is one repository row and the org row it folds into. | |
| 531 | type rowPair struct{ repo, org int64 } | |
| 532 | ||
| 533 | // sharedNameRows pairs a repository's label or milestone rows with the | |
| 534 | // org's rows carrying the same name. | |
| 535 | func sharedNameRows(tx *sql.Tx, table, nameCol string, repoID, orgID int64) ([]rowPair, error) { | |
| 536 | rows, err := tx.Query("SELECT t.id, o.id FROM "+table+" t JOIN "+table+" o"+ | |
| 537 | " ON o.org_id = ? AND o."+nameCol+" = t."+nameCol+" WHERE t.repo_id = ?", orgID, repoID) | |
| 538 | if err != nil { | |
| 539 | return nil, err | |
| 540 | } | |
| 541 | defer rows.Close() | |
| 542 | var out []rowPair | |
| 543 | for rows.Next() { | |
| 544 | var p rowPair | |
| 545 | if err := rows.Scan(&p.repo, &p.org); err != nil { | |
| 546 | return nil, err | |
| 547 | } | |
| 548 | out = append(out, p) | |
| 549 | } | |
| 550 | return out, rows.Err() | |
| 486 | 551 | } |
internal/store/scope.go +15
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package store |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "database/sql" | |
| 4 | 5 | "errors" |
| 5 | 6 | "strings" |
| 6 | 7 | ) |
| @@ -31,3 +32,17 @@ func inClause(ids []int64) (string, []any) { | ||
| 31 | 32 | } |
| 32 | 33 | return "(" + strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") + ")", args |
| 33 | 34 | } |
| 35 | ||
| 36 | // scanIDs collects a single-column id result and closes the rows. | |
| 37 | func scanIDs(rows *sql.Rows) ([]int64, error) { | |
| 38 | defer rows.Close() | |
| 39 | var out []int64 | |
| 40 | for rows.Next() { | |
| 41 | var id int64 | |
| 42 | if err := rows.Scan(&id); err != nil { | |
| 43 | return nil, err | |
| 44 | } | |
| 45 | out = append(out, id) | |
| 46 | } | |
| 47 | return out, rows.Err() | |
| 48 | } | |