Commit 02fa58993b

02fa58993bacfab451a35b4115d4252871356363

parent: 480aeee73f

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-11 16:19 UTC

store: a transfer into an org folds duplicate labels and milestones

A repository moving under an org kept its own rows beside the org's, so
the same name appeared twice in every list. The move now folds the rows
whose names the org holds into the org's, in the transaction that changes
the owner, through the helpers the org-level creates already use.

Ref #203
internal/store/labels.go +16 −15
@@ -149,29 +149,30 @@ func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) {
149149 if err != nil {
150150 return 0, err
151151 }
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
160155 }
161 rows.Close()
162156 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 {
169158 return 0, err
170159 }
171160 }
172161 return len(repoRows), tx.Commit()
173162}
174163
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.
166func 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
175176// DeleteOrgLabel removes an org's label from the org and from every issue
176177// under it.
177178func (s *Store) DeleteOrgLabel(orgID int64, name string) error {
internal/store/labels_test.go +66
@@ -229,3 +229,69 @@ func TestSetOrgLabelPromotesRepoLabels(t *testing.T) {
229229 t.Fatalf("memberships after org delete: %d", n)
230230 }
231231}
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.
236func 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)
8989 if err != nil {
9090 return 0, 0, err
9191 }
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 {
9798 return 0, 0, err
9899 }
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.
107func 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
110111 }
111112 }
112 return id, len(repoRows), tx.Commit()
113 _, err := tx.Exec("DELETE FROM milestones WHERE id = ?", repoRow)
114 return err
113115}
114116
115117// 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 {
476476
477477// TransferRepo moves a repository to a new owner. The unique index on
478478// (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.
479484func (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
484488 }
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.
508func 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.
531type 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.
535func 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()
486551}
internal/store/scope.go +15
@@ -1,6 +1,7 @@
11package store
22
33import (
4 "database/sql"
45 "errors"
56 "strings"
67)
@@ -31,3 +32,17 @@ func inClause(ids []int64) (string, []any) {
3132 }
3233 return "(" + strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") + ")", args
3334}
35
36// scanIDs collects a single-column id result and closes the rows.
37func 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}