Commit dbbdb35edb
Verified · cmc
cmd/gitbayd/adminabout.go added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | package main | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | ||
| 6 | "github.com/spf13/cobra" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/config" | |
| 9 | "gitbay.org/gitbay/internal/control" | |
| 10 | "gitbay.org/gitbay/internal/gitutil" | |
| 11 | "gitbay.org/gitbay/internal/store" | |
| 12 | ) | |
| 13 | ||
| 14 | // adminMigrateProfileAboutCmd drains profile_about_backfill: each owner's | |
| 15 | // parked about text becomes profile/README.* in <owner>/.gitbay. Idempotent | |
| 16 | // — an owner who already has the file keeps it and loses the row. | |
| 17 | func adminMigrateProfileAboutCmd() *cobra.Command { | |
| 18 | return &cobra.Command{ | |
| 19 | Use: "migrate-profile-about", | |
| 20 | Short: "write parked profile about text into each owner's .gitbay repository", | |
| 21 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 22 | cfg, err := config.Load(configPath) | |
| 23 | if err != nil { | |
| 24 | return err | |
| 25 | } | |
| 26 | st, err := openStore(cfg) | |
| 27 | if err != nil { | |
| 28 | return err | |
| 29 | } | |
| 30 | defer st.Close() | |
| 31 | rows, err := st.PendingAboutBackfill() | |
| 32 | if err != nil { | |
| 33 | return err | |
| 34 | } | |
| 35 | n := 0 | |
| 36 | for _, row := range rows { | |
| 37 | written, err := writeAbout(cfg, st, row) | |
| 38 | if err != nil { | |
| 39 | return fmt.Errorf("%s: %w", row.OwnerName, err) | |
| 40 | } | |
| 41 | if err := st.ClearAboutBackfill(row.OwnerKind, row.OwnerID); err != nil { | |
| 42 | return err | |
| 43 | } | |
| 44 | if written { | |
| 45 | n++ | |
| 46 | } | |
| 47 | } | |
| 48 | fmt.Printf("wrote %d profile about file(s)\n", n) | |
| 49 | return nil | |
| 50 | }, | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | // writeAbout creates <owner>/.gitbay if it does not exist and commits the | |
| 55 | // about at the recorded format. It reports whether it wrote anything: an | |
| 56 | // owner who already has the file is left alone. | |
| 57 | func writeAbout(cfg config.Config, st *store.Store, row store.AboutRow) (bool, error) { | |
| 58 | path := row.OwnerName + "/" + control.ProfileRepoName | |
| 59 | repo, err := st.RepoByPath(path) | |
| 60 | if err != nil { | |
| 61 | // Public, because the about it carries was public where it was. | |
| 62 | id, cerr := st.CreateRepo(row.OwnerKind, row.OwnerID, control.ProfileRepoName, "public") | |
| 63 | if cerr != nil { | |
| 64 | return false, cerr | |
| 65 | } | |
| 66 | dir := control.RepoDir(cfg.Server.Root, row.OwnerName, control.ProfileRepoName) | |
| 67 | if ierr := gitutil.InitBare(dir, "main", control.HooksDir(cfg.Server.Root)); ierr != nil { | |
| 68 | st.DeleteRepo(id) | |
| 69 | return false, ierr | |
| 70 | } | |
| 71 | if repo, err = st.RepoByPath(path); err != nil { | |
| 72 | return false, err | |
| 73 | } | |
| 74 | } | |
| 75 | dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 76 | ext := ".md" | |
| 77 | if row.Format == "org" { | |
| 78 | ext = ".org" | |
| 79 | } | |
| 80 | file := control.AboutBase + ext | |
| 81 | if _, err := gitutil.ReadBlob(dir, repo.DefaultBranch, file, 1); err == nil { | |
| 82 | return false, nil // already there | |
| 83 | } | |
| 84 | // A commit carries an identity. An owner without a verified address, | |
| 85 | // and every org, gets the noreply form rather than no commit. | |
| 86 | email := row.OwnerName + "@users.noreply." + cfg.SiteHost() | |
| 87 | if row.OwnerKind == "user" { | |
| 88 | if addr, _ := st.PrimaryVerifiedEmail(row.OwnerID); addr != "" { | |
| 89 | email = addr | |
| 90 | } | |
| 91 | } | |
| 92 | if _, err := gitutil.CommitFileChange(dir, repo.DefaultBranch, file, | |
| 93 | []byte(row.About), row.OwnerName, email, | |
| 94 | "move profile about out of the database"); err != nil { | |
| 95 | return false, err | |
| 96 | } | |
| 97 | return true, nil | |
| 98 | } | |
cmd/gitbayd/main.go +1
| @@ -399,6 +399,7 @@ func adminCmd() *cobra.Command { | ||
| 399 | 399 | backupCmd(), |
| 400 | 400 | gcCmd(), |
| 401 | 401 | adminMigrateCommitRefsCmd(), |
| 402 | adminMigrateProfileAboutCmd(), | |
| 402 | 403 | adminBackfillActivityCmd(), |
| 403 | 404 | ) |
| 404 | 405 | return admin |
e2e/aboutbackfill_test.go added +69
| @@ -0,0 +1,69 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "path/filepath" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/store" | |
| 9 | ) | |
| 10 | ||
| 11 | // The about text parked by migration 0058 becomes a file in the owner's | |
| 12 | // .gitbay repository. Running it twice writes nothing the second time. | |
| 13 | func TestMigrateProfileAbout(t *testing.T) { | |
| 14 | inst := startInstance(t) | |
| 15 | aliceKey := inst.newKey(t, "alice") | |
| 16 | inst.admin(t, "admin", "user", "create", "alice", | |
| 17 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 18 | ||
| 19 | // Seed the holding table the way the migration would have. | |
| 20 | dbPath := filepath.Join(inst.root, "gitbay.db") | |
| 21 | st, err := store.Open(dbPath) | |
| 22 | if err != nil { | |
| 23 | t.Fatal(err) | |
| 24 | } | |
| 25 | _, err = st.DB.Exec( | |
| 26 | "INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format) "+ | |
| 27 | "VALUES ('user', (SELECT id FROM users WHERE username='alice'), ?, 'org')", | |
| 28 | "* alice\n\ntext from the database\n") | |
| 29 | st.Close() | |
| 30 | if err != nil { | |
| 31 | t.Fatal(err) | |
| 32 | } | |
| 33 | ||
| 34 | inst.admin(t, "admin", "migrate-profile-about") | |
| 35 | ||
| 36 | out, _, code := inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") | |
| 37 | if code != 0 { | |
| 38 | t.Fatalf("profile show: %d", code) | |
| 39 | } | |
| 40 | if !strings.Contains(out, "text from the database") { | |
| 41 | t.Errorf("about not moved into the repository: %s", out) | |
| 42 | } | |
| 43 | if !strings.Contains(out, `"about_path":"profile/README.org"`) { | |
| 44 | t.Errorf("about not written at the recorded format: %s", out) | |
| 45 | } | |
| 46 | ||
| 47 | // The repository it made is public, so nothing that was world-readable | |
| 48 | // became hidden. | |
| 49 | out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/.gitbay", "--json") | |
| 50 | if !strings.Contains(out, `"visibility":"public"`) { | |
| 51 | t.Errorf("backfilled repository is not public: %s", out) | |
| 52 | } | |
| 53 | ||
| 54 | // Idempotent: a second run is a no-op and the table stays empty. | |
| 55 | inst.admin(t, "admin", "migrate-profile-about") | |
| 56 | st, err = store.Open(dbPath) | |
| 57 | if err != nil { | |
| 58 | t.Fatal(err) | |
| 59 | } | |
| 60 | var n int | |
| 61 | err = st.DB.QueryRow("SELECT count(*) FROM profile_about_backfill").Scan(&n) | |
| 62 | st.Close() | |
| 63 | if err != nil { | |
| 64 | t.Fatal(err) | |
| 65 | } | |
| 66 | if n != 0 { | |
| 67 | t.Errorf("holding table still has %d row(s)", n) | |
| 68 | } | |
| 69 | } | |
internal/control/migrate.go +1 −2
| @@ -166,8 +166,7 @@ func runAccountImportBundle(c *Ctx, args []string) int { | ||
| 166 | 166 | return c.fail(protocol.ExitUsage, "unsupported bundle %q (want %s)", b.Bundle, bundleVersion) |
| 167 | 167 | } |
| 168 | 168 | |
| 169 | if b.Profile.Description != "" || b.Profile.Website != "" || | |
| 170 | b.Profile.About != "" || len(b.Profile.Links) > 0 { | |
| 169 | if b.Profile.Description != "" || b.Profile.Website != "" || len(b.Profile.Links) > 0 { | |
| 171 | 170 | c.Store.SetOwnerProfile("user", c.User.ID, b.Profile) |
| 172 | 171 | } |
| 173 | 172 | for _, addr := range b.Emails { |
internal/store/aboutbackfill.go added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | // AboutRow is one owner's parked about text, waiting to become a file in | |
| 4 | // <owner>/.gitbay. Migration 0058 fills the table; the backfill command | |
| 5 | // drains it. | |
| 6 | type AboutRow struct { | |
| 7 | OwnerKind string | |
| 8 | OwnerID int64 | |
| 9 | OwnerName string | |
| 10 | About string | |
| 11 | Format string | |
| 12 | } | |
| 13 | ||
| 14 | // PendingAboutBackfill lists the owners whose about text has not been | |
| 15 | // written to a repository yet, resolving each one's name. | |
| 16 | func (s *Store) PendingAboutBackfill() ([]AboutRow, error) { | |
| 17 | rows, err := s.DB.Query(` | |
| 18 | SELECT b.owner_kind, b.owner_id, b.about, b.about_format, | |
| 19 | COALESCE(u.username, o.name) | |
| 20 | FROM profile_about_backfill b | |
| 21 | LEFT JOIN users u ON b.owner_kind = 'user' AND u.id = b.owner_id | |
| 22 | LEFT JOIN orgs o ON b.owner_kind = 'org' AND o.id = b.owner_id | |
| 23 | ORDER BY b.owner_kind, b.owner_id`) | |
| 24 | if err != nil { | |
| 25 | return nil, err | |
| 26 | } | |
| 27 | defer rows.Close() | |
| 28 | var out []AboutRow | |
| 29 | for rows.Next() { | |
| 30 | var r AboutRow | |
| 31 | var name *string | |
| 32 | if err := rows.Scan(&r.OwnerKind, &r.OwnerID, &r.About, &r.Format, &name); err != nil { | |
| 33 | return nil, err | |
| 34 | } | |
| 35 | if name == nil { | |
| 36 | continue // the owner is gone; the row goes with them | |
| 37 | } | |
| 38 | r.OwnerName = *name | |
| 39 | out = append(out, r) | |
| 40 | } | |
| 41 | return out, rows.Err() | |
| 42 | } | |
| 43 | ||
| 44 | // ClearAboutBackfill drops one owner's row once its file exists. | |
| 45 | func (s *Store) ClearAboutBackfill(kind string, id int64) error { | |
| 46 | _, err := s.DB.Exec( | |
| 47 | "DELETE FROM profile_about_backfill WHERE owner_kind = ? AND owner_id = ?", kind, id) | |
| 48 | return err | |
| 49 | } | |
internal/store/migrations/0058_profile_about_out.down.sql added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | ALTER TABLE users ADD COLUMN about TEXT NOT NULL DEFAULT ''; | |
| 2 | ALTER TABLE users ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md'; | |
| 3 | ALTER TABLE orgs ADD COLUMN about TEXT NOT NULL DEFAULT ''; | |
| 4 | ALTER TABLE orgs ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md'; | |
| 5 | ||
| 6 | UPDATE users SET | |
| 7 | about = (SELECT about FROM profile_about_backfill | |
| 8 | WHERE owner_kind = 'user' AND owner_id = users.id), | |
| 9 | about_format = (SELECT about_format FROM profile_about_backfill | |
| 10 | WHERE owner_kind = 'user' AND owner_id = users.id) | |
| 11 | WHERE id IN (SELECT owner_id FROM profile_about_backfill WHERE owner_kind = 'user'); | |
| 12 | ||
| 13 | UPDATE orgs SET | |
| 14 | about = (SELECT about FROM profile_about_backfill | |
| 15 | WHERE owner_kind = 'org' AND owner_id = orgs.id), | |
| 16 | about_format = (SELECT about_format FROM profile_about_backfill | |
| 17 | WHERE owner_kind = 'org' AND owner_id = orgs.id) | |
| 18 | WHERE id IN (SELECT owner_id FROM profile_about_backfill WHERE owner_kind = 'org'); | |
| 19 | ||
| 20 | DROP TABLE profile_about_backfill; | |
internal/store/migrations/0058_profile_about_out.up.sql added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | -- The about text moves into profile/README.* in <owner>/.gitbay. A SQL | |
| 2 | -- migration cannot write git objects, so the text is parked here and | |
| 3 | -- `gitbayd admin migrate-profile-about` drains the table into | |
| 4 | -- repositories. A later release drops the emptied table. | |
| 5 | CREATE TABLE profile_about_backfill ( | |
| 6 | owner_kind TEXT NOT NULL, | |
| 7 | owner_id INTEGER NOT NULL, | |
| 8 | about TEXT NOT NULL, | |
| 9 | about_format TEXT NOT NULL, | |
| 10 | PRIMARY KEY (owner_kind, owner_id) | |
| 11 | ); | |
| 12 | ||
| 13 | INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format) | |
| 14 | SELECT 'user', id, about, about_format FROM users WHERE about <> ''; | |
| 15 | ||
| 16 | INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format) | |
| 17 | SELECT 'org', id, about, about_format FROM orgs WHERE about <> ''; | |
| 18 | ||
| 19 | ALTER TABLE users DROP COLUMN about; | |
| 20 | ALTER TABLE users DROP COLUMN about_format; | |
| 21 | ALTER TABLE orgs DROP COLUMN about; | |
| 22 | ALTER TABLE orgs DROP COLUMN about_format; | |
internal/store/orgs.go +9 −13
| @@ -224,13 +224,12 @@ func (s *Store) RenameOrg(orgID int64, newName string) error { | ||
| 224 | 224 | return tx.Commit() |
| 225 | 225 | } |
| 226 | 226 | |
| 227 | // Profile is the presentational half of a user or org. | |
| 227 | // Profile is the presentational half of a user or org. The about text is | |
| 228 | // not here: it is a file in <owner>/.gitbay, read through the control | |
| 229 | // layer. | |
| 228 | 230 | type Profile struct { |
| 229 | Description string `json:"description,omitempty"` | |
| 230 | Website string `json:"website,omitempty"` | |
| 231 | About string `json:"about,omitempty"` | |
| 232 | // AboutFormat is "md" or "org"; About has no filename to dispatch on. | |
| 233 | AboutFormat string `json:"about_format,omitempty"` | |
| 231 | Description string `json:"description,omitempty"` | |
| 232 | Website string `json:"website,omitempty"` | |
| 234 | 233 | Links []ProfileLink `json:"links,omitempty"` |
| 235 | 234 | } |
| 236 | 235 | |
| @@ -247,8 +246,8 @@ func (s *Store) OwnerProfile(kind string, id int64) (Profile, error) { | ||
| 247 | 246 | var p Profile |
| 248 | 247 | var linksJSON string |
| 249 | 248 | err := s.DB.QueryRow( |
| 250 | "SELECT description, website, about, about_format, links FROM "+table+" WHERE id = ?", id). | |
| 251 | Scan(&p.Description, &p.Website, &p.About, &p.AboutFormat, &linksJSON) | |
| 249 | "SELECT description, website, links FROM "+table+" WHERE id = ?", id). | |
| 250 | Scan(&p.Description, &p.Website, &linksJSON) | |
| 252 | 251 | if err != nil { |
| 253 | 252 | return p, err |
| 254 | 253 | } |
| @@ -263,9 +262,6 @@ func (s *Store) OwnerProfile(kind string, id int64) (Profile, error) { | ||
| 263 | 262 | // SetOwnerProfile updates the profile for kind "user" or "org". |
| 264 | 263 | func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error { |
| 265 | 264 | table := map[string]string{"user": "users", "org": "orgs"}[kind] |
| 266 | if p.AboutFormat != "org" { | |
| 267 | p.AboutFormat = "md" | |
| 268 | } | |
| 269 | 265 | links := "" |
| 270 | 266 | if len(p.Links) > 0 { |
| 271 | 267 | raw, err := json.Marshal(p.Links) |
| @@ -275,7 +271,7 @@ func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error { | ||
| 275 | 271 | links = string(raw) |
| 276 | 272 | } |
| 277 | 273 | _, err := s.DB.Exec( |
| 278 | "UPDATE "+table+" SET description = ?, website = ?, about = ?, about_format = ?, links = ? WHERE id = ?", | |
| 279 | p.Description, p.Website, p.About, p.AboutFormat, links, id) | |
| 274 | "UPDATE "+table+" SET description = ?, website = ?, links = ? WHERE id = ?", | |
| 275 | p.Description, p.Website, links, id) | |
| 280 | 276 | return err |
| 281 | 277 | } |