Commit 71e32809de
Verified · cmc
cmd/gitbayd/adminusers.go +27
| @@ -57,6 +57,33 @@ func adminUserEnableCmd() *cobra.Command { | ||
| 57 | 57 | }) |
| 58 | 58 | } |
| 59 | 59 | |
| 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. | |
| 63 | func 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 | ||
| 60 | 87 | func adminAuditCmd() *cobra.Command { |
| 61 | 88 | var limit int |
| 62 | 89 | cmd := &cobra.Command{ |
cmd/gitbayd/main.go +1
| @@ -256,6 +256,7 @@ func adminCmd() *cobra.Command { | ||
| 256 | 256 | gcCmd(), |
| 257 | 257 | statsCmd(), |
| 258 | 258 | adminAuditCmd(), |
| 259 | adminMigrateCommitRefsCmd(), | |
| 259 | 260 | ) |
| 260 | 261 | return admin |
| 261 | 262 | } |
e2e/commentmigrate_test.go added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "path/filepath" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/store" | |
| 9 | ) | |
| 10 | ||
| 11 | func 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 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 11 | var 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. | |
| 17 | func (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 | ||
| 30 | func (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 | } | |