Commit dbbdb35edb

dbbdb35edb64aa6ccf4f883b282797fcd2ac6dfe

parent: 925e46b053

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 00:17 UTC

store: move the parked about text into each owner's .gitbay

Migration 0058 parks the text and drops the columns; gitbayd admin
migrate-profile-about writes it into a public <owner>/.gitbay. A SQL
migration cannot write git objects, and gitbayd migrates at startup,
so the text has to survive the drop somewhere.

Ref #236
cmd/gitbayd/adminabout.go added +98
@@ -0,0 +1,98 @@
1package main
2
3import (
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.
17func 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.
57func 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 {
399399 backupCmd(),
400400 gcCmd(),
401401 adminMigrateCommitRefsCmd(),
402 adminMigrateProfileAboutCmd(),
402403 adminBackfillActivityCmd(),
403404 )
404405 return admin
e2e/aboutbackfill_test.go added +69
@@ -0,0 +1,69 @@
1package e2e
2
3import (
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.
13func 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 {
166166 return c.fail(protocol.ExitUsage, "unsupported bundle %q (want %s)", b.Bundle, bundleVersion)
167167 }
168168
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 {
171170 c.Store.SetOwnerProfile("user", c.User.ID, b.Profile)
172171 }
173172 for _, addr := range b.Emails {
internal/store/aboutbackfill.go added +49
@@ -0,0 +1,49 @@
1package 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.
6type 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.
16func (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.
45func (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 @@
1ALTER TABLE users ADD COLUMN about TEXT NOT NULL DEFAULT '';
2ALTER TABLE users ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md';
3ALTER TABLE orgs ADD COLUMN about TEXT NOT NULL DEFAULT '';
4ALTER TABLE orgs ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md';
5
6UPDATE 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
13UPDATE 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
20DROP 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.
5CREATE 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
13INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format)
14SELECT 'user', id, about, about_format FROM users WHERE about <> '';
15
16INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format)
17SELECT 'org', id, about, about_format FROM orgs WHERE about <> '';
18
19ALTER TABLE users DROP COLUMN about;
20ALTER TABLE users DROP COLUMN about_format;
21ALTER TABLE orgs DROP COLUMN about;
22ALTER 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 {
224224 return tx.Commit()
225225}
226226
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.
228230type 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"`
234233 Links []ProfileLink `json:"links,omitempty"`
235234}
236235
@@ -247,8 +246,8 @@ func (s *Store) OwnerProfile(kind string, id int64) (Profile, error) {
247246 var p Profile
248247 var linksJSON string
249248 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)
252251 if err != nil {
253252 return p, err
254253 }
@@ -263,9 +262,6 @@ func (s *Store) OwnerProfile(kind string, id int64) (Profile, error) {
263262// SetOwnerProfile updates the profile for kind "user" or "org".
264263func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error {
265264 table := map[string]string{"user": "users", "org": "orgs"}[kind]
266 if p.AboutFormat != "org" {
267 p.AboutFormat = "md"
268 }
269265 links := ""
270266 if len(p.Links) > 0 {
271267 raw, err := json.Marshal(p.Links)
@@ -275,7 +271,7 @@ func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error {
275271 links = string(raw)
276272 }
277273 _, 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)
280276 return err
281277}