A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 71e32809de

71e32809de1fd972dc7db32ff1071fc808cc4738

parent: 2a6874e6b7

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-25T00:54:02Z

Add admin migrate-commit-refs backfill

Refs #31, #10. Legacy commit-reference comments predate the kind column
and render as author-attributed comments with a bare sha. This one-shot,
idempotent admin command matches the 'closed by/referenced in commit
<sha>: ...' format, rewrites the sha as a link to the commit page (short
shas resolve via ResolveRef), and flips the row to kind=system. Only
kind=comment rows are touched; human comments and already-converted rows
are left alone.
cmd/gitbayd/adminusers.go +27
@@ -57,6 +57,33 @@ func adminUserEnableCmd() *cobra.Command {
5757 })
5858 }
5959
60// adminMigrateCommitRefsCmd is a one-shot backfill: legacy commit-reference
61// comments (author-attributed, bare sha) become system messages with a
62// linked sha. Idempotent.
63func adminMigrateCommitRefsCmd() *cobra.Command {
64 return &cobra.Command{
65 Use: "migrate-commit-refs",
66 Short: "convert legacy commit-reference comments into linked system messages",
67 RunE: func(cmd *cobra.Command, args []string) error {
68 cfg, err := config.Load(configPath)
69 if err != nil {
70 return err
71 }
72 st, err := openStore(cfg)
73 if err != nil {
74 return err
75 }
76 defer st.Close()
77 n, err := st.MigrateCommitRefComments()
78 if err != nil {
79 return err
80 }
81 fmt.Printf("converted %d commit-reference comment(s) to system messages\n", n)
82 return nil
83 },
84 }
85}
86
6087 func adminAuditCmd() *cobra.Command {
6188 var limit int
6289 cmd := &cobra.Command{
cmd/gitbayd/main.go +1
@@ -256,6 +256,7 @@ func adminCmd() *cobra.Command {
256256 gcCmd(),
257257 statsCmd(),
258258 adminAuditCmd(),
259 adminMigrateCommitRefsCmd(),
259260 )
260261 return admin
261262 }
e2e/commentmigrate_test.go added +74
@@ -0,0 +1,74 @@
1package e2e
2
3import (
4 "path/filepath"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/store"
9)
10
11func TestMigrateCommitRefComments(t *testing.T) {
12 inst := startInstance(t)
13 aliceKey := inst.newKey(t, "alice")
14 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
15 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
16 t.Fatal("repo create failed")
17 }
18 // Two issues; the migration should touch commit-ref comments only.
19 inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'bug'")
20 inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'other'")
21 // A plain human comment that must be left alone.
22 inst.ssh(t, aliceKey, "", "issue", "comment", "alice/app", "2", "--message", "'just a note'")
23
24 // Simulate a legacy commit-reference comment: author-attributed
25 // (kind='comment'), bare short sha, the pre-system-message format.
26 dbPath := filepath.Join(inst.root, "gitbay.db")
27 legacy := "referenced in commit 2e6467a72f: Audit logging, auth throttling, pack limits, user disable"
28 st, err := store.Open(dbPath)
29 if err != nil {
30 t.Fatal(err)
31 }
32 _, err = st.DB.Exec(
33 "INSERT INTO issue_comments (issue_id, author_id, body, kind) "+
34 "VALUES ((SELECT id FROM issues WHERE number=1), "+
35 "(SELECT id FROM users WHERE username='alice'), ?, 'comment')", legacy)
36 st.Close()
37 if err != nil {
38 t.Fatal(err)
39 }
40
41 // Before: it renders as a comment card from alice, no link.
42 _, body := inst.get(t, "/alice/app/issues/1")
43 if !strings.Contains(body, "referenced in commit 2e6467a72f") ||
44 strings.Contains(body, "/alice/app/commit/2e6467a72f") {
45 t.Fatal("precondition: legacy comment not seeded as expected")
46 }
47
48 out := inst.admin(t, "admin", "migrate-commit-refs")
49 if !strings.Contains(out, "converted 1 commit-reference comment") {
50 t.Fatalf("migrate output: %s", out)
51 }
52
53 // After: a system message with a linked sha.
54 _, body = inst.get(t, "/alice/app/issues/1")
55 if !strings.Contains(body, `class="syscomment"`) ||
56 !strings.Contains(body, `href="/alice/app/commit/2e6467a72f"`) {
57 t.Fatalf("comment not converted:\n%s", body)
58 }
59 show, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json")
60 if !strings.Contains(show, `"author":"system"`) {
61 t.Fatalf("author not system: %s", show)
62 }
63
64 // The human comment on issue 2 is untouched.
65 out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "2", "--json")
66 if !strings.Contains(out, "just a note") || strings.Contains(out, `"author":"system"`) {
67 t.Fatalf("human comment altered: %s", out)
68 }
69
70 // Re-running converts nothing (idempotent).
71 if out := inst.admin(t, "admin", "migrate-commit-refs"); !strings.Contains(out, "converted 0") {
72 t.Fatalf("re-run not idempotent: %s", out)
73 }
74}
internal/store/commentmigrate.go added +80
@@ -0,0 +1,80 @@
1package store
2
3import (
4 "fmt"
5 "regexp"
6)
7
8// commitRefPat matches the legacy commit-reference comment format, written
9// before these entries were system messages: "closed by commit <sha>: …"
10// or "referenced in commit <sha>: …" with a bare short sha.
11var commitRefPat = regexp.MustCompile(`^((?:closed by|referenced in) commit )([0-9a-f]{7,40})(: [\s\S]*)$`)
12
13// MigrateCommitRefComments converts legacy commit-reference comments into
14// system messages with a linked sha, matching what new references produce.
15// It is idempotent: only kind='comment' rows are considered, and converted
16// rows become kind='system'. Returns the number converted.
17func (s *Store) MigrateCommitRefComments() (int, error) {
18 n1, err := s.migrateRefTable(
19 "issue_comments", "issues", "issue_id",
20 "'closed by commit %' OR c.body LIKE 'referenced in commit %'")
21 if err != nil {
22 return n1, err
23 }
24 n2, err := s.migrateRefTable(
25 "mr_comments", "merge_requests", "mr_id",
26 "'closed by commit %' OR c.body LIKE 'referenced in commit %'")
27 return n1 + n2, err
28}
29
30func (s *Store) migrateRefTable(table, itemTable, fk, likeClause string) (int, error) {
31 q := fmt.Sprintf(`
32 SELECT c.id, c.body, COALESCE(u.username, o.name) || '/' || r.name
33 FROM %s c
34 JOIN %s it ON it.id = c.%s
35 JOIN repos r ON r.id = it.repo_id
36 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
37 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
38 WHERE c.kind = 'comment' AND (c.body LIKE %s)`, table, itemTable, fk, likeClause)
39 rows, err := s.DB.Query(q)
40 if err != nil {
41 return 0, err
42 }
43 type conv struct {
44 id int64
45 body string
46 }
47 var todo []conv
48 for rows.Next() {
49 var id int64
50 var body, path string
51 if err := rows.Scan(&id, &body, &path); err != nil {
52 rows.Close()
53 return 0, err
54 }
55 m := commitRefPat.FindStringSubmatch(body)
56 if m == nil {
57 continue // LIKE candidate that isn't the exact format
58 }
59 verb, sha, rest := m[1], m[2], m[3]
60 newBody := fmt.Sprintf("%s[%s](/%s/commit/%s)%s", verb, sha, path, sha, rest)
61 todo = append(todo, conv{id, newBody})
62 }
63 rows.Close()
64 if err := rows.Err(); err != nil {
65 return 0, err
66 }
67
68 tx, err := s.DB.Begin()
69 if err != nil {
70 return 0, err
71 }
72 defer tx.Rollback()
73 upd := fmt.Sprintf("UPDATE %s SET body = ?, kind = 'system' WHERE id = ?", table)
74 for _, c := range todo {
75 if _, err := tx.Exec(upd, c.body, c.id); err != nil {
76 return 0, err
77 }
78 }
79 return len(todo), tx.Commit()
80}