internal/control/migrate.go

4744c629ed4f0158bdea7d0f1d207fdccb1f7f99
gitbay/internal/control/migrate.go history · blame · raw

301 lines · 10377 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		b.Profile.About != "" || len(b.Profile.Links) > 0 {
162		c.Store.SetOwnerProfile("user", c.User.ID, b.Profile)
163	}
164	for _, addr := range b.Emails {
165		c.Store.AddEmail(c.User.ID, addr, "", false) // unverified; re-verify here
166	}
167
168	type deferred struct {
169		Repo     string   `json:"repo"`
170		Commands []string `json:"commands"`
171	}
172	var repos, issues, mrs, comments, skipped int
173	var deferrals []deferred
174	for _, br := range b.Repos {
175		path := c.User.Username + "/" + br.Name
176		repo, err := c.Store.RepoByPath(path)
177		if err != nil {
178			id, err := c.Store.CreateRepo("user", c.User.ID, br.Name, br.Visibility)
179			if err != nil {
180				return c.fail(protocol.ExitFailure, "creating %s: %v", path, err)
181			}
182			dir := RepoDir(c.Cfg.Server.Root, c.User.Username, br.Name)
183			branch := br.DefaultBranch
184			if branch == "" {
185				branch = "main"
186			}
187			if err := gitutil.InitBare(dir, branch, HooksDir(c.Cfg.Server.Root)); err != nil {
188				c.Store.DeleteRepo(id)
189				return c.fail(protocol.ExitFailure, "initializing %s: %v", path, err)
190			}
191			if br.Description != "" {
192				gitutil.WriteDescription(dir, br.Description)
193			}
194			repo, err = c.Store.RepoByPath(path)
195			if err != nil {
196				return c.fail(protocol.ExitFailure, "%v", err)
197			}
198			repos++
199		}
200		for _, tpc := range br.Topics {
201			c.Store.AddTopic(repo.ID, tpc)
202		}
203		// Settings minus the two that would refuse the git push coming
204		// right after this; the user re-applies them once data is in.
205		s := br.Settings
206		var cmds []string
207		if s.RequireSignedCommits {
208			cmds = append(cmds, fmt.Sprintf("gitbay repo settings require-signed %s on", path))
209			s.RequireSignedCommits = false
210		}
211		if len(s.ProtectedBranches) > 0 {
212			for _, pb := range s.ProtectedBranches {
213				cmds = append(cmds, fmt.Sprintf("gitbay repo settings protect %s %s", path, pb))
214			}
215			s.ProtectedBranches = nil
216		}
217		if len(cmds) > 0 {
218			deferrals = append(deferrals, deferred{path, cmds})
219		}
220		s.GitDaemon = false // instance-dependent; opt back in explicitly
221		c.Store.SetRepoSettings(repo.ID, 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)
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))
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, "")
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))
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