internal/control/migrate.go
300 lines · 10322 bytes
1package control
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "strings"
8
9 "gitbay.org/gitbay/internal/gitutil"
10 "gitbay.org/gitbay/internal/protocol"
11 "gitbay.org/gitbay/internal/store"
12)
13
14func init() {
15 register(Command{Path: []string{"account", "export"},
16 Summary: "write your account bundle (profile, repos, issues, MRs) as JSON to stdout",
17 ReadOnly: true, Run: runAccountExport})
18 register(Command{Path: []string{"account", "import-bundle"},
19 Summary: "replay an account bundle from stdin (see gitbay migrate)",
20 ReadsStdin: true, Run: runAccountImportBundle})
21}
22
23// The bundle format doubles as a user-level backup. Keys are never
24// exported (trust is per-instance); emails arrive unverified.
25const bundleVersion = "gitbay-account/1"
26
27type bundleComment struct {
28 Author string `json:"author"`
29 Body string `json:"body"`
30 CreatedAt string `json:"created_at"`
31}
32type bundleIssue struct {
33 Number int64 `json:"number"`
34 Title string `json:"title"`
35 Body string `json:"body"`
36 State string `json:"state"`
37 Author string `json:"author"`
38 CreatedAt string `json:"created_at"`
39 Labels []string `json:"labels,omitempty"`
40 Comments []bundleComment `json:"comments,omitempty"`
41}
42type bundleMR struct {
43 Number int64 `json:"number"`
44 Title string `json:"title"`
45 Body string `json:"body"`
46 State string `json:"state"`
47 Author string `json:"author"`
48 SourceRef string `json:"source_ref"`
49 TargetRef string `json:"target_ref"`
50 CreatedAt string `json:"created_at"`
51 Comments []bundleComment `json:"comments,omitempty"`
52}
53type bundleRepo struct {
54 Name string `json:"name"`
55 Visibility string `json:"visibility"`
56 DefaultBranch string `json:"default_branch"`
57 Description string `json:"description,omitempty"`
58 Topics []string `json:"topics,omitempty"`
59 Settings store.RepoSettings `json:"settings"`
60 Issues []bundleIssue `json:"issues,omitempty"`
61 MRs []bundleMR `json:"mrs,omitempty"`
62}
63type bundle struct {
64 Bundle string `json:"bundle"`
65 Username string `json:"username"`
66 Profile store.Profile `json:"profile"`
67 Emails []string `json:"emails,omitempty"`
68 Repos []bundleRepo `json:"repos"`
69}
70
71func runAccountExport(c *Ctx, args []string) int {
72 if len(args) != 0 {
73 return c.fail(protocol.ExitUsage, "usage: account export > bundle.json")
74 }
75 b := bundle{Bundle: bundleVersion, Username: c.User.Username}
76 b.Profile, _ = c.Store.OwnerProfile("user", c.User.ID)
77 b.Emails, _ = c.Store.UserEmailAddresses(c.User.ID)
78 repos, err := c.Store.ListReposForOwner("user", c.User.ID)
79 if err != nil {
80 return c.fail(protocol.ExitFailure, "%v", err)
81 }
82 for _, r := range repos {
83 br := bundleRepo{
84 Name: r.Name,
85 Visibility: r.Visibility,
86 DefaultBranch: r.DefaultBranch,
87 Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)),
88 Settings: r.Settings,
89 }
90 br.Topics, _ = c.Store.ListTopics(r.ID)
91 issues, _ := c.Store.ListIssues(r.ID, "all", 0, 0)
92 for i := len(issues) - 1; i >= 0; i-- { // ascending numbers
93 iss := issues[i]
94 full, err := c.Store.IssueByNumber(r.ID, iss.Number)
95 if err != nil {
96 continue
97 }
98 bi := bundleIssue{Number: full.Number, Title: full.Title, Body: full.Body,
99 State: full.State, Author: full.Author, CreatedAt: full.CreatedAt, Labels: full.Labels}
100 if cs, err := c.Store.ListIssueComments(full.ID); err == nil {
101 for _, cm := range cs {
102 bi.Comments = append(bi.Comments, bundleComment{cm.Author, cm.Body, cm.CreatedAt})
103 }
104 }
105 br.Issues = append(br.Issues, bi)
106 }
107 mrs, _ := c.Store.ListMRs(r.ID, "all", 0, 0)
108 for i := len(mrs) - 1; i >= 0; i-- {
109 m := mrs[i]
110 bm := bundleMR{Number: m.Number, Title: m.Title, Body: m.Body, State: m.State,
111 Author: m.Author, SourceRef: m.SourceRef, TargetRef: m.TargetRef, CreatedAt: m.CreatedAt}
112 if cs, err := c.Store.ListMRComments(m.ID); err == nil {
113 for _, cm := range cs {
114 bm.Comments = append(bm.Comments, bundleComment{cm.Author, cm.Body, cm.CreatedAt})
115 }
116 }
117 br.MRs = append(br.MRs, bm)
118 }
119 b.Repos = append(b.Repos, br)
120 }
121 enc := json.NewEncoder(c.Stdout)
122 enc.SetIndent("", " ")
123 if err := enc.Encode(b); err != nil {
124 return protocol.ExitFailure
125 }
126 return protocol.ExitOK
127}
128
129func migAttribution(src, kind, author, date string, n int64) string {
130 return fmt.Sprintf("> migrated %s %s#%d — %s, %.10s\n\n", kind, src, n, author, date)
131}
132
133// runAccountImportBundle replays a bundle under the calling user. Repos are
134// created empty (the CLI pushes git data with the user's own key); issues,
135// MRs, and comments arrive attributed inline. Push-blocking policies
136// (require_signed_commits, protected_branches) are deferred and reported so
137// the git push that follows cannot be refused by them. Resumable: markers
138// skip everything already imported.
139func runAccountImportBundle(c *Ctx, args []string) int {
140 var src string
141 for i := 0; i < len(args); i++ {
142 if args[i] == "--source" && i+1 < len(args) {
143 src = args[i+1]
144 i++
145 } else {
146 return c.fail(protocol.ExitUsage, "usage: account import-bundle [--source <host>] < bundle.json")
147 }
148 }
149 if src == "" {
150 src = "the previous instance"
151 }
152 var b bundle
153 if err := json.NewDecoder(io.LimitReader(c.Stdin, 512<<20)).Decode(&b); err != nil {
154 return c.fail(protocol.ExitUsage, "bundle does not parse: %v", err)
155 }
156 if b.Bundle != bundleVersion {
157 return c.fail(protocol.ExitUsage, "unsupported bundle %q (want %s)", b.Bundle, bundleVersion)
158 }
159
160 if b.Profile.Description != "" || b.Profile.Website != "" {
161 c.Store.SetOwnerProfile("user", c.User.ID, b.Profile)
162 }
163 for _, addr := range b.Emails {
164 c.Store.AddEmail(c.User.ID, addr, "", false) // unverified; re-verify here
165 }
166
167 type deferred struct {
168 Repo string `json:"repo"`
169 Commands []string `json:"commands"`
170 }
171 var repos, issues, mrs, comments, skipped int
172 var deferrals []deferred
173 for _, br := range b.Repos {
174 path := c.User.Username + "/" + br.Name
175 repo, err := c.Store.RepoByPath(path)
176 if err != nil {
177 id, err := c.Store.CreateRepo("user", c.User.ID, br.Name, br.Visibility)
178 if err != nil {
179 return c.fail(protocol.ExitFailure, "creating %s: %v", path, err)
180 }
181 dir := RepoDir(c.Cfg.Server.Root, c.User.Username, br.Name)
182 branch := br.DefaultBranch
183 if branch == "" {
184 branch = "main"
185 }
186 if err := gitutil.InitBare(dir, branch, HooksDir(c.Cfg.Server.Root)); err != nil {
187 c.Store.DeleteRepo(id)
188 return c.fail(protocol.ExitFailure, "initializing %s: %v", path, err)
189 }
190 if br.Description != "" {
191 gitutil.WriteDescription(dir, br.Description)
192 }
193 repo, err = c.Store.RepoByPath(path)
194 if err != nil {
195 return c.fail(protocol.ExitFailure, "%v", err)
196 }
197 repos++
198 }
199 for _, tpc := range br.Topics {
200 c.Store.AddTopic(repo.ID, tpc)
201 }
202 // Settings minus the two that would refuse the git push coming
203 // right after this; the user re-applies them once data is in.
204 s := br.Settings
205 var cmds []string
206 if s.RequireSignedCommits {
207 cmds = append(cmds, fmt.Sprintf("gitbay repo settings require-signed %s on", path))
208 s.RequireSignedCommits = false
209 }
210 if len(s.ProtectedBranches) > 0 {
211 for _, pb := range s.ProtectedBranches {
212 cmds = append(cmds, fmt.Sprintf("gitbay repo settings protect %s %s", path, pb))
213 }
214 s.ProtectedBranches = nil
215 }
216 if len(cmds) > 0 {
217 deferrals = append(deferrals, deferred{path, cmds})
218 }
219 s.GitDaemon = false // instance-dependent; opt back in explicitly
220 c.Store.SetRepoSettings(repo.ID, s)
221
222 for _, bi := range br.Issues {
223 key := fmt.Sprintf("mig-issue:%d", bi.Number)
224 if _, seen, _ := c.Store.ImportMarker(repo.ID, key); seen {
225 skipped++
226 continue
227 }
228 body := migAttribution(src, "issue", bi.Author, bi.CreatedAt, bi.Number) + bi.Body
229 n, err := c.Store.CreateIssue(repo.ID, c.User.ID, bi.Title, body)
230 if err != nil {
231 return c.fail(protocol.ExitFailure, "%v", err)
232 }
233 iss, err := c.Store.IssueByNumber(repo.ID, n)
234 if err != nil {
235 return c.fail(protocol.ExitFailure, "%v", err)
236 }
237 for _, l := range bi.Labels {
238 c.Store.SetIssueLabel(repo.ID, iss.ID, l, true)
239 }
240 if bi.State != "open" {
241 c.Store.SetIssueState(iss.ID, "closed")
242 }
243 for _, cm := range bi.Comments {
244 c.Store.AddIssueComment(iss.ID, c.User.ID,
245 fmt.Sprintf("> %s, %.10s\n\n%s", cm.Author, cm.CreatedAt, cm.Body))
246 comments++
247 }
248 c.Store.SetImportMarker(repo.ID, key, fmt.Sprint(n))
249 issues++
250 }
251 for _, bm := range br.MRs {
252 key := fmt.Sprintf("mig-mr:%d", bm.Number)
253 if _, seen, _ := c.Store.ImportMarker(repo.ID, key); seen {
254 skipped++
255 continue
256 }
257 body := migAttribution(src, "merge request", bm.Author, bm.CreatedAt, bm.Number) + bm.Body
258 n, err := c.Store.CreateMR(repo.ID, c.User.ID, repo.ID, bm.SourceRef, bm.TargetRef, bm.Title, body, "")
259 if err != nil {
260 return c.fail(protocol.ExitFailure, "%v", err)
261 }
262 mr, err := c.Store.MRByNumber(repo.ID, n)
263 if err != nil {
264 return c.fail(protocol.ExitFailure, "%v", err)
265 }
266 if bm.State != "open" {
267 state := bm.State
268 if state == "source_gone" {
269 state = "closed"
270 }
271 c.Store.SetMRState(mr.ID, state)
272 }
273 for _, cm := range bm.Comments {
274 c.Store.AddMRComment(mr.ID, c.User.ID,
275 fmt.Sprintf("> %s, %.10s\n\n%s", cm.Author, cm.CreatedAt, cm.Body))
276 comments++
277 }
278 c.Store.SetImportMarker(repo.ID, key, fmt.Sprint(n))
279 mrs++
280 }
281 }
282 d := map[string]any{
283 "repos": repos, "issues": issues, "mrs": mrs, "comments": comments,
284 "already_imported": skipped, "deferred_settings": deferrals,
285 }
286 return c.emit(d, func(w io.Writer) {
287 fmt.Fprintf(w, "imported %d repos, %d issues, %d MRs, %d comments (%d already present)\n",
288 repos, issues, mrs, comments, skipped)
289 if len(deferrals) > 0 {
290 fmt.Fprintf(w, "\nafter pushing git data, re-apply the deferred policies:\n")
291 for _, df := range deferrals {
292 for _, cmd := range df.Commands {
293 fmt.Fprintf(w, " %s\n", cmd)
294 }
295 }
296 }
297 })
298}
299
300var _ = strings.TrimSpace // placeholder against accidental import drops