internal/control/migrate.go

9d8fd17820457f0c60320c9f4a8b0da1db45a6b0
gitbay/internal/control/migrate.go history · blame · raw

299 lines · 10490 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		s.GitDaemon = false // instance-dependent; opt back in explicitly
219		c.Store.SetRepoSettings(repo.ID, s)
220
221		for _, bi := range br.Issues {
222			key := fmt.Sprintf("mig-issue:%d", bi.Number)
223			if _, seen, _ := c.Store.ImportMarker(repo.ID, key); seen {
224				skipped++
225				continue
226			}
227			body := migAttribution(src, "issue", bi.Author, bi.CreatedAt, bi.Number) + bi.Body
228			n, err := c.Store.CreateIssue(repo.ID, c.User.ID, bi.Title, body, "md")
229			if err != nil {
230				return c.fail(protocol.ExitFailure, "%v", err)
231			}
232			iss, err := c.Store.IssueByNumber(repo.ID, n)
233			if err != nil {
234				return c.fail(protocol.ExitFailure, "%v", err)
235			}
236			for _, l := range bi.Labels {
237				c.Store.SetIssueLabel(repo.ID, iss.ID, l, true)
238			}
239			if bi.State != "open" {
240				c.Store.SetIssueState(iss.ID, "closed")
241			}
242			for _, cm := range bi.Comments {
243				c.Store.AddIssueComment(iss.ID, c.User.ID,
244					fmt.Sprintf("> %s, %.10s\n\n%s", cm.Author, cm.CreatedAt, cm.Body), "md")
245				comments++
246			}
247			c.Store.SetImportMarker(repo.ID, key, fmt.Sprint(n))
248			issues++
249		}
250		for _, bm := range br.MRs {
251			key := fmt.Sprintf("mig-mr:%d", bm.Number)
252			if _, seen, _ := c.Store.ImportMarker(repo.ID, key); seen {
253				skipped++
254				continue
255			}
256			body := migAttribution(src, "merge request", bm.Author, bm.CreatedAt, bm.Number) + bm.Body
257			n, err := c.Store.CreateMR(repo.ID, c.User.ID, repo.ID, bm.SourceRef, bm.TargetRef, bm.Title, body, "", "md")
258			if err != nil {
259				return c.fail(protocol.ExitFailure, "%v", err)
260			}
261			mr, err := c.Store.MRByNumber(repo.ID, n)
262			if err != nil {
263				return c.fail(protocol.ExitFailure, "%v", err)
264			}
265			if bm.State != "open" {
266				state := bm.State
267				if state == "source_gone" {
268					state = "closed"
269				}
270				c.Store.SetMRState(mr.ID, state)
271			}
272			for _, cm := range bm.Comments {
273				c.Store.AddMRComment(mr.ID, c.User.ID,
274					fmt.Sprintf("> %s, %.10s\n\n%s", cm.Author, cm.CreatedAt, cm.Body), "md")
275				comments++
276			}
277			c.Store.SetImportMarker(repo.ID, key, fmt.Sprint(n))
278			mrs++
279		}
280	}
281	d := map[string]any{
282		"repos": repos, "issues": issues, "mrs": mrs, "comments": comments,
283		"already_imported": skipped, "deferred_settings": deferrals,
284	}
285	return c.emit(d, func(w io.Writer) {
286		fmt.Fprintf(w, "imported %d repos, %d issues, %d MRs, %d comments (%d already present)\n",
287			repos, issues, mrs, comments, skipped)
288		if len(deferrals) > 0 {
289			fmt.Fprintf(w, "\nafter pushing git data, re-apply the deferred policies:\n")
290			for _, df := range deferrals {
291				for _, cmd := range df.Commands {
292					fmt.Fprintf(w, "  %s\n", cmd)
293				}
294			}
295		}
296	})
297}
298
299var _ = strings.TrimSpace // placeholder against accidental import drops