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